From b761be1f92c3c73e5d5b6ace956bccd558d21dd4 Mon Sep 17 00:00:00 2001 From: MakD Date: Wed, 4 Feb 2026 10:47:24 +0530 Subject: [PATCH 01/19] chore: Add extended Material Icons dependency This commit adds the `androidx.compose.material:material-icons-extended` dependency to the project. This provides access to a wider range of icons for use in the app's user interface. --- app/build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 99ed2312..48952d0c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3.window.size.class1) implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.compose.m3) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afc998e0..eeee853f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -95,6 +95,7 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } libmpv = { group = "dev.jdtech.mpv", name = "libmpv", version.ref = "libmpv" } From 1d7dccf8357edcc9a0907a9bac90b8d3d50139d4 Mon Sep 17 00:00:00 2001 From: MakD Date: Wed, 4 Feb 2026 15:14:37 +0530 Subject: [PATCH 02/19] feat(audiobookshelf): Add Audiobookshelf integration This commit introduces a comprehensive integration with Audiobookshelf, allowing users to connect their Audiobookshelf server, browse libraries, and play audiobooks and podcasts directly within the app. This feature establishes a complete end-to-end experience, from authentication to playback, managed within the user's existing Jellyfin session context. ### Key Additions & Changes: * **Authentication & Configuration**: * A new login flow (`AudiobookshelfLoginScreen`) allows users to connect to their Audiobookshelf server. * Authentication details and server configuration are securely stored per Jellyfin user profile using `SecurePreferencesRepository` and `AudiobookshelfConfigEntity`. * The `SessionManager` now links Audiobookshelf sessions to the active Jellyfin session. * **API & Data Layer**: * Added `AudiobookshelfApiService` with Retrofit for all network requests to the Audiobookshelf server. * A new `AudiobookshelfRepository` manages data fetching, caching, and state for libraries, items, and progress. * Introduced Room DAO (`AudiobookshelfDao`) and entities (`AudiobookshelfLibraryEntity`, `AudiobookshelfItemEntity`, `AudiobookshelfProgressEntity`) to cache data locally. * New serializable data models for all Audiobookshelf API responses have been created. * **UI & Navigation**: * **Settings**: A new entry in the main settings screen to manage the Audiobookshelf connection. * **Main Navigation**: Added a new "Audiobooks" destination to the bottom navigation bar, which becomes visible upon successful authentication. * **Libraries Screen**: A new screen (`AudiobookshelfLibrariesScreen`) to display a user's libraries and a "Continue Listening" section. * **Library View**: A dedicated screen (`AudiobookshelfLibraryScreen`) to browse and search items within a specific library, with distinct layouts for audiobooks (grid) and podcasts (list). * **Item Details**: A detail screen (`AudiobookshelfItemScreen`) shows comprehensive information about a selected audiobook or podcast, including metadata, description, and chapter/episode lists. * **Playback System**: * **Player Screen**: A new, dedicated player UI (`AudiobookshelfPlayerScreen`) for audio playback, featuring controls for play/pause, seeking, skipping, chapters, playback speed, and a sleep timer. * **Mini Player**: A persistent mini-player appears at the bottom of the app for navigating while listening. * **Playback Engine**: Implemented `AudiobookshelfPlayer` using ExoPlayer to handle media playback from Audiobookshelf sessions. It also correctly handles token-based authentication for media streams. * **Progress Syncing**: `AudiobookshelfProgressSyncer` is introduced to periodically sync playback progress back to the Audiobookshelf server, ensuring continuity across devices. * **Session Management**: The player gracefully handles pausing/stopping Jellyfin video playback if audio playback is initiated, and vice-versa. * **Background Playback**: An `AudiobookshelfPlayerService` and `MediaSession` integration enable background audio playback and notification controls. --- app/src/main/AndroidManifest.xml | 10 + .../afinity/data/database/AfinityDatabase.kt | 13 +- .../data/database/DatabaseMigrations.kt | 174 +++- .../data/database/dao/AudiobookshelfDao.kt | 153 +++ .../entities/AudiobookshelfConfigEntity.kt | 17 + .../entities/AudiobookshelfItemEntity.kt | 30 + .../entities/AudiobookshelfLibraryEntity.kt | 21 + .../entities/AudiobookshelfProgressEntity.kt | 23 + .../afinity/data/manager/SessionManager.kt | 6 + .../audiobookshelf/AudiobookshelfItem.kt | 514 ++++++++++ .../audiobookshelf/AudiobookshelfLibrary.kt | 177 ++++ .../audiobookshelf/AudiobookshelfPlayback.kt | 279 ++++++ .../audiobookshelf/AudiobookshelfUser.kt | 146 +++ .../data/network/AudiobookshelfApiService.kt | 132 +++ .../repository/AudiobookshelfRepository.kt | 104 +++ .../repository/SecurePreferencesRepository.kt | 33 +- .../AudiobookshelfRepositoryImpl.kt | 878 ++++++++++++++++++ .../impl/SecurePreferencesRepositoryImpl.kt | 133 +++ .../makd/afinity/di/AudiobookshelfModule.kt | 31 + .../com/makd/afinity/di/HiltQualifiers.kt | 6 +- .../java/com/makd/afinity/di/NetworkModule.kt | 108 +++ .../makd/afinity/navigation/Destination.kt | 43 +- .../makd/afinity/navigation/MainNavigation.kt | 152 ++- .../navigation/MainNavigationViewModel.kt | 9 +- .../AudiobookshelfPlaybackManager.kt | 120 +++ .../audiobookshelf/AudiobookshelfPlayer.kt | 315 +++++++ .../AudiobookshelfPlayerService.kt | 79 ++ .../AudiobookshelfProgressSyncer.kt | 109 +++ .../item/AudiobookshelfItemScreen.kt | 153 +++ .../item/AudiobookshelfItemViewModel.kt | 86 ++ .../item/components/ChapterList.kt | 134 +++ .../item/components/EpisodeList.kt | 138 +++ .../item/components/ItemHeader.kt | 208 +++++ .../AudiobookshelfLibrariesScreen.kt | 232 +++++ .../AudiobookshelfLibrariesViewModel.kt | 73 ++ .../libraries/components/LibraryCard.kt | 93 ++ .../library/AudiobookshelfLibraryScreen.kt | 197 ++++ .../library/AudiobookshelfLibraryViewModel.kt | 137 +++ .../library/components/AudiobookCard.kt | 96 ++ .../library/components/PodcastCard.kt | 107 +++ .../login/AudiobookshelfLoginScreen.kt | 327 +++++++ .../login/AudiobookshelfLoginViewModel.kt | 171 ++++ .../player/AudiobookshelfPlayerScreen.kt | 263 ++++++ .../player/AudiobookshelfPlayerViewModel.kt | 161 ++++ .../player/components/ChapterSelector.kt | 169 ++++ .../player/components/MiniPlayer.kt | 140 +++ .../components/PlaybackSpeedSelector.kt | 112 +++ .../player/components/PlayerControls.kt | 165 ++++ .../player/components/SleepTimerDialog.kt | 164 ++++ .../makd/afinity/ui/player/PlayerViewModel.kt | 16 +- .../afinity/ui/settings/SettingsScreen.kt | 58 ++ .../afinity/ui/settings/SettingsViewModel.kt | 30 +- .../res/drawable/ic_headphones_filled.xml | 12 + app/src/main/res/values/strings.xml | 7 + gradle.properties | 12 +- gradle/libs.versions.toml | 24 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 57 files changed, 7249 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt create mode 100644 app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfConfigEntity.kt create mode 100644 app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfItemEntity.kt create mode 100644 app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfLibraryEntity.kt create mode 100644 app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfProgressEntity.kt create mode 100644 app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt create mode 100644 app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt create mode 100644 app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt create mode 100644 app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt create mode 100644 app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt create mode 100644 app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt create mode 100644 app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt create mode 100644 app/src/main/java/com/makd/afinity/di/AudiobookshelfModule.kt create mode 100644 app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlaybackManager.kt create mode 100644 app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt create mode 100644 app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt create mode 100644 app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfProgressSyncer.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemViewModel.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryViewModel.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginViewModel.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt create mode 100644 app/src/main/res/drawable/ic_headphones_filled.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2aa45fcd..c1cba647 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt b/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt index e7b6d01d..3ec0069d 100644 --- a/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt +++ b/app/src/main/java/com/makd/afinity/data/database/AfinityDatabase.kt @@ -5,6 +5,7 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.makd.afinity.data.database.dao.AudiobookshelfDao import com.makd.afinity.data.database.dao.BoxSetCacheDao import com.makd.afinity.data.database.dao.EpisodeDao import com.makd.afinity.data.database.dao.GenreCacheDao @@ -38,6 +39,10 @@ import com.makd.afinity.data.database.entities.DownloadDto import com.makd.afinity.data.database.entities.GenreCacheEntity import com.makd.afinity.data.database.entities.GenreMovieCacheEntity import com.makd.afinity.data.database.entities.GenreShowCacheEntity +import com.makd.afinity.data.database.entities.AudiobookshelfConfigEntity +import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity +import com.makd.afinity.data.database.entities.AudiobookshelfLibraryEntity +import com.makd.afinity.data.database.entities.AudiobookshelfProgressEntity import com.makd.afinity.data.database.entities.JellyseerrConfigEntity import com.makd.afinity.data.database.entities.JellyseerrRequestEntity import com.makd.afinity.data.database.entities.LibraryCacheEntity @@ -90,8 +95,13 @@ import com.makd.afinity.data.models.user.User JellyseerrRequestEntity::class, JellyseerrConfigEntity::class, + + AudiobookshelfConfigEntity::class, + AudiobookshelfLibraryEntity::class, + AudiobookshelfItemEntity::class, + AudiobookshelfProgressEntity::class, ], - version = 25, + version = 26, exportSchema = false ) @TypeConverters(AfinityTypeConverters::class) @@ -120,6 +130,7 @@ abstract class AfinityDatabase : RoomDatabase() { abstract fun personSectionDao(): PersonSectionDao abstract fun movieSectionDao(): MovieSectionDao abstract fun jellyseerrDao(): JellyseerrDao + abstract fun audiobookshelfDao(): AudiobookshelfDao companion object { @Volatile diff --git a/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt b/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt index 24fad652..276c0969 100644 --- a/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt +++ b/app/src/main/java/com/makd/afinity/data/database/DatabaseMigrations.kt @@ -202,26 +202,31 @@ object DatabaseMigrations { val defaultServerId = "default-server-migration" db.execSQL("DROP TABLE IF EXISTS servers_new") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE servers_new ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, version TEXT, address TEXT NOT NULL ) - """.trimIndent()) + """.trimIndent() + ) - db.execSQL(""" + db.execSQL( + """ INSERT INTO servers_new (id, name, version, address) SELECT id, name, version, '$defaultServerId' FROM servers - """.trimIndent()) + """.trimIndent() + ) db.execSQL("DROP TABLE servers") db.execSQL("ALTER TABLE servers_new RENAME TO servers") db.execSQL("DROP TABLE IF EXISTS movies_new") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE movies_new ( id TEXT PRIMARY KEY NOT NULL, serverId TEXT NOT NULL, @@ -243,21 +248,25 @@ object DatabaseMigrations { tagline TEXT, people TEXT ) - """.trimIndent()) + """.trimIndent() + ) - db.execSQL(""" + db.execSQL( + """ INSERT INTO movies_new SELECT id, COALESCE(serverId, '$defaultServerId'), name, originalTitle, overview, runtimeTicks, premiereDate, dateCreated, communityRating, officialRating, criticRating, status, productionYear, endDate, chapters, images, genres, tagline, people FROM movies - """.trimIndent()) + """.trimIndent() + ) db.execSQL("DROP TABLE movies") db.execSQL("ALTER TABLE movies_new RENAME TO movies") db.execSQL("DROP TABLE IF EXISTS shows_new") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE shows_new ( id TEXT PRIMARY KEY NOT NULL, serverId TEXT NOT NULL, @@ -277,22 +286,26 @@ object DatabaseMigrations { genres TEXT, people TEXT ) - """.trimIndent()) + """.trimIndent() + ) - db.execSQL(""" + db.execSQL( + """ INSERT INTO shows_new SELECT id, COALESCE(serverId, '$defaultServerId'), name, originalTitle, overview, runtimeTicks, communityRating, officialRating, status, productionYear, premiereDate, dateCreated, dateLastContentAdded, endDate, images, genres, people FROM shows - """.trimIndent()) + """.trimIndent() + ) db.execSQL("DROP TABLE shows") db.execSQL("ALTER TABLE shows_new RENAME TO shows") db.execSQL("DROP INDEX IF EXISTS index_seasons_seriesId") db.execSQL("DROP TABLE IF EXISTS seasons_new") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE seasons_new ( id TEXT PRIMARY KEY NOT NULL, serverId TEXT NOT NULL, @@ -304,15 +317,18 @@ object DatabaseMigrations { images TEXT, FOREIGN KEY (seriesId) REFERENCES shows(id) ON DELETE CASCADE ) - """.trimIndent()) + """.trimIndent() + ) db.execSQL("CREATE INDEX index_seasons_seriesId ON seasons_new(seriesId)") - db.execSQL(""" + db.execSQL( + """ INSERT INTO seasons_new SELECT id, '$defaultServerId', seriesId, name, seriesName, overview, indexNumber, images FROM seasons - """.trimIndent()) + """.trimIndent() + ) db.execSQL("DROP TABLE seasons") db.execSQL("ALTER TABLE seasons_new RENAME TO seasons") @@ -320,7 +336,8 @@ object DatabaseMigrations { db.execSQL("DROP INDEX IF EXISTS index_episodes_seasonId") db.execSQL("DROP INDEX IF EXISTS index_episodes_seriesId") db.execSQL("DROP TABLE IF EXISTS episodes_new") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE episodes_new ( id TEXT PRIMARY KEY NOT NULL, serverId TEXT NOT NULL, @@ -340,24 +357,28 @@ object DatabaseMigrations { FOREIGN KEY (seasonId) REFERENCES seasons(id) ON DELETE CASCADE, FOREIGN KEY (seriesId) REFERENCES shows(id) ON DELETE CASCADE ) - """.trimIndent()) + """.trimIndent() + ) db.execSQL("CREATE INDEX index_episodes_seasonId ON episodes_new(seasonId)") db.execSQL("CREATE INDEX index_episodes_seriesId ON episodes_new(seriesId)") - db.execSQL(""" + db.execSQL( + """ INSERT INTO episodes_new SELECT id, COALESCE(serverId, '$defaultServerId'), seasonId, seriesId, name, seriesName, overview, indexNumber, indexNumberEnd, parentIndexNumber, runtimeTicks, premiereDate, communityRating, chapters, images FROM episodes - """.trimIndent()) + """.trimIndent() + ) db.execSQL("DROP TABLE episodes") db.execSQL("ALTER TABLE episodes_new RENAME TO episodes") db.execSQL("DROP TABLE IF EXISTS userdata_new") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE userdata_new ( userId TEXT NOT NULL, itemId TEXT NOT NULL, @@ -368,13 +389,16 @@ object DatabaseMigrations { toBeSynced INTEGER NOT NULL, PRIMARY KEY (userId, itemId, serverId) ) - """.trimIndent()) + """.trimIndent() + ) - db.execSQL(""" + db.execSQL( + """ INSERT INTO userdata_new SELECT userId, itemId, '$defaultServerId', played, favorite, playbackPositionTicks, toBeSynced FROM userdata - """.trimIndent()) + """.trimIndent() + ) db.execSQL("DROP TABLE userdata") db.execSQL("ALTER TABLE userdata_new RENAME TO userdata") @@ -384,7 +408,8 @@ object DatabaseMigrations { val MIGRATION_23_24 = object : Migration(23, 24) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS jellyseerr_config") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE IF NOT EXISTS jellyseerr_config ( jellyfinServerId TEXT NOT NULL, jellyfinUserId TEXT NOT NULL, @@ -395,10 +420,12 @@ object DatabaseMigrations { permissions INTEGER, PRIMARY KEY(jellyfinServerId, jellyfinUserId) ) - """.trimIndent()) + """.trimIndent() + ) db.execSQL("DROP TABLE IF EXISTS jellyseerr_requests") - db.execSQL(""" + db.execSQL( + """ CREATE TABLE IF NOT EXISTS jellyseerr_requests ( id INTEGER NOT NULL, jellyfinServerId TEXT NOT NULL, @@ -422,7 +449,8 @@ object DatabaseMigrations { mediaStatus INTEGER, PRIMARY KEY(id, jellyfinServerId, jellyfinUserId) ) - """.trimIndent()) + """.trimIndent() + ) } } @@ -433,6 +461,93 @@ object DatabaseMigrations { } } + val MIGRATION_25_26 = object : Migration(25, 26) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS audiobookshelf_config ( + jellyfinServerId TEXT NOT NULL, + jellyfinUserId TEXT NOT NULL, + serverUrl TEXT NOT NULL, + absUserId TEXT NOT NULL, + username TEXT NOT NULL, + isLoggedIn INTEGER NOT NULL, + lastSync INTEGER NOT NULL, + PRIMARY KEY(jellyfinServerId, jellyfinUserId) + ) + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS audiobookshelf_libraries ( + id TEXT NOT NULL, + jellyfinServerId TEXT NOT NULL, + jellyfinUserId TEXT NOT NULL, + name TEXT NOT NULL, + mediaType TEXT NOT NULL, + icon TEXT, + displayOrder INTEGER NOT NULL, + totalItems INTEGER NOT NULL, + totalDuration REAL, + lastUpdated INTEGER NOT NULL, + cachedAt INTEGER NOT NULL, + PRIMARY KEY(id, jellyfinServerId, jellyfinUserId) + ) + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS audiobookshelf_items ( + id TEXT NOT NULL, + jellyfinServerId TEXT NOT NULL, + jellyfinUserId TEXT NOT NULL, + libraryId TEXT NOT NULL, + title TEXT NOT NULL, + authorName TEXT, + narratorName TEXT, + seriesName TEXT, + seriesSequence TEXT, + mediaType TEXT NOT NULL, + duration REAL, + coverUrl TEXT, + description TEXT, + publishedYear TEXT, + genres TEXT, + numTracks INTEGER, + numChapters INTEGER, + addedAt INTEGER, + updatedAt INTEGER, + cachedAt INTEGER NOT NULL, + PRIMARY KEY(id, jellyfinServerId, jellyfinUserId) + ) + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS audiobookshelf_progress ( + id TEXT NOT NULL, + jellyfinServerId TEXT NOT NULL, + jellyfinUserId TEXT NOT NULL, + libraryItemId TEXT NOT NULL, + episodeId TEXT, + currentTime REAL NOT NULL, + duration REAL NOT NULL, + progress REAL NOT NULL, + isFinished INTEGER NOT NULL, + lastUpdate INTEGER NOT NULL, + startedAt INTEGER NOT NULL, + finishedAt INTEGER, + pendingSync INTEGER NOT NULL, + PRIMARY KEY(id, jellyfinServerId, jellyfinUserId) + ) + """.trimIndent() + ) + } + } + val ALL_MIGRATIONS = arrayOf( MIGRATION_1_2, MIGRATION_2_3, @@ -453,6 +568,7 @@ object DatabaseMigrations { MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, - MIGRATION_24_25 + MIGRATION_24_25, + MIGRATION_25_26 ) } \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt new file mode 100644 index 00000000..21b39932 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/database/dao/AudiobookshelfDao.kt @@ -0,0 +1,153 @@ +package com.makd.afinity.data.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.makd.afinity.data.database.entities.AudiobookshelfConfigEntity +import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity +import com.makd.afinity.data.database.entities.AudiobookshelfLibraryEntity +import com.makd.afinity.data.database.entities.AudiobookshelfProgressEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AudiobookshelfDao { + + @Query("SELECT * FROM audiobookshelf_config WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun getConfig(serverId: String, userId: String): AudiobookshelfConfigEntity? + + @Query("SELECT * FROM audiobookshelf_config WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId") + fun getConfigFlow(serverId: String, userId: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertConfig(config: AudiobookshelfConfigEntity) + + @Query("DELETE FROM audiobookshelf_config WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun deleteConfig(serverId: String, userId: String) + + @Query("SELECT * FROM audiobookshelf_libraries WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId ORDER BY displayOrder ASC") + fun getLibrariesFlow(serverId: String, userId: String): Flow> + + @Query("SELECT * FROM audiobookshelf_libraries WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId ORDER BY displayOrder ASC") + suspend fun getLibraries(serverId: String, userId: String): List + + @Query("SELECT * FROM audiobookshelf_libraries WHERE id = :libraryId AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun getLibrary( + libraryId: String, + serverId: String, + userId: String + ): AudiobookshelfLibraryEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLibraries(libraries: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLibrary(library: AudiobookshelfLibraryEntity) + + @Query("DELETE FROM audiobookshelf_libraries WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun deleteAllLibraries(serverId: String, userId: String) + + @Query("SELECT * FROM audiobookshelf_items WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId AND libraryId = :libraryId ORDER BY title ASC") + fun getItemsFlow( + serverId: String, + userId: String, + libraryId: String + ): Flow> + + @Query("SELECT * FROM audiobookshelf_items WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId AND libraryId = :libraryId ORDER BY title ASC") + suspend fun getItems( + serverId: String, + userId: String, + libraryId: String + ): List + + @Query("SELECT * FROM audiobookshelf_items WHERE id = :itemId AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun getItem(itemId: String, serverId: String, userId: String): AudiobookshelfItemEntity? + + @Query("SELECT * FROM audiobookshelf_items WHERE id = :itemId AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + fun getItemFlow( + itemId: String, + serverId: String, + userId: String + ): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: AudiobookshelfItemEntity) + + @Query("DELETE FROM audiobookshelf_items WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId AND libraryId = :libraryId") + suspend fun deleteItemsByLibrary(serverId: String, userId: String, libraryId: String) + + @Query("DELETE FROM audiobookshelf_items WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun deleteAllItems(serverId: String, userId: String) + + @Query("SELECT * FROM audiobookshelf_items WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId AND (title LIKE '%' || :query || '%' OR authorName LIKE '%' || :query || '%') ORDER BY title ASC") + suspend fun searchItems( + serverId: String, + userId: String, + query: String + ): List + + @Query("SELECT * FROM audiobookshelf_progress WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId ORDER BY lastUpdate DESC") + fun getAllProgressFlow( + serverId: String, + userId: String + ): Flow> + + @Query("SELECT * FROM audiobookshelf_progress WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId AND isFinished = 0 ORDER BY lastUpdate DESC") + fun getInProgressFlow( + serverId: String, + userId: String + ): Flow> + + @Query("SELECT * FROM audiobookshelf_progress WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId AND isFinished = 0 ORDER BY lastUpdate DESC") + suspend fun getInProgress(serverId: String, userId: String): List + + @Query("SELECT * FROM audiobookshelf_progress WHERE libraryItemId = :itemId AND jellyfinServerId = :serverId AND jellyfinUserId = :userId AND (episodeId IS NULL OR episodeId = '')") + suspend fun getProgressForItem( + itemId: String, + serverId: String, + userId: String + ): AudiobookshelfProgressEntity? + + @Query("SELECT * FROM audiobookshelf_progress WHERE libraryItemId = :itemId AND episodeId = :episodeId AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun getProgressForEpisode( + itemId: String, + episodeId: String, + serverId: String, + userId: String + ): AudiobookshelfProgressEntity? + + @Query("SELECT * FROM audiobookshelf_progress WHERE libraryItemId = :itemId AND jellyfinServerId = :serverId AND jellyfinUserId = :userId AND (episodeId IS NULL OR episodeId = '')") + fun getProgressForItemFlow( + itemId: String, + serverId: String, + userId: String + ): Flow + + @Query("SELECT * FROM audiobookshelf_progress WHERE pendingSync = 1 AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun getPendingSyncProgress( + serverId: String, + userId: String + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertProgress(progress: AudiobookshelfProgressEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertProgressList(progressList: List) + + @Query("UPDATE audiobookshelf_progress SET pendingSync = 0 WHERE id = :progressId AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun markSynced(progressId: String, serverId: String, userId: String) + + @Query("DELETE FROM audiobookshelf_progress WHERE jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun deleteAllProgress(serverId: String, userId: String) + + @Query("DELETE FROM audiobookshelf_items WHERE cachedAt < :expiryTime AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun deleteExpiredItems(expiryTime: Long, serverId: String, userId: String) + + @Query("DELETE FROM audiobookshelf_libraries WHERE cachedAt < :expiryTime AND jellyfinServerId = :serverId AND jellyfinUserId = :userId") + suspend fun deleteExpiredLibraries(expiryTime: Long, serverId: String, userId: String) +} diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfConfigEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfConfigEntity.kt new file mode 100644 index 00000000..856a42b9 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfConfigEntity.kt @@ -0,0 +1,17 @@ +package com.makd.afinity.data.database.entities + +import androidx.room.Entity + +@Entity( + tableName = "audiobookshelf_config", + primaryKeys = ["jellyfinServerId", "jellyfinUserId"] +) +data class AudiobookshelfConfigEntity( + val jellyfinServerId: String, + val jellyfinUserId: String, + val serverUrl: String, + val absUserId: String, + val username: String, + val isLoggedIn: Boolean, + val lastSync: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfItemEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfItemEntity.kt new file mode 100644 index 00000000..9101c055 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfItemEntity.kt @@ -0,0 +1,30 @@ +package com.makd.afinity.data.database.entities + +import androidx.room.Entity + +@Entity( + tableName = "audiobookshelf_items", + primaryKeys = ["id", "jellyfinServerId", "jellyfinUserId"] +) +data class AudiobookshelfItemEntity( + val id: String, + val jellyfinServerId: String, + val jellyfinUserId: String, + val libraryId: String, + val title: String, + val authorName: String?, + val narratorName: String?, + val seriesName: String?, + val seriesSequence: String?, + val mediaType: String, + val duration: Double?, + val coverUrl: String?, + val description: String?, + val publishedYear: String?, + val genres: String?, + val numTracks: Int?, + val numChapters: Int?, + val addedAt: Long?, + val updatedAt: Long?, + val cachedAt: Long +) diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfLibraryEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfLibraryEntity.kt new file mode 100644 index 00000000..c8323c0c --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfLibraryEntity.kt @@ -0,0 +1,21 @@ +package com.makd.afinity.data.database.entities + +import androidx.room.Entity + +@Entity( + tableName = "audiobookshelf_libraries", + primaryKeys = ["id", "jellyfinServerId", "jellyfinUserId"] +) +data class AudiobookshelfLibraryEntity( + val id: String, + val jellyfinServerId: String, + val jellyfinUserId: String, + val name: String, + val mediaType: String, + val icon: String?, + val displayOrder: Int, + val totalItems: Int, + val totalDuration: Double?, + val lastUpdated: Long, + val cachedAt: Long +) diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfProgressEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfProgressEntity.kt new file mode 100644 index 00000000..ef9263ff --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/database/entities/AudiobookshelfProgressEntity.kt @@ -0,0 +1,23 @@ +package com.makd.afinity.data.database.entities + +import androidx.room.Entity + +@Entity( + tableName = "audiobookshelf_progress", + primaryKeys = ["id", "jellyfinServerId", "jellyfinUserId"] +) +data class AudiobookshelfProgressEntity( + val id: String, + val jellyfinServerId: String, + val jellyfinUserId: String, + val libraryItemId: String, + val episodeId: String?, + val currentTime: Double, + val duration: Double, + val progress: Double, + val isFinished: Boolean, + val lastUpdate: Long, + val startedAt: Long, + val finishedAt: Long?, + val pendingSync: Boolean +) diff --git a/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt b/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt index 8a7da2c2..693d5faa 100644 --- a/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt +++ b/app/src/main/java/com/makd/afinity/data/manager/SessionManager.kt @@ -4,6 +4,7 @@ import android.content.Context import com.makd.afinity.BuildConfig import com.makd.afinity.data.models.server.Server import com.makd.afinity.data.models.user.User +import com.makd.afinity.data.repository.AudiobookshelfRepository import com.makd.afinity.data.repository.DatabaseRepository import com.makd.afinity.data.repository.JellyseerrRepository import com.makd.afinity.data.repository.SecurePreferencesRepository @@ -50,6 +51,7 @@ class SessionManager @Inject constructor( private val sessionPreferences: SessionPreferences, private val securePrefsRepository: SecurePreferencesRepository, private val jellyseerrRepository: JellyseerrRepository, + private val audiobookshelfRepository: AudiobookshelfRepository, @ApplicationContext private val context: Context ) { private val _currentSession = MutableStateFlow(null) @@ -110,6 +112,9 @@ class SessionManager @Inject constructor( jellyseerrRepository.setActiveJellyfinSession(serverId, userId) Timber.d("Linked Jellyseerr session for user: $userId") + audiobookshelfRepository.setActiveJellyfinSession(serverId, userId) + Timber.d("Linked Audiobookshelf session for user: $userId") + val session = Session( serverId = serverId, userId = userId, @@ -253,6 +258,7 @@ class SessionManager @Inject constructor( sessionPreferences.clearSession() jellyseerrRepository.clearActiveSession() + audiobookshelfRepository.clearActiveSession() _currentSession.value = null _connectionState.value = ConnectionState.Disconnected authRepository.clearAllAuthData() diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt new file mode 100644 index 00000000..55258085 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfItem.kt @@ -0,0 +1,514 @@ +package com.makd.afinity.data.models.audiobookshelf + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LibraryItem( + @SerialName("id") + val id: String, + @SerialName("ino") + val ino: String? = null, + @SerialName("oldLibraryItemId") + val oldLibraryItemId: String? = null, + @SerialName("libraryId") + val libraryId: String, + @SerialName("folderId") + val folderId: String? = null, + @SerialName("path") + val path: String? = null, + @SerialName("relPath") + val relPath: String? = null, + @SerialName("isFile") + val isFile: Boolean? = null, + @SerialName("mtimeMs") + val mtimeMs: Long? = null, + @SerialName("ctimeMs") + val ctimeMs: Long? = null, + @SerialName("birthtimeMs") + val birthtimeMs: Long? = null, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null, + @SerialName("isMissing") + val isMissing: Boolean? = null, + @SerialName("isInvalid") + val isInvalid: Boolean? = null, + @SerialName("mediaType") + val mediaType: String, + @SerialName("media") + val media: Media, + @SerialName("numFiles") + val numFiles: Int? = null, + @SerialName("size") + val size: Long? = null, + @SerialName("userMediaProgress") + val userMediaProgress: MediaProgress? = null, + @SerialName("rssFeedUrl") + val rssFeedUrl: String? = null +) + +@Serializable +data class Media( + @SerialName("id") + val id: String? = null, + @SerialName("libraryItemId") + val libraryItemId: String? = null, + @SerialName("metadata") + val metadata: MediaMetadata, + @SerialName("coverPath") + val coverPath: String? = null, + @SerialName("tags") + val tags: List? = null, + @SerialName("audioFiles") + val audioFiles: List? = null, + @SerialName("chapters") + val chapters: List? = null, + @SerialName("duration") + val duration: Double? = null, + @SerialName("size") + val size: Long? = null, + @SerialName("tracks") + val tracks: List? = null, + @SerialName("episodes") + val episodes: List? = null, + @SerialName("autoDownloadEpisodes") + val autoDownloadEpisodes: Boolean? = null, + @SerialName("autoDownloadSchedule") + val autoDownloadSchedule: String? = null, + @SerialName("lastEpisodeCheck") + val lastEpisodeCheck: Long? = null, + @SerialName("maxEpisodesToKeep") + val maxEpisodesToKeep: Int? = null, + @SerialName("maxNewEpisodesToDownload") + val maxNewEpisodesToDownload: Int? = null, + @SerialName("ebookFile") + val ebookFile: EbookFile? = null, + @SerialName("numTracks") + val numTracks: Int? = null, + @SerialName("numAudioFiles") + val numAudioFiles: Int? = null, + @SerialName("numChapters") + val numChapters: Int? = null, + @SerialName("numMissingParts") + val numMissingParts: Int? = null, + @SerialName("numInvalidAudioFiles") + val numInvalidAudioFiles: Int? = null +) + +@Serializable +data class MediaMetadata( + @SerialName("title") + val title: String? = null, + @SerialName("titleIgnorePrefix") + val titleIgnorePrefix: String? = null, + @SerialName("subtitle") + val subtitle: String? = null, + @SerialName("authorName") + val authorName: String? = null, + @SerialName("authorNameLF") + val authorNameLF: String? = null, + @SerialName("narratorName") + val narratorName: String? = null, + @SerialName("seriesName") + val seriesName: String? = null, + @SerialName("genres") + val genres: List? = null, + @SerialName("publishedYear") + val publishedYear: String? = null, + @SerialName("publishedDate") + val publishedDate: String? = null, + @SerialName("publisher") + val publisher: String? = null, + @SerialName("description") + val description: String? = null, + @SerialName("isbn") + val isbn: String? = null, + @SerialName("asin") + val asin: String? = null, + @SerialName("language") + val language: String? = null, + @SerialName("explicit") + val explicit: Boolean? = null, + @SerialName("abridged") + val abridged: Boolean? = null, + @SerialName("authors") + val authors: List? = null, + @SerialName("narrators") + val narrators: List? = null, + @SerialName("series") + val series: List? = null, + @SerialName("feedUrl") + val feedUrl: String? = null, + @SerialName("imageUrl") + val imageUrl: String? = null, + @SerialName("itunesPageUrl") + val itunesPageUrl: String? = null, + @SerialName("itunesId") + val itunesId: Int? = null, + @SerialName("itunesArtistId") + val itunesArtistId: Int? = null, + @SerialName("type") + val type: String? = null, + @SerialName("releaseDate") + val releaseDate: String? = null, + @SerialName("lastCoverSearch") + val lastCoverSearch: Long? = null, + @SerialName("lastCoverSearchQuery") + val lastCoverSearchQuery: String? = null +) + +@Serializable +data class Author( + @SerialName("id") + val id: String, + @SerialName("name") + val name: String, + @SerialName("asin") + val asin: String? = null, + @SerialName("description") + val description: String? = null, + @SerialName("imagePath") + val imagePath: String? = null, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null, + @SerialName("numBooks") + val numBooks: Int? = null +) + +@Serializable +data class SeriesItem( + @SerialName("id") + val id: String, + @SerialName("name") + val name: String, + @SerialName("sequence") + val sequence: String? = null +) + +@Serializable +data class AudioFile( + @SerialName("index") + val index: Int, + @SerialName("ino") + val ino: String, + @SerialName("metadata") + val metadata: FileMetadata, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null, + @SerialName("trackNumFromMeta") + val trackNumFromMeta: Int? = null, + @SerialName("discNumFromMeta") + val discNumFromMeta: Int? = null, + @SerialName("trackNumFromFilename") + val trackNumFromFilename: Int? = null, + @SerialName("discNumFromFilename") + val discNumFromFilename: Int? = null, + @SerialName("manuallyVerified") + val manuallyVerified: Boolean? = null, + @SerialName("invalid") + val invalid: Boolean? = null, + @SerialName("exclude") + val exclude: Boolean? = null, + @SerialName("error") + val error: String? = null, + @SerialName("format") + val format: String? = null, + @SerialName("duration") + val duration: Double? = null, + @SerialName("bitRate") + val bitRate: Int? = null, + @SerialName("language") + val language: String? = null, + @SerialName("codec") + val codec: String? = null, + @SerialName("timeBase") + val timeBase: String? = null, + @SerialName("channels") + val channels: Int? = null, + @SerialName("channelLayout") + val channelLayout: String? = null, + @SerialName("embeddedCoverArt") + val embeddedCoverArt: String? = null, + @SerialName("metaTags") + val metaTags: AudioMetaTags? = null, + @SerialName("mimeType") + val mimeType: String? = null +) + +@Serializable +data class FileMetadata( + @SerialName("filename") + val filename: String, + @SerialName("ext") + val ext: String? = null, + @SerialName("path") + val path: String? = null, + @SerialName("relPath") + val relPath: String? = null, + @SerialName("size") + val size: Long? = null, + @SerialName("mtimeMs") + val mtimeMs: Long? = null, + @SerialName("ctimeMs") + val ctimeMs: Long? = null, + @SerialName("birthtimeMs") + val birthtimeMs: Long? = null +) + +@Serializable +data class AudioMetaTags( + @SerialName("tagAlbum") + val tagAlbum: String? = null, + @SerialName("tagArtist") + val tagArtist: String? = null, + @SerialName("tagGenre") + val tagGenre: String? = null, + @SerialName("tagTitle") + val tagTitle: String? = null, + @SerialName("tagSeries") + val tagSeries: String? = null, + @SerialName("tagSeriesPart") + val tagSeriesPart: String? = null, + @SerialName("tagTrack") + val tagTrack: String? = null, + @SerialName("tagDisc") + val tagDisc: String? = null, + @SerialName("tagSubtitle") + val tagSubtitle: String? = null, + @SerialName("tagAlbumArtist") + val tagAlbumArtist: String? = null, + @SerialName("tagDate") + val tagDate: String? = null, + @SerialName("tagComposer") + val tagComposer: String? = null, + @SerialName("tagPublisher") + val tagPublisher: String? = null, + @SerialName("tagComment") + val tagComment: String? = null, + @SerialName("tagDescription") + val tagDescription: String? = null, + @SerialName("tagEncoder") + val tagEncoder: String? = null, + @SerialName("tagEncodedBy") + val tagEncodedBy: String? = null, + @SerialName("tagIsbn") + val tagIsbn: String? = null, + @SerialName("tagLanguage") + val tagLanguage: String? = null, + @SerialName("tagASIN") + val tagASIN: String? = null, + @SerialName("tagOverdriveMediaMarker") + val tagOverdriveMediaMarker: String? = null, + @SerialName("tagOriginalYear") + val tagOriginalYear: String? = null, + @SerialName("tagReleaseCountry") + val tagReleaseCountry: String? = null, + @SerialName("tagReleaseType") + val tagReleaseType: String? = null, + @SerialName("tagReleaseStatus") + val tagReleaseStatus: String? = null, + @SerialName("tagISRC") + val tagISRC: String? = null, + @SerialName("tagMusicBrainzTrackId") + val tagMusicBrainzTrackId: String? = null, + @SerialName("tagMusicBrainzAlbumId") + val tagMusicBrainzAlbumId: String? = null, + @SerialName("tagMusicBrainzAlbumArtistId") + val tagMusicBrainzAlbumArtistId: String? = null, + @SerialName("tagMusicBrainzArtistId") + val tagMusicBrainzArtistId: String? = null +) + +@Serializable +data class EbookFile( + @SerialName("ino") + val ino: String, + @SerialName("metadata") + val metadata: FileMetadata, + @SerialName("ebookFormat") + val ebookFormat: String, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null +) + +@Serializable +data class PodcastEpisode( + @SerialName("id") + val id: String, + @SerialName("oldEpisodeId") + val oldEpisodeId: String? = null, + @SerialName("index") + val index: Int? = null, + @SerialName("season") + val season: String? = null, + @SerialName("episode") + val episode: String? = null, + @SerialName("episodeType") + val episodeType: String? = null, + @SerialName("title") + val title: String, + @SerialName("subtitle") + val subtitle: String? = null, + @SerialName("description") + val description: String? = null, + @SerialName("enclosure") + val enclosure: Enclosure? = null, + @SerialName("guid") + val guid: String? = null, + @SerialName("pubDate") + val pubDate: String? = null, + @SerialName("chapters") + val chapters: List? = null, + @SerialName("audioFile") + val audioFile: AudioFile? = null, + @SerialName("audioTrack") + val audioTrack: AudioTrack? = null, + @SerialName("publishedAt") + val publishedAt: Long? = null, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null, + @SerialName("duration") + val duration: Double? = null, + @SerialName("size") + val size: Long? = null +) + +@Serializable +data class Enclosure( + @SerialName("url") + val url: String, + @SerialName("type") + val type: String? = null, + @SerialName("length") + val length: String? = null +) + +@Serializable +data class ItemResponse( + @SerialName("id") + val id: String? = null, + @SerialName("ino") + val ino: String? = null, + @SerialName("oldLibraryItemId") + val oldLibraryItemId: String? = null, + @SerialName("libraryId") + val libraryId: String? = null, + @SerialName("folderId") + val folderId: String? = null, + @SerialName("path") + val path: String? = null, + @SerialName("relPath") + val relPath: String? = null, + @SerialName("isFile") + val isFile: Boolean? = null, + @SerialName("mtimeMs") + val mtimeMs: Long? = null, + @SerialName("ctimeMs") + val ctimeMs: Long? = null, + @SerialName("birthtimeMs") + val birthtimeMs: Long? = null, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null, + @SerialName("isMissing") + val isMissing: Boolean? = null, + @SerialName("isInvalid") + val isInvalid: Boolean? = null, + @SerialName("mediaType") + val mediaType: String? = null, + @SerialName("media") + val media: Media? = null, + @SerialName("numFiles") + val numFiles: Int? = null, + @SerialName("size") + val size: Long? = null, + @SerialName("userMediaProgress") + val userMediaProgress: MediaProgress? = null, + @SerialName("rssFeedUrl") + val rssFeedUrl: String? = null, + @SerialName("libraryFiles") + val libraryFiles: List? = null +) + +@Serializable +data class LibraryFile( + @SerialName("ino") + val ino: String, + @SerialName("metadata") + val metadata: FileMetadata, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null, + @SerialName("fileType") + val fileType: String? = null +) + +@Serializable +data class SearchResponse( + @SerialName("book") + val book: List? = null, + @SerialName("podcast") + val podcast: List? = null, + @SerialName("narrators") + val narrators: List? = null, + @SerialName("authors") + val authors: List? = null, + @SerialName("series") + val series: List? = null, + @SerialName("tags") + val tags: List? = null +) + +@Serializable +data class SearchResultBook( + @SerialName("libraryItem") + val libraryItem: LibraryItem, + @SerialName("matchKey") + val matchKey: String? = null, + @SerialName("matchText") + val matchText: String? = null +) + +@Serializable +data class SearchResultPodcast( + @SerialName("libraryItem") + val libraryItem: LibraryItem, + @SerialName("matchKey") + val matchKey: String? = null, + @SerialName("matchText") + val matchText: String? = null +) + +@Serializable +data class NarratorResult( + @SerialName("name") + val name: String, + @SerialName("numBooks") + val numBooks: Int? = null +) + +@Serializable +data class SeriesResult( + @SerialName("series") + val series: SeriesItem, + @SerialName("books") + val books: List? = null +) + +@Serializable +data class ItemsInProgressResponse( + @SerialName("libraryItems") + val libraryItems: List +) diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt new file mode 100644 index 00000000..be60bf60 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt @@ -0,0 +1,177 @@ +package com.makd.afinity.data.models.audiobookshelf + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Library( + @SerialName("id") + val id: String, + @SerialName("name") + val name: String, + @SerialName("folders") + val folders: List? = null, + @SerialName("displayOrder") + val displayOrder: Int? = null, + @SerialName("icon") + val icon: String? = null, + @SerialName("mediaType") + val mediaType: String, + @SerialName("provider") + val provider: String? = null, + @SerialName("settings") + val settings: LibrarySettings? = null, + @SerialName("stats") + val stats: LibraryStats? = null, + @SerialName("createdAt") + val createdAt: Long? = null, + @SerialName("lastUpdate") + val lastUpdate: Long? = null +) + +@Serializable +data class Folder( + @SerialName("id") + val id: String, + @SerialName("fullPath") + val fullPath: String, + @SerialName("libraryId") + val libraryId: String? = null, + @SerialName("addedAt") + val addedAt: Long? = null +) + +@Serializable +data class LibrarySettings( + @SerialName("coverAspectRatio") + val coverAspectRatio: Int? = null, + @SerialName("disableWatcher") + val disableWatcher: Boolean? = null, + @SerialName("skipMatchingMediaWithAsin") + val skipMatchingMediaWithAsin: Boolean? = null, + @SerialName("skipMatchingMediaWithIsbn") + val skipMatchingMediaWithIsbn: Boolean? = null, + @SerialName("autoScanCronExpression") + val autoScanCronExpression: String? = null, + @SerialName("audiobooksOnly") + val audiobooksOnly: Boolean? = null, + @SerialName("hideSingleBookSeries") + val hideSingleBookSeries: Boolean? = null, + @SerialName("onlyShowLaterBooksInContinueSeries") + val onlyShowLaterBooksInContinueSeries: Boolean? = null, + @SerialName("metadataPrecedence") + val metadataPrecedence: List? = null, + @SerialName("podcastSearchRegion") + val podcastSearchRegion: String? = null +) + +@Serializable +data class LibraryStats( + @SerialName("totalItems") + val totalItems: Int? = null, + @SerialName("totalSize") + val totalSize: Long? = null, + @SerialName("totalDuration") + val totalDuration: Double? = null, + @SerialName("numAudioFiles") + val numAudioFiles: Int? = null, + @SerialName("numAudioTracks") + val numAudioTracks: Int? = null +) + +@Serializable +data class LibrariesResponse( + @SerialName("libraries") + val libraries: List +) + +@Serializable +data class LibraryResponse( + @SerialName("library") + val library: Library? = null, + @SerialName("issues") + val issues: Int? = null, + @SerialName("numUserPlaylists") + val numUserPlaylists: Int? = null, + @SerialName("filterData") + val filterData: FilterData? = null +) + +@Serializable +data class FilterData( + @SerialName("authors") + val authors: List? = null, + @SerialName("genres") + val genres: List? = null, + @SerialName("tags") + val tags: List? = null, + @SerialName("series") + val series: List? = null, + @SerialName("narrators") + val narrators: List? = null, + @SerialName("languages") + val languages: List? = null, + @SerialName("publishers") + val publishers: List? = null +) + +@Serializable +data class AuthorFilter( + @SerialName("id") + val id: String, + @SerialName("name") + val name: String +) + +@Serializable +data class SeriesFilter( + @SerialName("id") + val id: String, + @SerialName("name") + val name: String +) + +@Serializable +data class LibraryItemsResponse( + @SerialName("results") + val results: List, + @SerialName("total") + val total: Int, + @SerialName("limit") + val limit: Int, + @SerialName("page") + val page: Int, + @SerialName("sortBy") + val sortBy: String? = null, + @SerialName("sortDesc") + val sortDesc: Boolean? = null, + @SerialName("filterBy") + val filterBy: String? = null, + @SerialName("mediaType") + val mediaType: String? = null, + @SerialName("minified") + val minified: Boolean? = null, + @SerialName("collapseseries") + val collapseseries: Boolean? = null, + @SerialName("include") + val include: String? = null +) + +@Serializable +data class PersonalizedView( + @SerialName("id") + val id: String, + @SerialName("label") + val label: String, + @SerialName("labelStringKey") + val labelStringKey: String? = null, + @SerialName("type") + val type: String, + @SerialName("entities") + val entities: List +) + +@Serializable +data class PersonalizedResponse( + val items: List +) diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt new file mode 100644 index 00000000..24ad4087 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfPlayback.kt @@ -0,0 +1,279 @@ +package com.makd.afinity.data.models.audiobookshelf + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AudioTrack( + @SerialName("index") + val index: Int, + @SerialName("startOffset") + val startOffset: Double, + @SerialName("duration") + val duration: Double, + @SerialName("title") + val title: String? = null, + @SerialName("contentUrl") + val contentUrl: String? = null, + @SerialName("mimeType") + val mimeType: String? = null, + @SerialName("codec") + val codec: String? = null, + @SerialName("metadata") + val metadata: FileMetadata? = null +) + +@Serializable +data class BookChapter( + @SerialName("id") + val id: Int, + @SerialName("start") + val start: Double, + @SerialName("end") + val end: Double, + @SerialName("title") + val title: String +) + +@Serializable +data class MediaProgress( + @SerialName("id") + val id: String, + @SerialName("libraryItemId") + val libraryItemId: String, + @SerialName("episodeId") + val episodeId: String? = null, + @SerialName("duration") + val duration: Double, + @SerialName("progress") + val progress: Double, + @SerialName("currentTime") + val currentTime: Double, + @SerialName("isFinished") + val isFinished: Boolean, + @SerialName("hideFromContinueListening") + val hideFromContinueListening: Boolean? = null, + @SerialName("ebookLocation") + val ebookLocation: String? = null, + @SerialName("ebookProgress") + val ebookProgress: Double? = null, + @SerialName("lastUpdate") + val lastUpdate: Long, + @SerialName("startedAt") + val startedAt: Long, + @SerialName("finishedAt") + val finishedAt: Long? = null +) + +@Serializable +data class PlaybackSession( + @SerialName("id") + val id: String, + @SerialName("userId") + val userId: String, + @SerialName("libraryId") + val libraryId: String, + @SerialName("libraryItemId") + val libraryItemId: String, + @SerialName("episodeId") + val episodeId: String? = null, + @SerialName("mediaType") + val mediaType: String, + @SerialName("mediaMetadata") + val mediaMetadata: MediaMetadata? = null, + @SerialName("chapters") + val chapters: List? = null, + @SerialName("displayTitle") + val displayTitle: String? = null, + @SerialName("displayAuthor") + val displayAuthor: String? = null, + @SerialName("coverPath") + val coverPath: String? = null, + @SerialName("duration") + val duration: Double, + @SerialName("playMethod") + val playMethod: Int, + @SerialName("mediaPlayer") + val mediaPlayer: String? = null, + @SerialName("deviceInfo") + val deviceInfo: DeviceInfo? = null, + @SerialName("serverVersion") + val serverVersion: String? = null, + @SerialName("date") + val date: String? = null, + @SerialName("dayOfWeek") + val dayOfWeek: String? = null, + @SerialName("timeListening") + val timeListening: Double? = null, + @SerialName("startTime") + val startTime: Double, + @SerialName("currentTime") + val currentTime: Double, + @SerialName("startedAt") + val startedAt: Long, + @SerialName("updatedAt") + val updatedAt: Long, + @SerialName("audioTracks") + val audioTracks: List? = null, + @SerialName("videoTrack") + val videoTrack: VideoTrack? = null, + @SerialName("libraryItem") + val libraryItem: LibraryItem? = null +) + +@Serializable +data class VideoTrack( + @SerialName("index") + val index: Int, + @SerialName("startOffset") + val startOffset: Double? = null, + @SerialName("duration") + val duration: Double? = null, + @SerialName("title") + val title: String? = null, + @SerialName("contentUrl") + val contentUrl: String? = null, + @SerialName("mimeType") + val mimeType: String? = null, + @SerialName("codec") + val codec: String? = null +) + +@Serializable +data class DeviceInfo( + @SerialName("id") + val id: String? = null, + @SerialName("deviceId") + val deviceId: String, + @SerialName("ipAddress") + val ipAddress: String? = null, + @SerialName("browserName") + val browserName: String? = null, + @SerialName("browserVersion") + val browserVersion: String? = null, + @SerialName("osName") + val osName: String? = null, + @SerialName("osVersion") + val osVersion: String? = null, + @SerialName("deviceType") + val deviceType: String? = null, + @SerialName("manufacturer") + val manufacturer: String? = null, + @SerialName("model") + val model: String? = null, + @SerialName("sdkVersion") + val sdkVersion: Int? = null, + @SerialName("clientName") + val clientName: String? = null, + @SerialName("clientVersion") + val clientVersion: String? = null +) + +@Serializable +data class PlaybackSessionRequest( + @SerialName("deviceInfo") + val deviceInfo: DeviceInfo, + @SerialName("forceDirectPlay") + val forceDirectPlay: Boolean? = null, + @SerialName("forceTranscode") + val forceTranscode: Boolean? = null, + @SerialName("supportedMimeTypes") + val supportedMimeTypes: List? = null, + @SerialName("mediaPlayer") + val mediaPlayer: String? = null +) + +@Serializable +data class MediaProgressSyncData( + @SerialName("currentTime") + val currentTime: Double, + @SerialName("timeListened") + val timeListened: Double, + @SerialName("duration") + val duration: Double, + @SerialName("progress") + val progress: Double? = null, + @SerialName("isFinished") + val isFinished: Boolean? = null +) + +@Serializable +data class SyncResponse( + @SerialName("id") + val id: String? = null, + @SerialName("success") + val success: Boolean? = null +) + +@Serializable +data class ProgressUpdateRequest( + @SerialName("currentTime") + val currentTime: Double? = null, + @SerialName("duration") + val duration: Double? = null, + @SerialName("progress") + val progress: Double? = null, + @SerialName("isFinished") + val isFinished: Boolean? = null +) + +@Serializable +data class BatchLocalSessionRequest( + @SerialName("sessions") + val sessions: List +) + +@Serializable +data class LocalSessionData( + @SerialName("id") + val id: String, + @SerialName("libraryItemId") + val libraryItemId: String, + @SerialName("episodeId") + val episodeId: String? = null, + @SerialName("currentTime") + val currentTime: Double, + @SerialName("timeListening") + val timeListening: Double, + @SerialName("duration") + val duration: Double, + @SerialName("progress") + val progress: Double, + @SerialName("startedAt") + val startedAt: Long, + @SerialName("updatedAt") + val updatedAt: Long +) + +@Serializable +data class BatchSyncResponse( + @SerialName("results") + val results: List? = null, + @SerialName("numSuccessful") + val numSuccessful: Int? = null, + @SerialName("numFailed") + val numFailed: Int? = null +) + +@Serializable +data class BatchSyncResult( + @SerialName("id") + val id: String, + @SerialName("success") + val success: Boolean, + @SerialName("progressSynced") + val progressSynced: Boolean? = null, + @SerialName("error") + val error: String? = null +) + +enum class PlayMethod(val value: Int) { + DIRECT_PLAY(0), + DIRECT_STREAM(1), + TRANSCODE(2), + LOCAL(3); + + companion object { + fun fromValue(value: Int): PlayMethod = entries.find { it.value == value } ?: DIRECT_PLAY + } +} diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt new file mode 100644 index 00000000..3c3467fa --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfUser.kt @@ -0,0 +1,146 @@ +package com.makd.afinity.data.models.audiobookshelf + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AudiobookshelfUser( + @SerialName("id") + val id: String, + @SerialName("username") + val username: String, + @SerialName("type") + val type: String? = null, + @SerialName("token") + val token: String? = null, + @SerialName("mediaProgress") + val mediaProgress: List? = null, + @SerialName("bookmarks") + val bookmarks: List? = null, + @SerialName("isActive") + val isActive: Boolean? = null, + @SerialName("isLocked") + val isLocked: Boolean? = null, + @SerialName("lastSeen") + val lastSeen: Long? = null, + @SerialName("createdAt") + val createdAt: Long? = null, + @SerialName("permissions") + val permissions: UserPermissions? = null, + @SerialName("librariesAccessible") + val librariesAccessible: List? = null, + @SerialName("itemTagsSelected") + val itemTagsSelected: List? = null +) + +@Serializable +data class UserPermissions( + @SerialName("download") + val download: Boolean? = null, + @SerialName("update") + val update: Boolean? = null, + @SerialName("delete") + val delete: Boolean? = null, + @SerialName("upload") + val upload: Boolean? = null, + @SerialName("accessAllLibraries") + val accessAllLibraries: Boolean? = null, + @SerialName("accessAllTags") + val accessAllTags: Boolean? = null, + @SerialName("accessExplicitContent") + val accessExplicitContent: Boolean? = null +) + +@Serializable +data class Bookmark( + @SerialName("libraryItemId") + val libraryItemId: String, + @SerialName("title") + val title: String, + @SerialName("time") + val time: Double, + @SerialName("createdAt") + val createdAt: Long +) + +@Serializable +data class LoginRequest( + @SerialName("username") + val username: String, + @SerialName("password") + val password: String +) + +@Serializable +data class LoginResponse( + @SerialName("user") + val user: AudiobookshelfUser, + @SerialName("userDefaultLibraryId") + val userDefaultLibraryId: String? = null, + @SerialName("serverSettings") + val serverSettings: ServerSettings? = null, + @SerialName("ereaderDevices") + val ereaderDevices: List? = null +) + +@Serializable +data class ServerSettings( + @SerialName("id") + val id: String? = null, + @SerialName("scannerFindCovers") + val scannerFindCovers: Boolean? = null, + @SerialName("scannerCoverProvider") + val scannerCoverProvider: String? = null, + @SerialName("scannerParseSubtitle") + val scannerParseSubtitle: Boolean? = null, + @SerialName("scannerPreferMatchedMetadata") + val scannerPreferMatchedMetadata: Boolean? = null, + @SerialName("scannerDisableWatcher") + val scannerDisableWatcher: Boolean? = null, + @SerialName("storeCoverWithItem") + val storeCoverWithItem: Boolean? = null, + @SerialName("storeMetadataWithItem") + val storeMetadataWithItem: Boolean? = null, + @SerialName("metadataFileFormat") + val metadataFileFormat: String? = null, + @SerialName("rateLimitLoginRequests") + val rateLimitLoginRequests: Int? = null, + @SerialName("rateLimitLoginWindow") + val rateLimitLoginWindow: Int? = null, + @SerialName("backupSchedule") + val backupSchedule: String? = null, + @SerialName("backupsToKeep") + val backupsToKeep: Int? = null, + @SerialName("maxBackupSize") + val maxBackupSize: Int? = null, + @SerialName("loggerDailyLogsToKeep") + val loggerDailyLogsToKeep: Int? = null, + @SerialName("loggerScannerLogsToKeep") + val loggerScannerLogsToKeep: Int? = null, + @SerialName("homeBookshelfView") + val homeBookshelfView: Int? = null, + @SerialName("bookshelfView") + val bookshelfView: Int? = null, + @SerialName("sortingIgnorePrefix") + val sortingIgnorePrefix: Boolean? = null, + @SerialName("sortingPrefixes") + val sortingPrefixes: List? = null, + @SerialName("chromecastEnabled") + val chromecastEnabled: Boolean? = null, + @SerialName("dateFormat") + val dateFormat: String? = null, + @SerialName("language") + val language: String? = null, + @SerialName("logLevel") + val logLevel: Int? = null, + @SerialName("version") + val version: String? = null +) + +@Serializable +data class AuthorizeResponse( + @SerialName("user") + val user: AudiobookshelfUser, + @SerialName("userDefaultLibraryId") + val userDefaultLibraryId: String? = null +) diff --git a/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt b/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt new file mode 100644 index 00000000..63819477 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt @@ -0,0 +1,132 @@ +package com.makd.afinity.data.network + +import com.makd.afinity.data.models.audiobookshelf.AuthorizeResponse +import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfUser +import com.makd.afinity.data.models.audiobookshelf.BatchLocalSessionRequest +import com.makd.afinity.data.models.audiobookshelf.BatchSyncResponse +import com.makd.afinity.data.models.audiobookshelf.ItemResponse +import com.makd.afinity.data.models.audiobookshelf.ItemsInProgressResponse +import com.makd.afinity.data.models.audiobookshelf.LibrariesResponse +import com.makd.afinity.data.models.audiobookshelf.LibraryItemsResponse +import com.makd.afinity.data.models.audiobookshelf.LibraryResponse +import com.makd.afinity.data.models.audiobookshelf.LoginRequest +import com.makd.afinity.data.models.audiobookshelf.LoginResponse +import com.makd.afinity.data.models.audiobookshelf.MediaProgress +import com.makd.afinity.data.models.audiobookshelf.MediaProgressSyncData +import com.makd.afinity.data.models.audiobookshelf.PersonalizedView +import com.makd.afinity.data.models.audiobookshelf.PlaybackSession +import com.makd.afinity.data.models.audiobookshelf.PlaybackSessionRequest +import com.makd.afinity.data.models.audiobookshelf.ProgressUpdateRequest +import com.makd.afinity.data.models.audiobookshelf.SearchResponse +import com.makd.afinity.data.models.audiobookshelf.SyncResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface AudiobookshelfApiService { + + @POST("api/authorize") + suspend fun authorize(): Response + + @POST("login") + suspend fun login(@Body credentials: LoginRequest): Response + + @GET("api/libraries") + suspend fun getLibraries(): Response + + @GET("api/libraries/{libraryId}") + suspend fun getLibrary( + @Path("libraryId") id: String, + @Query("include") include: String? = null + ): Response + + @GET("api/libraries/{libraryId}/items") + suspend fun getLibraryItems( + @Path("libraryId") id: String, + @Query("minified") minified: Int = 1, + @Query("limit") limit: Int = 100, + @Query("page") page: Int = 0, + @Query("sort") sort: String? = null, + @Query("desc") desc: Int? = null, + @Query("filter") filter: String? = null, + @Query("include") include: String? = null, + @Query("collapseseries") collapseseries: Int? = null + ): Response + + @GET("api/libraries/{libraryId}/personalized") + suspend fun getPersonalized( + @Path("libraryId") id: String, + @Query("limit") limit: Int? = null, + @Query("include") include: String? = null + ): Response> + + @GET("api/libraries/{libraryId}/search") + suspend fun search( + @Path("libraryId") id: String, + @Query("q") query: String, + @Query("limit") limit: Int? = null + ): Response + + @GET("api/items/{itemId}") + suspend fun getItem( + @Path("itemId") id: String, + @Query("expanded") expanded: Int = 1, + @Query("include") include: String? = "progress" + ): Response + + @GET("api/me") + suspend fun getMe(): Response + + @GET("api/me/items-in-progress") + suspend fun getItemsInProgress( + @Query("limit") limit: Int? = null + ): Response + + @PATCH("api/me/progress/{itemId}") + suspend fun updateProgress( + @Path("itemId") id: String, + @Body progress: ProgressUpdateRequest + ): Response + + @PATCH("api/me/progress/{itemId}/{episodeId}") + suspend fun updateEpisodeProgress( + @Path("itemId") itemId: String, + @Path("episodeId") episodeId: String, + @Body progress: ProgressUpdateRequest + ): Response + + @POST("api/items/{itemId}/play") + suspend fun startPlaybackSession( + @Path("itemId") itemId: String, + @Body request: PlaybackSessionRequest + ): Response + + @POST("api/items/{itemId}/play/{episodeId}") + suspend fun startEpisodePlaybackSession( + @Path("itemId") itemId: String, + @Path("episodeId") episodeId: String, + @Body request: PlaybackSessionRequest + ): Response + + @POST("api/session/{sessionId}/sync") + suspend fun syncPlaybackSession( + @Path("sessionId") id: String, + @Body syncData: MediaProgressSyncData + ): Response + + @POST("api/session/{sessionId}/close") + suspend fun closePlaybackSession( + @Path("sessionId") id: String, + @Body syncData: MediaProgressSyncData? = null + ): Response + + @POST("api/session/local-all") + suspend fun syncAllLocalSessions( + @Body request: BatchLocalSessionRequest + ): Response +} diff --git a/app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt new file mode 100644 index 00000000..4c46e66d --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt @@ -0,0 +1,104 @@ +package com.makd.afinity.data.repository + +import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfUser +import com.makd.afinity.data.models.audiobookshelf.Library +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.data.models.audiobookshelf.MediaProgress +import com.makd.afinity.data.models.audiobookshelf.PersonalizedView +import com.makd.afinity.data.models.audiobookshelf.PlaybackSession +import com.makd.afinity.data.models.audiobookshelf.SearchResponse +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID + +data class ItemWithProgress( + val item: LibraryItem, + val progress: MediaProgress? +) + +interface AudiobookshelfRepository { + + suspend fun setActiveJellyfinSession(serverId: String, userId: UUID) + + fun clearActiveSession() + + val currentSessionId: StateFlow + + val isAuthenticated: StateFlow + + val currentConfig: StateFlow + + suspend fun login( + serverUrl: String, + username: String, + password: String + ): Result + + suspend fun logout(): Result + + suspend fun validateToken(): Result + + suspend fun setServerUrl(url: String) + + suspend fun getServerUrl(): String? + + suspend fun hasValidConfiguration(): Boolean + + fun getLibrariesFlow(): Flow> + + suspend fun refreshLibraries(): Result> + + suspend fun getLibrary(libraryId: String): Result + + fun getLibraryItemsFlow(libraryId: String): Flow> + + suspend fun refreshLibraryItems( + libraryId: String, + limit: Int = 100, + page: Int = 0 + ): Result> + + suspend fun getItemDetails(itemId: String): Result + + suspend fun searchLibrary(libraryId: String, query: String): Result + + suspend fun getPersonalized(libraryId: String): Result> + + fun getInProgressItemsFlow(): Flow> + + suspend fun refreshProgress(): Result> + + suspend fun updateProgress( + itemId: String, + episodeId: String?, + currentTime: Double, + duration: Double, + isFinished: Boolean + ): Result + + fun getProgressForItemFlow(itemId: String): Flow + + suspend fun startPlaybackSession(itemId: String, episodeId: String?): Result + + suspend fun syncPlaybackSession( + sessionId: String, + timeListened: Double, + currentTime: Double, + duration: Double + ): Result + + suspend fun closePlaybackSession( + sessionId: String, + currentTime: Double, + timeListened: Double, + duration: Double + ): Result + + suspend fun syncPendingProgress(): Result +} + +data class AudiobookshelfConfig( + val serverUrl: String, + val absUserId: String, + val username: String +) diff --git a/app/src/main/java/com/makd/afinity/data/repository/SecurePreferencesRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/SecurePreferencesRepository.kt index 808327e6..77cdd895 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/SecurePreferencesRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/SecurePreferencesRepository.kt @@ -70,4 +70,35 @@ interface SecurePreferencesRepository { suspend fun saveJellyseerrCookie(cookie: String) suspend fun saveJellyseerrUsername(username: String) suspend fun clearJellyseerrAuthData() -} \ No newline at end of file + + suspend fun saveAudiobookshelfAuthForUser( + jellyfinServerId: String, + jellyfinUserId: UUID, + serverUrl: String, + accessToken: String, + absUserId: String, + username: String + ) + + suspend fun switchAudiobookshelfContext(jellyfinServerId: String, jellyfinUserId: UUID): Boolean + fun clearActiveAudiobookshelfCache() + + suspend fun getAudiobookshelfAuthForUser( + jellyfinServerId: String, + jellyfinUserId: UUID + ): AudiobookshelfAuthData? + + suspend fun clearAudiobookshelfAuthForUser(jellyfinServerId: String, jellyfinUserId: UUID) + + fun getCachedAudiobookshelfServerUrl(): String? + fun getCachedAudiobookshelfToken(): String? + + suspend fun hasValidAudiobookshelfAuth(): Boolean +} + +data class AudiobookshelfAuthData( + val serverUrl: String, + val accessToken: String, + val absUserId: String, + val username: String +) \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt new file mode 100644 index 00000000..a7f17814 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt @@ -0,0 +1,878 @@ +package com.makd.afinity.data.repository.audiobookshelf + +import android.os.Build +import com.makd.afinity.BuildConfig +import com.makd.afinity.data.database.AfinityDatabase +import com.makd.afinity.data.database.entities.AudiobookshelfConfigEntity +import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity +import com.makd.afinity.data.database.entities.AudiobookshelfLibraryEntity +import com.makd.afinity.data.database.entities.AudiobookshelfProgressEntity +import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfUser +import com.makd.afinity.data.models.audiobookshelf.DeviceInfo +import com.makd.afinity.data.models.audiobookshelf.Library +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.data.models.audiobookshelf.LoginRequest +import com.makd.afinity.data.models.audiobookshelf.MediaProgress +import com.makd.afinity.data.models.audiobookshelf.MediaProgressSyncData +import com.makd.afinity.data.models.audiobookshelf.PersonalizedView +import com.makd.afinity.data.models.audiobookshelf.PlaybackSession +import com.makd.afinity.data.models.audiobookshelf.PlaybackSessionRequest +import com.makd.afinity.data.models.audiobookshelf.ProgressUpdateRequest +import com.makd.afinity.data.models.audiobookshelf.SearchResponse +import com.makd.afinity.data.network.AudiobookshelfApiService +import com.makd.afinity.data.repository.AudiobookshelfConfig +import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.ItemWithProgress +import com.makd.afinity.data.repository.SecurePreferencesRepository +import com.makd.afinity.util.NetworkConnectivityMonitor +import dagger.Lazy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudiobookshelfRepositoryImpl @Inject constructor( + private val apiService: Lazy, + private val securePreferencesRepository: SecurePreferencesRepository, + private val database: AfinityDatabase, + private val networkConnectivityMonitor: NetworkConnectivityMonitor +) : AudiobookshelfRepository { + + private val audiobookshelfDao = database.audiobookshelfDao() + private val json = Json { ignoreUnknownKeys = true } + + private val _isAuthenticated = MutableStateFlow(false) + override val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + private val _currentSessionId = MutableStateFlow(null) + override val currentSessionId: StateFlow = _currentSessionId.asStateFlow() + + private val _currentConfig = MutableStateFlow(null) + override val currentConfig: StateFlow = _currentConfig.asStateFlow() + + private var activeContext: Pair? = null + + private var pendingServerUrl: String? = null + + companion object { + private const val CACHE_VALIDITY_MS = 5 * 60 * 1000L + } + + override suspend fun setActiveJellyfinSession(serverId: String, userId: UUID) { + Timber.d("Switching Audiobookshelf context to Server: $serverId, User: $userId") + _isAuthenticated.value = false + _currentConfig.value = null + activeContext = serverId to userId + _currentSessionId.value = "${serverId}_$userId" + + val hasAuth = securePreferencesRepository.switchAudiobookshelfContext(serverId, userId) + val config = audiobookshelfDao.getConfig(serverId, userId.toString()) + + if (hasAuth && config?.isLoggedIn == true) { + _isAuthenticated.value = true + _currentConfig.value = AudiobookshelfConfig( + serverUrl = config.serverUrl, + absUserId = config.absUserId, + username = config.username + ) + } + + Timber.d("Audiobookshelf Context Switched. Authenticated: ${_isAuthenticated.value}") + } + + override fun clearActiveSession() { + activeContext = null + _currentSessionId.value = null + securePreferencesRepository.clearActiveAudiobookshelfCache() + _isAuthenticated.value = false + _currentConfig.value = null + pendingServerUrl = null + Timber.d("Audiobookshelf active session cleared") + } + + override suspend fun login( + serverUrl: String, + username: String, + password: String + ): Result { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active Jellyfin session")) + + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + pendingServerUrl = serverUrl + securePreferencesRepository.saveAudiobookshelfAuthForUser( + jellyfinServerId = currentServerId, + jellyfinUserId = currentUserId, + serverUrl = serverUrl, + accessToken = "", + absUserId = "", + username = username + ) + + val loginRequest = LoginRequest(username, password) + val response = apiService.get().login(loginRequest) + + if (response.isSuccessful && response.body() != null) { + val loginResponse = response.body()!! + val user = loginResponse.user + val token = user.token + ?: return@withContext Result.failure(Exception("No token received")) + + securePreferencesRepository.saveAudiobookshelfAuthForUser( + jellyfinServerId = currentServerId, + jellyfinUserId = currentUserId, + serverUrl = serverUrl, + accessToken = token, + absUserId = user.id, + username = user.username + ) + + audiobookshelfDao.insertConfig( + AudiobookshelfConfigEntity( + jellyfinServerId = currentServerId, + jellyfinUserId = currentUserId.toString(), + serverUrl = serverUrl, + absUserId = user.id, + username = user.username, + isLoggedIn = true, + lastSync = System.currentTimeMillis() + ) + ) + + _isAuthenticated.value = true + _currentConfig.value = AudiobookshelfConfig( + serverUrl = serverUrl, + absUserId = user.id, + username = user.username + ) + + user.mediaProgress?.let { progressList -> + progressList.forEach { progress -> + cacheProgress(progress) + } + } + + Timber.d("Audiobookshelf login successful for user: ${user.username}") + Result.success(user) + } else { + val errorMsg = "Login failed: ${response.code()} - ${response.message()}" + Timber.e(errorMsg) + pendingServerUrl = null + Result.failure(Exception(errorMsg)) + } + } catch (e: Exception) { + Timber.e(e, "Audiobookshelf login failed") + pendingServerUrl = null + Result.failure(e) + } + } + } + + override suspend fun logout(): Result { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + + try { + securePreferencesRepository.clearAudiobookshelfAuthForUser( + currentServerId, + currentUserId + ) + + audiobookshelfDao.deleteConfig(currentServerId, currentUserId.toString()) + audiobookshelfDao.deleteAllLibraries(currentServerId, currentUserId.toString()) + audiobookshelfDao.deleteAllItems(currentServerId, currentUserId.toString()) + audiobookshelfDao.deleteAllProgress(currentServerId, currentUserId.toString()) + + _isAuthenticated.value = false + _currentConfig.value = null + pendingServerUrl = null + + Timber.d("Audiobookshelf logout successful") + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "Audiobookshelf logout failed") + Result.failure(e) + } + } + } + + override suspend fun validateToken(): Result { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().authorize() + if (response.isSuccessful && response.body() != null) { + Result.success(true) + } else { + _isAuthenticated.value = false + Result.success(false) + } + } catch (e: Exception) { + Timber.e(e, "Token validation failed") + Result.failure(e) + } + } + } + + override suspend fun setServerUrl(url: String) { + pendingServerUrl = url + } + + override suspend fun getServerUrl(): String? { + return pendingServerUrl ?: _currentConfig.value?.serverUrl + } + + override suspend fun hasValidConfiguration(): Boolean { + return _isAuthenticated.value && _currentConfig.value != null + } + + override fun getLibrariesFlow(): Flow> { + val (serverId, userId) = activeContext ?: return flowOf(emptyList()) + return audiobookshelfDao.getLibrariesFlow(serverId, userId.toString()).map { entities -> + entities.map { it.toLibrary() } + } + } + + override suspend fun refreshLibraries(): Result> { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().getLibraries() + + if (response.isSuccessful && response.body() != null) { + val libraries = response.body()!!.libraries + + val entities = libraries.mapIndexed { index, library -> + AudiobookshelfLibraryEntity( + id = library.id, + jellyfinServerId = currentServerId, + jellyfinUserId = currentUserId.toString(), + name = library.name, + mediaType = library.mediaType, + icon = library.icon, + displayOrder = library.displayOrder ?: index, + totalItems = library.stats?.totalItems ?: 0, + totalDuration = library.stats?.totalDuration, + lastUpdated = library.lastUpdate ?: System.currentTimeMillis(), + cachedAt = System.currentTimeMillis() + ) + } + + audiobookshelfDao.deleteAllLibraries(currentServerId, currentUserId.toString()) + audiobookshelfDao.insertLibraries(entities) + + Result.success(libraries) + } else { + Result.failure(Exception("Failed to fetch libraries: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to refresh libraries") + Result.failure(e) + } + } + } + + override suspend fun getLibrary(libraryId: String): Result { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().getLibrary(libraryId) + + if (response.isSuccessful && response.body()?.library != null) { + Result.success(response.body()!!.library!!) + } else { + Result.failure(Exception("Failed to fetch library: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get library") + Result.failure(e) + } + } + } + + override fun getLibraryItemsFlow(libraryId: String): Flow> { + val (serverId, userId) = activeContext ?: return flowOf(emptyList()) + return audiobookshelfDao.getItemsFlow(serverId, userId.toString(), libraryId) + .map { entities -> + entities.map { it.toLibraryItem() } + } + } + + override suspend fun refreshLibraryItems( + libraryId: String, + limit: Int, + page: Int + ): Result> { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().getLibraryItems( + id = libraryId, + limit = limit, + page = page, + include = "progress" + ) + + if (response.isSuccessful && response.body() != null) { + val items = response.body()!!.results + val entities = items.map { item -> + item.toEntity(currentServerId, currentUserId.toString()) + } + + if (page == 0) { + audiobookshelfDao.deleteItemsByLibrary( + currentServerId, + currentUserId.toString(), + libraryId + ) + } + audiobookshelfDao.insertItems(entities) + items.forEach { item -> + item.userMediaProgress?.let { progress -> + cacheProgress(progress) + } + } + + Result.success(items) + } else { + Result.failure(Exception("Failed to fetch items: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to refresh library items") + Result.failure(e) + } + } + } + + override suspend fun getItemDetails(itemId: String): Result { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + val cached = + audiobookshelfDao.getItem(itemId, currentServerId, currentUserId.toString()) + if (cached != null) { + return@withContext Result.success(cached.toLibraryItem()) + } + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().getItem(itemId) + + if (response.isSuccessful && response.body() != null) { + val itemResponse = response.body()!! + val item = LibraryItem( + id = itemResponse.id ?: itemId, + ino = itemResponse.ino, + libraryId = itemResponse.libraryId ?: "", + mediaType = itemResponse.mediaType ?: "book", + media = itemResponse.media!!, + addedAt = itemResponse.addedAt, + updatedAt = itemResponse.updatedAt, + userMediaProgress = itemResponse.userMediaProgress + ) + audiobookshelfDao.insertItem( + item.toEntity( + currentServerId, + currentUserId.toString() + ) + ) + + itemResponse.userMediaProgress?.let { progress -> + cacheProgress(progress) + } + + Result.success(item) + } else { + Result.failure(Exception("Failed to fetch item: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get item details") + Result.failure(e) + } + } + } + + override suspend fun searchLibrary(libraryId: String, query: String): Result { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().search(libraryId, query) + + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("Search failed: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to search library") + Result.failure(e) + } + } + } + + override suspend fun getPersonalized(libraryId: String): Result> { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().getPersonalized(libraryId) + + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("Failed to fetch personalized: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get personalized") + Result.failure(e) + } + } + } + + override fun getInProgressItemsFlow(): Flow> { + val (serverId, userId) = activeContext ?: return flowOf(emptyList()) + + val progressFlow = audiobookshelfDao.getInProgressFlow(serverId, userId.toString()) + val itemsFlow = audiobookshelfDao.getItemsFlow(serverId, userId.toString(), "") + return progressFlow.map { progressList -> + progressList.mapNotNull { progress -> + val item = + audiobookshelfDao.getItem(progress.libraryItemId, serverId, userId.toString()) + item?.let { + ItemWithProgress( + item = it.toLibraryItem(), + progress = progress.toMediaProgress() + ) + } + } + } + } + + override suspend fun refreshProgress(): Result> { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().getItemsInProgress() + + if (response.isSuccessful && response.body() != null) { + val items = response.body()!!.libraryItems + val progressList = items.mapNotNull { it.userMediaProgress } + items.forEach { item -> + audiobookshelfDao.insertItem( + item.toEntity( + currentServerId, + currentUserId.toString() + ) + ) + item.userMediaProgress?.let { progress -> + cacheProgress(progress) + } + } + + Result.success(progressList) + } else { + Result.failure(Exception("Failed to fetch progress: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to refresh progress") + Result.failure(e) + } + } + } + + override suspend fun updateProgress( + itemId: String, + episodeId: String?, + currentTime: Double, + duration: Double, + isFinished: Boolean + ): Result { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + + try { + val progress = if (duration > 0) currentTime / duration else 0.0 + val request = ProgressUpdateRequest( + currentTime = currentTime, + duration = duration, + progress = progress, + isFinished = isFinished + ) + + if (networkConnectivityMonitor.isCurrentlyConnected()) { + val response = if (episodeId != null) { + apiService.get().updateEpisodeProgress(itemId, episodeId, request) + } else { + apiService.get().updateProgress(itemId, request) + } + + if (response.isSuccessful && response.body() != null) { + val mediaProgress = response.body()!! + cacheProgress(mediaProgress) + return@withContext Result.success(mediaProgress) + } + } + val localProgress = AudiobookshelfProgressEntity( + id = "${itemId}_${episodeId ?: ""}", + jellyfinServerId = currentServerId, + jellyfinUserId = currentUserId.toString(), + libraryItemId = itemId, + episodeId = episodeId, + currentTime = currentTime, + duration = duration, + progress = progress, + isFinished = isFinished, + lastUpdate = System.currentTimeMillis(), + startedAt = System.currentTimeMillis(), + finishedAt = if (isFinished) System.currentTimeMillis() else null, + pendingSync = true + ) + audiobookshelfDao.insertProgress(localProgress) + + Result.success(localProgress.toMediaProgress()) + } catch (e: Exception) { + Timber.e(e, "Failed to update progress") + Result.failure(e) + } + } + } + + override fun getProgressForItemFlow(itemId: String): Flow { + val (serverId, userId) = activeContext ?: return flowOf(null) + return audiobookshelfDao.getProgressForItemFlow(itemId, serverId, userId.toString()) + .map { it?.toMediaProgress() } + } + + override suspend fun startPlaybackSession( + itemId: String, + episodeId: String? + ): Result { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val deviceInfo = DeviceInfo( + deviceId = getDeviceId(), + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + sdkVersion = Build.VERSION.SDK_INT, + clientName = "AFinity", + clientVersion = BuildConfig.VERSION_NAME + ) + + val request = PlaybackSessionRequest( + deviceInfo = deviceInfo, + forceDirectPlay = true, + mediaPlayer = "ExoPlayer", + supportedMimeTypes = listOf( + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/flac", + "audio/wav" + ) + ) + + val response = if (episodeId != null) { + apiService.get().startEpisodePlaybackSession(itemId, episodeId, request) + } else { + apiService.get().startPlaybackSession(itemId, request) + } + + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("Failed to start session: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to start playback session") + Result.failure(e) + } + } + } + + override suspend fun syncPlaybackSession( + sessionId: String, + timeListened: Double, + currentTime: Double, + duration: Double + ): Result { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val syncData = MediaProgressSyncData( + currentTime = currentTime, + timeListened = timeListened, + duration = duration, + progress = if (duration > 0) currentTime / duration else 0.0 + ) + + val response = apiService.get().syncPlaybackSession(sessionId, syncData) + + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to sync session: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to sync playback session") + Result.failure(e) + } + } + } + + override suspend fun closePlaybackSession( + sessionId: String, + currentTime: Double, + timeListened: Double, + duration: Double + ): Result { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val syncData = MediaProgressSyncData( + currentTime = currentTime, + timeListened = timeListened, + duration = duration, + progress = if (duration > 0) currentTime / duration else 0.0 + ) + + val response = apiService.get().closePlaybackSession(sessionId, syncData) + + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to close session: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to close playback session") + Result.failure(e) + } + } + } + + override suspend fun syncPendingProgress(): Result { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + try { + val pendingProgress = audiobookshelfDao.getPendingSyncProgress( + currentServerId, + currentUserId.toString() + ) + + var syncedCount = 0 + + pendingProgress.forEach { progress -> + try { + val request = ProgressUpdateRequest( + currentTime = progress.currentTime, + duration = progress.duration, + progress = progress.progress, + isFinished = progress.isFinished + ) + + val response = if (progress.episodeId != null) { + apiService.get().updateEpisodeProgress( + progress.libraryItemId, + progress.episodeId, + request + ) + } else { + apiService.get().updateProgress(progress.libraryItemId, request) + } + + if (response.isSuccessful) { + audiobookshelfDao.markSynced( + progress.id, + currentServerId, + currentUserId.toString() + ) + syncedCount++ + } + } catch (e: Exception) { + Timber.w(e, "Failed to sync progress for item ${progress.libraryItemId}") + } + } + + Result.success(syncedCount) + } catch (e: Exception) { + Timber.e(e, "Failed to sync pending progress") + Result.failure(e) + } + } + } + + private suspend fun cacheProgress(progress: MediaProgress) { + val (currentServerId, currentUserId) = activeContext ?: return + + val entity = AudiobookshelfProgressEntity( + id = progress.id, + jellyfinServerId = currentServerId, + jellyfinUserId = currentUserId.toString(), + libraryItemId = progress.libraryItemId, + episodeId = progress.episodeId, + currentTime = progress.currentTime, + duration = progress.duration, + progress = progress.progress, + isFinished = progress.isFinished, + lastUpdate = progress.lastUpdate, + startedAt = progress.startedAt, + finishedAt = progress.finishedAt, + pendingSync = false + ) + + audiobookshelfDao.insertProgress(entity) + } + + private fun getDeviceId(): String { + return "${Build.MANUFACTURER}_${Build.MODEL}_${Build.ID}".replace(" ", "_") + } + + private fun AudiobookshelfLibraryEntity.toLibrary(): Library { + return Library( + id = id, + name = name, + mediaType = mediaType, + icon = icon, + displayOrder = displayOrder + ) + } + + private fun LibraryItem.toEntity(serverId: String, userId: String): AudiobookshelfItemEntity { + return AudiobookshelfItemEntity( + id = id, + jellyfinServerId = serverId, + jellyfinUserId = userId, + libraryId = libraryId, + title = media.metadata.title ?: "Unknown", + authorName = media.metadata.authorName, + narratorName = media.metadata.narratorName, + seriesName = media.metadata.seriesName, + seriesSequence = media.metadata.series?.firstOrNull()?.sequence, + mediaType = mediaType, + duration = media.duration, + coverUrl = media.coverPath, + description = media.metadata.description, + publishedYear = media.metadata.publishedYear, + genres = media.metadata.genres?.let { json.encodeToString(it) }, + numTracks = media.numTracks, + numChapters = media.numChapters, + addedAt = addedAt, + updatedAt = updatedAt, + cachedAt = System.currentTimeMillis() + ) + } + + private fun AudiobookshelfItemEntity.toLibraryItem(): LibraryItem { + val genres = genres?.let { + try { + json.decodeFromString>(it) + } catch (e: Exception) { + null + } + } + + return LibraryItem( + id = id, + libraryId = libraryId, + mediaType = mediaType, + media = com.makd.afinity.data.models.audiobookshelf.Media( + metadata = com.makd.afinity.data.models.audiobookshelf.MediaMetadata( + title = title, + authorName = authorName, + narratorName = narratorName, + seriesName = seriesName, + description = description, + publishedYear = publishedYear, + genres = genres + ), + duration = duration, + coverPath = coverUrl, + numTracks = numTracks, + numChapters = numChapters + ), + addedAt = addedAt, + updatedAt = updatedAt + ) + } + + private fun AudiobookshelfProgressEntity.toMediaProgress(): MediaProgress { + return MediaProgress( + id = id, + libraryItemId = libraryItemId, + episodeId = episodeId, + duration = duration, + progress = progress, + currentTime = currentTime, + isFinished = isFinished, + lastUpdate = lastUpdate, + startedAt = startedAt, + finishedAt = finishedAt + ) + } +} diff --git a/app/src/main/java/com/makd/afinity/data/repository/impl/SecurePreferencesRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/impl/SecurePreferencesRepositoryImpl.kt index 696eb5c1..70894fdb 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/impl/SecurePreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/impl/SecurePreferencesRepositoryImpl.kt @@ -12,6 +12,7 @@ import com.google.crypto.tink.RegistryConfiguration import com.google.crypto.tink.aead.AeadConfig import com.google.crypto.tink.aead.PredefinedAeadParameters import com.google.crypto.tink.integration.android.AndroidKeysetManager +import com.makd.afinity.data.repository.AudiobookshelfAuthData import com.makd.afinity.data.repository.SecurePreferencesRepository import com.makd.afinity.data.repository.ServerUserToken import dagger.hilt.android.qualifiers.ApplicationContext @@ -94,6 +95,18 @@ class SecurePreferencesRepositoryImpl @Inject constructor( @Volatile private var cachedJellyseerrUsername: String? = null + @Volatile + private var cachedAudiobookshelfUrl: String? = null + + @Volatile + private var cachedAudiobookshelfToken: String? = null + + @Volatile + private var cachedAudiobookshelfUserId: String? = null + + @Volatile + private var cachedAudiobookshelfUsername: String? = null + private val _authenticationState = MutableStateFlow(false) init { @@ -171,6 +184,10 @@ class SecurePreferencesRepositoryImpl @Inject constructor( cachedJellyseerrUrl = null cachedJellyseerrCookie = null cachedJellyseerrUsername = null + cachedAudiobookshelfUrl = null + cachedAudiobookshelfToken = null + cachedAudiobookshelfUserId = null + cachedAudiobookshelfUsername = null _authenticationState.value = false Timber.d("Cleared all secure data") } @@ -409,4 +426,120 @@ class SecurePreferencesRepositoryImpl @Inject constructor( } Timber.d("Cleared all tokens for server=$serverId") } + + private fun getAudiobookshelfKey( + prefix: String, serverId: String, userId: UUID + ): Preferences.Key { + return stringPreferencesKey("${prefix}_${serverId}_$userId") + } + + override suspend fun saveAudiobookshelfAuthForUser( + jellyfinServerId: String, + jellyfinUserId: UUID, + serverUrl: String, + accessToken: String, + absUserId: String, + username: String + ) { + context.dataStore.edit { prefs -> + prefs[getAudiobookshelfKey("abs_url", jellyfinServerId, jellyfinUserId)] = + encrypt(serverUrl) + prefs[getAudiobookshelfKey("abs_token", jellyfinServerId, jellyfinUserId)] = + encrypt(accessToken) + prefs[getAudiobookshelfKey("abs_userid", jellyfinServerId, jellyfinUserId)] = + encrypt(absUserId) + prefs[getAudiobookshelfKey("abs_user", jellyfinServerId, jellyfinUserId)] = + encrypt(username) + } + + cachedAudiobookshelfUrl = serverUrl + cachedAudiobookshelfToken = accessToken + cachedAudiobookshelfUserId = absUserId + cachedAudiobookshelfUsername = username + + Timber.d("Saved Audiobookshelf auth for user $jellyfinUserId on server $jellyfinServerId") + } + + override suspend fun switchAudiobookshelfContext( + jellyfinServerId: String, jellyfinUserId: UUID + ): Boolean { + return withContext(Dispatchers.IO) { + val prefs = context.dataStore.data.first() + + val urlKey = getAudiobookshelfKey("abs_url", jellyfinServerId, jellyfinUserId) + val tokenKey = getAudiobookshelfKey("abs_token", jellyfinServerId, jellyfinUserId) + val userIdKey = getAudiobookshelfKey("abs_userid", jellyfinServerId, jellyfinUserId) + val userKey = getAudiobookshelfKey("abs_user", jellyfinServerId, jellyfinUserId) + + val url = decrypt(prefs[urlKey]) + val token = decrypt(prefs[tokenKey]) + val absUserId = decrypt(prefs[userIdKey]) + val username = decrypt(prefs[userKey]) + + cachedAudiobookshelfUrl = url + cachedAudiobookshelfToken = token + cachedAudiobookshelfUserId = absUserId + cachedAudiobookshelfUsername = username + + Timber.d("Switched Audiobookshelf context. Valid: ${!url.isNullOrBlank() && !token.isNullOrBlank()}") + + !url.isNullOrBlank() && !token.isNullOrBlank() + } + } + + override fun clearActiveAudiobookshelfCache() { + cachedAudiobookshelfUrl = null + cachedAudiobookshelfToken = null + cachedAudiobookshelfUserId = null + cachedAudiobookshelfUsername = null + } + + override suspend fun getAudiobookshelfAuthForUser( + jellyfinServerId: String, jellyfinUserId: UUID + ): AudiobookshelfAuthData? { + return withContext(Dispatchers.IO) { + val prefs = context.dataStore.data.first() + val url = + decrypt(prefs[getAudiobookshelfKey("abs_url", jellyfinServerId, jellyfinUserId)]) + val token = + decrypt(prefs[getAudiobookshelfKey("abs_token", jellyfinServerId, jellyfinUserId)]) + val absUserId = + decrypt(prefs[getAudiobookshelfKey("abs_userid", jellyfinServerId, jellyfinUserId)]) + val username = + decrypt(prefs[getAudiobookshelfKey("abs_user", jellyfinServerId, jellyfinUserId)]) + + if (url != null && token != null && absUserId != null && username != null) { + AudiobookshelfAuthData( + serverUrl = url, + accessToken = token, + absUserId = absUserId, + username = username + ) + } else { + null + } + } + } + + override suspend fun clearAudiobookshelfAuthForUser( + jellyfinServerId: String, jellyfinUserId: UUID + ) { + context.dataStore.edit { prefs -> + prefs.remove(getAudiobookshelfKey("abs_url", jellyfinServerId, jellyfinUserId)) + prefs.remove(getAudiobookshelfKey("abs_token", jellyfinServerId, jellyfinUserId)) + prefs.remove(getAudiobookshelfKey("abs_userid", jellyfinServerId, jellyfinUserId)) + prefs.remove(getAudiobookshelfKey("abs_user", jellyfinServerId, jellyfinUserId)) + } + clearActiveAudiobookshelfCache() + Timber.d("Cleared Audiobookshelf auth for user $jellyfinUserId on server $jellyfinServerId") + } + + override fun getCachedAudiobookshelfServerUrl(): String? = cachedAudiobookshelfUrl + + override fun getCachedAudiobookshelfToken(): String? = cachedAudiobookshelfToken + + override suspend fun hasValidAudiobookshelfAuth(): Boolean { + if (!cachedAudiobookshelfToken.isNullOrBlank() && !cachedAudiobookshelfUrl.isNullOrBlank()) return true + return false + } } \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/di/AudiobookshelfModule.kt b/app/src/main/java/com/makd/afinity/di/AudiobookshelfModule.kt new file mode 100644 index 00000000..1bb7fe69 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/di/AudiobookshelfModule.kt @@ -0,0 +1,31 @@ +package com.makd.afinity.di + +import com.makd.afinity.data.database.AfinityDatabase +import com.makd.afinity.data.database.dao.AudiobookshelfDao +import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.audiobookshelf.AudiobookshelfRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AudiobookshelfModule { + + @Binds + @Singleton + abstract fun bindAudiobookshelfRepository( + impl: AudiobookshelfRepositoryImpl + ): AudiobookshelfRepository + + companion object { + @Provides + @Singleton + fun provideAudiobookshelfDao(database: AfinityDatabase): AudiobookshelfDao { + return database.audiobookshelfDao() + } + } +} diff --git a/app/src/main/java/com/makd/afinity/di/HiltQualifiers.kt b/app/src/main/java/com/makd/afinity/di/HiltQualifiers.kt index d81d0825..e97ed125 100644 --- a/app/src/main/java/com/makd/afinity/di/HiltQualifiers.kt +++ b/app/src/main/java/com/makd/afinity/di/HiltQualifiers.kt @@ -32,4 +32,8 @@ annotation class MainDispatcher @Qualifier @Retention(AnnotationRetention.BINARY) -annotation class DefaultDispatcher \ No newline at end of file +annotation class DefaultDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AudiobookshelfClient \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/di/NetworkModule.kt b/app/src/main/java/com/makd/afinity/di/NetworkModule.kt index c90a898c..46d41a90 100644 --- a/app/src/main/java/com/makd/afinity/di/NetworkModule.kt +++ b/app/src/main/java/com/makd/afinity/di/NetworkModule.kt @@ -3,6 +3,7 @@ package com.makd.afinity.di import android.content.Context import com.makd.afinity.BuildConfig import com.makd.afinity.core.AppConstants +import com.makd.afinity.data.network.AudiobookshelfApiService import com.makd.afinity.data.network.JellyseerrApiService import com.makd.afinity.data.repository.SecurePreferencesRepository import dagger.Module @@ -42,6 +43,10 @@ annotation class DownloadClient @Retention(AnnotationRetention.BINARY) annotation class JellyseerrClient +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AudiobookshelfRetrofit + @Module @InstallIn(SingletonComponent::class) object NetworkModule { @@ -284,4 +289,107 @@ object NetworkModule { fun provideJellyseerrApiService(retrofit: Retrofit): JellyseerrApiService { return retrofit.create(JellyseerrApiService::class.java) } + + private fun normalizeAudiobookshelfUrl(raw: String?): String { + if (raw.isNullOrBlank()) { + throw IOException("Audiobookshelf server URL not configured") + } + + var base = raw.trim() + + if (!base.startsWith("http://") && !base.startsWith("https://")) { + base = "http://$base" + } + + if (!base.endsWith("/")) { + base += "/" + } + + return base + } + + @Provides + @Singleton + @AudiobookshelfClient + fun provideAudiobookshelfOkHttpClient( + baseOkHttpClient: OkHttpClient, + securePreferencesRepository: SecurePreferencesRepository + ): OkHttpClient { + return baseOkHttpClient.newBuilder() + .addInterceptor { chain -> + val originalRequest = chain.request() + + val savedUrl = securePreferencesRepository.getCachedAudiobookshelfServerUrl() + val currentBaseUrl = try { + if (!savedUrl.isNullOrBlank()) { + normalizeAudiobookshelfUrl(savedUrl) + } else { + null + } + } catch (e: Exception) { + null + } + + if (currentBaseUrl == null) { + throw IOException("Audiobookshelf server URL not configured. Please configure the server URL first.") + } + + val newUrl = currentBaseUrl.toHttpUrlOrNull()?.newBuilder() + ?.addPathSegments(originalRequest.url.encodedPath.removePrefix("/")) + ?.apply { + for (i in 0 until originalRequest.url.querySize) { + addQueryParameter( + originalRequest.url.queryParameterName(i), + originalRequest.url.queryParameterValue(i) + ) + } + } + ?.build() + ?: throw IOException("Failed to build Audiobookshelf URL") + + val newRequest = originalRequest.newBuilder() + .url(newUrl) + .apply { + val token = securePreferencesRepository.getCachedAudiobookshelfToken() + token?.let { + addHeader("Authorization", "Bearer $it") + } + addHeader("Content-Type", "application/json") + } + .build() + + chain.proceed(newRequest) + } + .build() + } + + private val audiobookshelfJson = kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + @Provides + @Singleton + @AudiobookshelfRetrofit + fun provideAudiobookshelfRetrofit( + @AudiobookshelfClient okHttpClient: OkHttpClient + ): Retrofit { + val contentType = "application/json".toMediaType() + val baseUrl = "http://placeholder.audiobookshelf/" + + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(audiobookshelfJson.asConverterFactory(contentType)) + .build() + } + + @Provides + @Singleton + fun provideAudiobookshelfApiService( + @AudiobookshelfRetrofit retrofit: Retrofit + ): AudiobookshelfApiService { + return retrofit.create(AudiobookshelfApiService::class.java) + } } \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/navigation/Destination.kt b/app/src/main/java/com/makd/afinity/navigation/Destination.kt index 227f9d07..e2ee41a5 100644 --- a/app/src/main/java/com/makd/afinity/navigation/Destination.kt +++ b/app/src/main/java/com/makd/afinity/navigation/Destination.kt @@ -38,6 +38,12 @@ enum class Destination( selectedIconRes = R.drawable.ic_plus_filled, unselectedIconRes = R.drawable.ic_plus ), + AUDIOBOOKS( + route = "audiobookshelf/libraries", + title = "Audiobooks", + selectedIconRes = R.drawable.ic_headphones_filled, + unselectedIconRes = R.drawable.ic_headphones + ), LIVE_TV( route = "live_tv", title = "Live TV", @@ -64,6 +70,37 @@ enum class Destination( const val LOGIN_ROUTE = "login?serverUrl={serverUrl}" + const val AUDIOBOOKSHELF_LOGIN_ROUTE = "audiobookshelf/login" + const val AUDIOBOOKSHELF_LIBRARIES_ROUTE = "audiobookshelf/libraries" + const val AUDIOBOOKSHELF_LIBRARY_ROUTE = "audiobookshelf/library/{libraryId}" + const val AUDIOBOOKSHELF_ITEM_ROUTE = "audiobookshelf/item/{itemId}" + const val AUDIOBOOKSHELF_PLAYER_ROUTE = + "audiobookshelf/player/{itemId}?episodeId={episodeId}" + + fun createAudiobookshelfLoginRoute(): String { + return AUDIOBOOKSHELF_LOGIN_ROUTE + } + + fun createAudiobookshelfLibrariesRoute(): String { + return AUDIOBOOKSHELF_LIBRARIES_ROUTE + } + + fun createAudiobookshelfLibraryRoute(libraryId: String): String { + return "audiobookshelf/library/$libraryId" + } + + fun createAudiobookshelfItemRoute(itemId: String): String { + return "audiobookshelf/item/$itemId" + } + + fun createAudiobookshelfPlayerRoute(itemId: String, episodeId: String? = null): String { + return if (episodeId != null) { + "audiobookshelf/player/$itemId?episodeId=$episodeId" + } else { + "audiobookshelf/player/$itemId" + } + } + fun createPersonRoute(personId: String): String { return "person/$personId" } @@ -133,7 +170,11 @@ enum class Destination( return LICENSES_ROUTE } - fun createFilteredMediaRoute(filterType: String, filterId: Int, filterName: String): String { + fun createFilteredMediaRoute( + filterType: String, + filterId: Int, + filterName: String + ): String { return "filtered_media/$filterType/$filterId/${filterName.replace("/", "%2F")}" } diff --git a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt index 3e1ddb87..b9041ea5 100644 --- a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt +++ b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt @@ -2,6 +2,10 @@ package com.makd.afinity.navigation +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -29,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.makd.afinity.data.manager.OfflineModeManager +import com.makd.afinity.data.repository.AudiobookshelfRepository import com.makd.afinity.data.repository.JellyseerrRepository import com.makd.afinity.data.repository.watchlist.WatchlistRepository import com.makd.afinity.data.updater.UpdateManager @@ -48,6 +53,12 @@ import com.makd.afinity.ui.requests.FilteredMediaScreen import com.makd.afinity.ui.requests.RequestsScreen import com.makd.afinity.ui.search.GenreResultsScreen import com.makd.afinity.ui.search.SearchScreen +import com.makd.afinity.ui.audiobookshelf.item.AudiobookshelfItemScreen +import com.makd.afinity.ui.audiobookshelf.libraries.AudiobookshelfLibrariesScreen +import com.makd.afinity.ui.audiobookshelf.library.AudiobookshelfLibraryScreen +import com.makd.afinity.ui.audiobookshelf.login.AudiobookshelfLoginScreen +import com.makd.afinity.ui.audiobookshelf.player.AudiobookshelfPlayerScreen +import com.makd.afinity.ui.audiobookshelf.player.components.MiniPlayer import com.makd.afinity.ui.settings.LicensesScreen import com.makd.afinity.ui.settings.SettingsScreen import com.makd.afinity.ui.settings.appearance.AppearanceOptionsScreen @@ -79,9 +90,15 @@ fun MainNavigation( hiltViewModel().jellyseerrRepository val isJellyseerrAuthenticated by jellyseerrRepository.isAuthenticated .collectAsStateWithLifecycle() + val audiobookshelfRepository: AudiobookshelfRepository = + hiltViewModel().audiobookshelfRepository + val isAudiobookshelfAuthenticated by audiobookshelfRepository.isAuthenticated + .collectAsStateWithLifecycle() val hasLiveTvAccess by viewModel.hasLiveTvAccess.collectAsStateWithLifecycle() val appLoadingState by viewModel.appLoadingState.collectAsStateWithLifecycle() val isOffline by offlineModeManager.isOffline.collectAsStateWithLifecycle(initialValue = false) + val audiobookshelfPlaybackState by viewModel.audiobookshelfPlaybackManager.playbackState + .collectAsStateWithLifecycle() val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -113,7 +130,11 @@ fun MainNavigation( route != "licenses" && route != "server_management" && !route.startsWith("add_edit_server") && - !route.startsWith("login") + !route.startsWith("login") && + !route.startsWith("audiobookshelf/login") && + !route.startsWith("audiobookshelf/library/") && + !route.startsWith("audiobookshelf/item/") && + !route.startsWith("audiobookshelf/player/") } ?: true val navigationSuiteScaffoldState = rememberNavigationSuiteScaffoldState() @@ -160,6 +181,10 @@ fun MainNavigation( return@forEach } + if (destination == Destination.AUDIOBOOKS && !isAudiobookshelfAuthenticated) { + return@forEach + } + if (destination == Destination.LIVE_TV && !hasLiveTvAccess) { return@forEach } @@ -212,10 +237,15 @@ fun MainNavigation( navigationRailContainerColor = MaterialTheme.colorScheme.surface ) ) { + val isOnAudiobookshelfPlayer = currentDestination?.route + ?.startsWith("audiobookshelf/player/") == true + val showMiniPlayer = audiobookshelfPlaybackState.sessionId != null && !isOnAudiobookshelfPlayer + + Column(modifier = Modifier.fillMaxSize()) { NavHost( navController = navController, startDestination = Destination.HOME.route, - modifier = Modifier.fillMaxSize() + modifier = Modifier.weight(1f) ) { composable(Destination.HOME.route) { HomeScreen( @@ -550,6 +580,9 @@ fun MainNavigation( onServerManagementClick = { val route = Destination.createServerManagementRoute() navController.navigate(route) + }, + onAudiobookshelfClick = { + navController.navigate(Destination.createAudiobookshelfLoginRoute()) } ) } @@ -642,6 +675,121 @@ fun MainNavigation( widthSizeClass = widthSizeClass ) } + + composable(Destination.AUDIOBOOKSHELF_LOGIN_ROUTE) { + AudiobookshelfLoginScreen( + onNavigateBack = { navController.popBackStack() }, + onLoginSuccess = { + navController.navigate(Destination.createAudiobookshelfLibrariesRoute()) { + popUpTo(Destination.AUDIOBOOKSHELF_LOGIN_ROUTE) { inclusive = true } + } + } + ) + } + + composable(Destination.AUDIOBOOKSHELF_LIBRARIES_ROUTE) { + AudiobookshelfLibrariesScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToLibrary = { libraryId -> + navController.navigate( + Destination.createAudiobookshelfLibraryRoute( + libraryId + ) + ) + }, + onNavigateToItem = { itemId -> + navController.navigate(Destination.createAudiobookshelfItemRoute(itemId)) + }, + onNavigateToLogin = { + navController.navigate(Destination.createAudiobookshelfLoginRoute()) + } + ) + } + + composable( + route = Destination.AUDIOBOOKSHELF_LIBRARY_ROUTE, + arguments = listOf( + navArgument("libraryId") { type = NavType.StringType } + ) + ) { + AudiobookshelfLibraryScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToItem = { itemId -> + navController.navigate(Destination.createAudiobookshelfItemRoute(itemId)) + } + ) + } + + composable( + route = Destination.AUDIOBOOKSHELF_ITEM_ROUTE, + arguments = listOf( + navArgument("itemId") { type = NavType.StringType } + ) + ) { + AudiobookshelfItemScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToPlayer = { itemId, episodeId -> + navController.navigate( + Destination.createAudiobookshelfPlayerRoute(itemId, episodeId) + ) + } + ) + } + + composable( + route = Destination.AUDIOBOOKSHELF_PLAYER_ROUTE, + arguments = listOf( + navArgument("itemId") { type = NavType.StringType }, + navArgument("episodeId") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { + AudiobookshelfPlayerScreen( + onNavigateBack = { navController.popBackStack() } + ) + } + } + + AnimatedVisibility( + visible = showMiniPlayer, + enter = slideInVertically { it }, + exit = slideOutVertically { it } + ) { + MiniPlayer( + title = audiobookshelfPlaybackState.displayTitle, + author = audiobookshelfPlaybackState.displayAuthor, + coverUrl = audiobookshelfPlaybackState.coverUrl, + currentTime = audiobookshelfPlaybackState.currentTime, + duration = audiobookshelfPlaybackState.duration, + isPlaying = audiobookshelfPlaybackState.isPlaying, + isBuffering = audiobookshelfPlaybackState.isBuffering, + onPlayPauseClick = { + if (viewModel.audiobookshelfPlayer.isPlaying()) { + viewModel.audiobookshelfPlayer.pause() + } else { + viewModel.audiobookshelfPlayer.play() + } + }, + onCloseClick = { + viewModel.audiobookshelfPlayer.pause() + coroutineScope.launch { + viewModel.audiobookshelfPlayer.closeSession() + } + }, + onClick = { + val itemId = audiobookshelfPlaybackState.itemId + val episodeId = audiobookshelfPlaybackState.episodeId + if (itemId != null) { + navController.navigate( + Destination.createAudiobookshelfPlayerRoute(itemId, episodeId) + ) + } + } + ) + } } } GlobalUpdateDialog(updateManager = updateManager) diff --git a/app/src/main/java/com/makd/afinity/navigation/MainNavigationViewModel.kt b/app/src/main/java/com/makd/afinity/navigation/MainNavigationViewModel.kt index a0f2a143..ebca5bec 100644 --- a/app/src/main/java/com/makd/afinity/navigation/MainNavigationViewModel.kt +++ b/app/src/main/java/com/makd/afinity/navigation/MainNavigationViewModel.kt @@ -7,10 +7,13 @@ import com.makd.afinity.data.models.media.AfinityItem import com.makd.afinity.data.models.media.AfinityShow import com.makd.afinity.data.repository.AppDataRepository import com.makd.afinity.data.repository.JellyfinRepository +import com.makd.afinity.data.repository.AudiobookshelfRepository import com.makd.afinity.data.repository.JellyseerrRepository import com.makd.afinity.data.repository.auth.AuthRepository import com.makd.afinity.data.repository.livetv.LiveTvRepository import com.makd.afinity.data.repository.watchlist.WatchlistRepository +import com.makd.afinity.player.audiobookshelf.AudiobookshelfPlaybackManager +import com.makd.afinity.player.audiobookshelf.AudiobookshelfPlayer import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -28,6 +31,9 @@ class MainNavigationViewModel @Inject constructor( private val jellyfinRepository: JellyfinRepository, val watchlistRepository: WatchlistRepository, val jellyseerrRepository: JellyseerrRepository, + val audiobookshelfRepository: AudiobookshelfRepository, + val audiobookshelfPlayer: AudiobookshelfPlayer, + val audiobookshelfPlaybackManager: AudiobookshelfPlaybackManager, private val liveTvRepository: LiveTvRepository, private val offlineModeManager: OfflineModeManager ) : ViewModel() { @@ -82,8 +88,7 @@ class MainNavigationViewModel @Inject constructor( Timber.d("Fresh login detected") _hasLiveTvAccess.value = false loadAppData() - } - else if (!isAuthenticated) { + } else if (!isAuthenticated) { _hasLiveTvAccess.value = false } diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlaybackManager.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlaybackManager.kt new file mode 100644 index 00000000..d211ea35 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlaybackManager.kt @@ -0,0 +1,120 @@ +package com.makd.afinity.player.audiobookshelf + +import com.makd.afinity.data.models.audiobookshelf.AudioTrack +import com.makd.afinity.data.models.audiobookshelf.BookChapter +import com.makd.afinity.data.models.audiobookshelf.PlaybackSession +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudiobookshelfPlaybackManager @Inject constructor() { + + private val _playbackState = MutableStateFlow(AudiobookshelfPlaybackState()) + val playbackState: StateFlow = _playbackState.asStateFlow() + + private val _currentSession = MutableStateFlow(null) + val currentSession: StateFlow = _currentSession.asStateFlow() + + fun setSession(session: PlaybackSession, serverUrl: String? = null, token: String? = null) { + _currentSession.value = session + + val coverUrl = if (serverUrl != null) { + val base = "$serverUrl/api/items/${session.libraryItemId}/cover" + if (token != null) "$base?token=$token" else base + } else { + session.coverPath + } + + _playbackState.value = _playbackState.value.copy( + sessionId = session.id, + itemId = session.libraryItemId, + episodeId = session.episodeId, + duration = session.duration, + chapters = session.chapters ?: emptyList(), + audioTracks = session.audioTracks ?: emptyList(), + displayTitle = session.displayTitle ?: "Unknown", + displayAuthor = session.displayAuthor, + coverUrl = coverUrl + ) + } + + fun updatePosition(currentTime: Double) { + _playbackState.value = _playbackState.value.copy( + currentTime = currentTime, + currentChapter = findCurrentChapter(currentTime) + ) + } + + fun updatePlayingState(isPlaying: Boolean) { + _playbackState.value = _playbackState.value.copy(isPlaying = isPlaying) + } + + fun updateBufferingState(isBuffering: Boolean) { + _playbackState.value = _playbackState.value.copy(isBuffering = isBuffering) + } + + fun updatePlaybackSpeed(speed: Float) { + _playbackState.value = _playbackState.value.copy(playbackSpeed = speed) + } + + fun setSleepTimer(endTimeMillis: Long?) { + _playbackState.value = _playbackState.value.copy(sleepTimerEndTime = endTimeMillis) + } + + fun clearSession() { + _currentSession.value = null + _playbackState.value = AudiobookshelfPlaybackState() + } + + private fun findCurrentChapter(currentTime: Double): BookChapter? { + return _playbackState.value.chapters.find { chapter -> + currentTime >= chapter.start && currentTime < chapter.end + } + } + + fun getNextChapter(): BookChapter? { + val currentChapter = _playbackState.value.currentChapter + ?: return _playbackState.value.chapters.firstOrNull() + val currentIndex = _playbackState.value.chapters.indexOf(currentChapter) + return _playbackState.value.chapters.getOrNull(currentIndex + 1) + } + + fun getPreviousChapter(): BookChapter? { + val currentChapter = _playbackState.value.currentChapter ?: return null + val currentIndex = _playbackState.value.chapters.indexOf(currentChapter) + return _playbackState.value.chapters.getOrNull(currentIndex - 1) + } +} + +data class AudiobookshelfPlaybackState( + val sessionId: String? = null, + val itemId: String? = null, + val episodeId: String? = null, + val displayTitle: String = "", + val displayAuthor: String? = null, + val coverUrl: String? = null, + val currentTime: Double = 0.0, + val duration: Double = 0.0, + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val playbackSpeed: Float = 1.0f, + val chapters: List = emptyList(), + val audioTracks: List = emptyList(), + val currentChapter: BookChapter? = null, + val sleepTimerEndTime: Long? = null +) { + val currentChapterIndex: Int + get() = currentChapter?.let { chapter -> chapters.indexOf(chapter) } ?: -1 + + val progress: Float + get() = if (duration > 0) (currentTime / duration).toFloat() else 0f + + val remainingTime: Double + get() = (duration - currentTime).coerceAtLeast(0.0) + + val hasSleepTimer: Boolean + get() = sleepTimerEndTime != null && sleepTimerEndTime > System.currentTimeMillis() +} diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt new file mode 100644 index 00000000..d942bcf7 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt @@ -0,0 +1,315 @@ +package com.makd.afinity.player.audiobookshelf + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.makd.afinity.data.models.audiobookshelf.PlaybackSession +import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.SecurePreferencesRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudiobookshelfPlayer @Inject constructor( + @ApplicationContext private val context: Context, + private val playbackManager: AudiobookshelfPlaybackManager, + private val progressSyncer: AudiobookshelfProgressSyncer, + private val audiobookshelfRepository: AudiobookshelfRepository, + private val securePreferencesRepository: SecurePreferencesRepository +) { + private var exoPlayer: ExoPlayer? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var positionUpdateJob: Job? = null + private var sleepTimerJob: Job? = null + + private var currentSession: PlaybackSession? = null + private var serverUrl: String? = null + + private val playerListener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + playbackManager.updatePlayingState(isPlaying) + + if (isPlaying) { + startPositionUpdates() + progressSyncer.startSyncing() + } else { + stopPositionUpdates() + scope.launch { + progressSyncer.syncNow() + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> { + playbackManager.updateBufferingState(true) + } + + Player.STATE_READY -> { + playbackManager.updateBufferingState(false) + } + + Player.STATE_ENDED -> { + playbackManager.updatePlayingState(false) + stopPositionUpdates() + scope.launch { + progressSyncer.syncNow() + audiobookshelfRepository.updateProgress( + itemId = currentSession?.libraryItemId ?: return@launch, + episodeId = currentSession?.episodeId, + currentTime = playbackManager.playbackState.value.duration, + duration = playbackManager.playbackState.value.duration, + isFinished = true + ) + } + } + + Player.STATE_IDLE -> { + } + } + } + + override fun onPlayerError(error: PlaybackException) { + Timber.e(error, "Player error: ${error.message}") + playbackManager.updatePlayingState(false) + playbackManager.updateBufferingState(false) + } + } + + @OptIn(UnstableApi::class) + fun initialize() { + if (exoPlayer != null) return + + val token = securePreferencesRepository.getCachedAudiobookshelfToken() + val dataSourceFactory = DefaultHttpDataSource.Factory() + .setDefaultRequestProperties( + buildMap { + if (token != null) { + put("Authorization", "Bearer $token") + } + } + ) + + exoPlayer = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setHandleAudioBecomingNoisy(true) + .build() + .apply { + addListener(playerListener) + playWhenReady = false + } + + Timber.d("AudiobookshelfPlayer initialized") + } + + fun release() { + stopPositionUpdates() + progressSyncer.stopSyncing() + cancelSleepTimer() + + exoPlayer?.apply { + removeListener(playerListener) + release() + } + exoPlayer = null + + playbackManager.clearSession() + currentSession = null + + Timber.d("AudiobookshelfPlayer released") + } + + suspend fun loadSession(session: PlaybackSession, baseUrl: String) { + release() + initialize() + + currentSession = session + serverUrl = baseUrl + + val token = securePreferencesRepository.getCachedAudiobookshelfToken() + playbackManager.setSession(session, baseUrl, token) + + val audioTracks = session.audioTracks + if (audioTracks.isNullOrEmpty()) { + Timber.e("No audio tracks in session") + return + } + + val mediaItems = audioTracks.map { track -> + val url = if (track.contentUrl?.startsWith("http") == true) { + track.contentUrl + } else { + "$baseUrl${track.contentUrl}" + } + + MediaItem.Builder() + .setUri(url) + .setMediaId(track.index.toString()) + .build() + } + + exoPlayer?.apply { + setMediaItems(mediaItems) + prepare() + val startTime = session.currentTime + if (startTime > 0) { + seekToPosition(startTime) + } + } + + Timber.d("Loaded session with ${audioTracks.size} tracks, starting at ${session.currentTime}s") + } + + fun play() { + exoPlayer?.play() + } + + fun pause() { + exoPlayer?.pause() + } + + fun seekTo(positionMs: Long) { + exoPlayer?.seekTo(positionMs) + updatePosition() + } + + fun seekToPosition(positionSeconds: Double) { + val audioTracks = currentSession?.audioTracks ?: return + var accumulatedDuration = 0.0 + + for ((index, track) in audioTracks.withIndex()) { + if (positionSeconds < accumulatedDuration + track.duration) { + val positionInTrack = positionSeconds - accumulatedDuration + exoPlayer?.apply { + seekTo(index, (positionInTrack * 1000).toLong()) + } + break + } + accumulatedDuration += track.duration + } + + updatePosition() + } + + fun skipForward(seconds: Int = 30) { + val currentPosition = getCurrentPositionSeconds() + seekToPosition(currentPosition + seconds) + } + + fun skipBackward(seconds: Int = 30) { + val currentPosition = getCurrentPositionSeconds() + seekToPosition((currentPosition - seconds).coerceAtLeast(0.0)) + } + + fun seekToChapter(chapterIndex: Int) { + val chapters = playbackManager.playbackState.value.chapters + if (chapterIndex in chapters.indices) { + seekToPosition(chapters[chapterIndex].start) + } + } + + fun setPlaybackSpeed(speed: Float) { + exoPlayer?.setPlaybackSpeed(speed) + playbackManager.updatePlaybackSpeed(speed) + } + + fun setSleepTimer(durationMinutes: Int) { + cancelSleepTimer() + + if (durationMinutes <= 0) { + playbackManager.setSleepTimer(null) + return + } + + val endTime = System.currentTimeMillis() + (durationMinutes * 60 * 1000L) + playbackManager.setSleepTimer(endTime) + + sleepTimerJob = scope.launch { + delay(durationMinutes * 60 * 1000L) + pause() + playbackManager.setSleepTimer(null) + Timber.d("Sleep timer triggered - pausing playback") + } + + Timber.d("Sleep timer set for $durationMinutes minutes") + } + + fun cancelSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + playbackManager.setSleepTimer(null) + } + + fun getCurrentPositionSeconds(): Double { + val player = exoPlayer ?: return 0.0 + val audioTracks = currentSession?.audioTracks ?: return 0.0 + var totalPosition = 0.0 + val currentMediaItemIndex = player.currentMediaItemIndex + + for (i in 0 until currentMediaItemIndex) { + totalPosition += audioTracks.getOrNull(i)?.duration ?: 0.0 + } + + totalPosition += player.currentPosition / 1000.0 + + return totalPosition + } + + private fun startPositionUpdates() { + stopPositionUpdates() + + positionUpdateJob = scope.launch { + while (true) { + updatePosition() + delay(1000) + } + } + } + + private fun stopPositionUpdates() { + positionUpdateJob?.cancel() + positionUpdateJob = null + } + + private fun updatePosition() { + val position = getCurrentPositionSeconds() + playbackManager.updatePosition(position) + } + + fun isPlaying(): Boolean = exoPlayer?.isPlaying == true + + suspend fun closeSession() { + val state = playbackManager.playbackState.value + val sessionId = state.sessionId ?: return + + progressSyncer.stopSyncing() + + try { + audiobookshelfRepository.closePlaybackSession( + sessionId = sessionId, + currentTime = state.currentTime, + timeListened = 0.0, + duration = state.duration + ) + } catch (e: Exception) { + Timber.e(e, "Failed to close session") + } + + playbackManager.clearSession() + } +} diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt new file mode 100644 index 00000000..9b15f70c --- /dev/null +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt @@ -0,0 +1,79 @@ +package com.makd.afinity.player.audiobookshelf + +import android.app.PendingIntent +import android.content.Intent +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import com.makd.afinity.MainActivity +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class AudiobookshelfPlayerService : MediaSessionService() { + + @Inject + lateinit var audiobookshelfPlayer: AudiobookshelfPlayer + + @Inject + lateinit var playbackManager: AudiobookshelfPlaybackManager + + private var mediaSession: MediaSession? = null + + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + + val player = ExoPlayer.Builder(this) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_MEDIA) + .build(), + true + ) + .setHandleAudioBecomingNoisy(true) + .build() + + val sessionActivityPendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + mediaSession = MediaSession.Builder(this, player) + .setSessionActivity(sessionActivityPendingIntent) + .build() + + Timber.d("AudiobookshelfPlayerService created") + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + } + mediaSession = null + + Timber.d("AudiobookshelfPlayerService destroyed") + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + val player = mediaSession?.player + + if (player == null || !player.playWhenReady || player.mediaItemCount == 0) { + stopSelf() + } + } +} diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfProgressSyncer.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfProgressSyncer.kt new file mode 100644 index 00000000..4334327e --- /dev/null +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfProgressSyncer.kt @@ -0,0 +1,109 @@ +package com.makd.afinity.player.audiobookshelf + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import com.makd.afinity.data.repository.AudiobookshelfRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudiobookshelfProgressSyncer @Inject constructor( + @ApplicationContext private val context: Context, + private val audiobookshelfRepository: AudiobookshelfRepository, + private val playbackManager: AudiobookshelfPlaybackManager +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var syncJob: Job? = null + private var lastSyncTime: Double = 0.0 + private var totalTimeListened: Double = 0.0 + private var sessionStartTime: Long = 0L + + companion object { + private const val WIFI_SYNC_INTERVAL_MS = 15_000L + private const val CELLULAR_SYNC_INTERVAL_MS = 60_000L + } + + fun startSyncing() { + stopSyncing() + + sessionStartTime = System.currentTimeMillis() + lastSyncTime = playbackManager.playbackState.value.currentTime + totalTimeListened = 0.0 + + syncJob = scope.launch { + while (true) { + val interval = getSyncInterval() + delay(interval) + + syncProgress() + } + } + + Timber.d("Progress syncer started") + } + + fun stopSyncing() { + syncJob?.cancel() + syncJob = null + Timber.d("Progress syncer stopped") + } + + suspend fun syncNow() { + syncProgress() + } + + private suspend fun syncProgress() { + val state = playbackManager.playbackState.value + val sessionId = state.sessionId ?: return + + val currentTime = state.currentTime + val timeListenedSinceLastSync = (currentTime - lastSyncTime).coerceAtLeast(0.0) + totalTimeListened += timeListenedSinceLastSync + lastSyncTime = currentTime + + try { + val result = audiobookshelfRepository.syncPlaybackSession( + sessionId = sessionId, + timeListened = timeListenedSinceLastSync, + currentTime = currentTime, + duration = state.duration + ) + + result.fold( + onSuccess = { + Timber.d("Progress synced: ${currentTime}s / ${state.duration}s") + }, + onFailure = { error -> + Timber.w(error, "Failed to sync progress, will retry") + } + ) + } catch (e: Exception) { + Timber.e(e, "Exception syncing progress") + } + } + + private fun getSyncInterval(): Long { + return if (isOnWifi()) { + WIFI_SYNC_INTERVAL_MS + } else { + CELLULAR_SYNC_INTERVAL_MS + } + } + + private fun isOnWifi(): Boolean { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt new file mode 100644 index 00000000..48421066 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt @@ -0,0 +1,153 @@ +package com.makd.afinity.ui.audiobookshelf.item + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.ui.audiobookshelf.item.components.ChapterList +import com.makd.afinity.ui.audiobookshelf.item.components.EpisodeList +import com.makd.afinity.ui.audiobookshelf.item.components.ItemHeader + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudiobookshelfItemScreen( + onNavigateBack: () -> Unit, + onNavigateToPlayer: (String, String?) -> Unit, + viewModel: AudiobookshelfItemViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val item by viewModel.item.collectAsStateWithLifecycle() + val progress by viewModel.progress.collectAsStateWithLifecycle() + val config by viewModel.currentConfig.collectAsStateWithLifecycle() + + val isPodcast = item?.mediaType?.lowercase() == "podcast" + + Scaffold( + topBar = { + TopAppBar( + title = { Text(item?.media?.metadata?.title ?: "Item") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when { + uiState.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + + item != null -> { + androidx.compose.foundation.layout.Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + ItemHeader( + item = item!!, + progress = progress, + serverUrl = config?.serverUrl, + onPlay = { + onNavigateToPlayer(viewModel.itemId, null) + } + ) + + if (isPodcast && uiState.episodes.isNotEmpty()) { + EpisodeList( + episodes = uiState.episodes, + onEpisodeClick = { episode -> + // Could show episode details + }, + onEpisodePlay = { episode -> + onNavigateToPlayer(viewModel.itemId, episode.id) + }, + modifier = Modifier.padding(top = 16.dp) + ) + } else if (!isPodcast && uiState.chapters.isNotEmpty()) { + ChapterList( + chapters = uiState.chapters, + currentPosition = progress?.currentTime, + onChapterClick = { chapter -> + // Could seek to chapter + onNavigateToPlayer(viewModel.itemId, null) + }, + modifier = Modifier.padding(top = 16.dp) + ) + } + + Spacer( + modifier = Modifier.padding(bottom = 32.dp) + ) + } + } + + uiState.error != null -> { + Text( + text = "Failed to load item", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyLarge + ) + } + } + + AnimatedVisibility( + visible = uiState.error != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = uiState.error ?: "", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemViewModel.kt new file mode 100644 index 00000000..824af570 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemViewModel.kt @@ -0,0 +1,86 @@ +package com.makd.afinity.ui.audiobookshelf.item + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.audiobookshelf.BookChapter +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.data.models.audiobookshelf.MediaProgress +import com.makd.afinity.data.models.audiobookshelf.PodcastEpisode +import com.makd.afinity.data.repository.AudiobookshelfRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AudiobookshelfItemViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val audiobookshelfRepository: AudiobookshelfRepository +) : ViewModel() { + + val itemId: String = savedStateHandle.get("itemId") ?: "" + + private val _uiState = MutableStateFlow(AudiobookshelfItemUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _item = MutableStateFlow(null) + val item: StateFlow = _item.asStateFlow() + + val progress: StateFlow = + audiobookshelfRepository.getProgressForItemFlow(itemId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val currentConfig = audiobookshelfRepository.currentConfig + + init { + loadItem() + } + + private fun loadItem() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + val result = audiobookshelfRepository.getItemDetails(itemId) + + result.fold( + onSuccess = { item -> + _item.value = item + _uiState.value = _uiState.value.copy( + isLoading = false, + chapters = item.media.chapters ?: emptyList(), + episodes = item.media.episodes ?: emptyList() + ) + Timber.d("Loaded item: ${item.media.metadata.title}") + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = error.message + ) + Timber.e(error, "Failed to load item") + } + ) + } + } + + fun refresh() { + loadItem() + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } +} + +data class AudiobookshelfItemUiState( + val isLoading: Boolean = false, + val chapters: List = emptyList(), + val episodes: List = emptyList(), + val error: String? = null +) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt new file mode 100644 index 00000000..b9f7c71c --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt @@ -0,0 +1,134 @@ +package com.makd.afinity.ui.audiobookshelf.item.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.makd.afinity.data.models.audiobookshelf.BookChapter + +@Composable +fun ChapterList( + chapters: List, + currentPosition: Double?, + onChapterClick: (BookChapter) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Chapters (${chapters.size})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + chapters.forEachIndexed { index, chapter -> + val isCurrentChapter = currentPosition?.let { pos -> + pos >= chapter.start && pos < chapter.end + } ?: false + + val isCompleted = currentPosition?.let { pos -> + pos >= chapter.end + } ?: false + + ChapterItem( + chapter = chapter, + index = index + 1, + isCurrentChapter = isCurrentChapter, + isCompleted = isCompleted, + onClick = { onChapterClick(chapter) } + ) + + if (index < chapters.size - 1) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + } + } +} + +@Composable +private fun ChapterItem( + chapter: BookChapter, + index: Int, + isCurrentChapter: Boolean, + isCompleted: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when { + isCurrentChapter -> { + Icon( + imageVector = Icons.Filled.PlayCircle, + contentDescription = "Currently playing", + tint = MaterialTheme.colorScheme.primary + ) + } + + isCompleted -> { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = "Completed", + tint = MaterialTheme.colorScheme.outline + ) + } + + else -> { + Text( + text = index.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = chapter.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = if (isCurrentChapter) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = formatChapterDuration(chapter.end - chapter.start), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +private fun formatChapterDuration(seconds: Double): String { + val totalSeconds = seconds.toLong() + val minutes = totalSeconds / 60 + val secs = totalSeconds % 60 + return "${minutes}:${secs.toString().padStart(2, '0')}" +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt new file mode 100644 index 00000000..4a56dd26 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt @@ -0,0 +1,138 @@ +package com.makd.afinity.ui.audiobookshelf.item.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.makd.afinity.data.models.audiobookshelf.PodcastEpisode +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun EpisodeList( + episodes: List, + onEpisodeClick: (PodcastEpisode) -> Unit, + onEpisodePlay: (PodcastEpisode) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Episodes (${episodes.size})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + episodes.forEachIndexed { index, episode -> + EpisodeItem( + episode = episode, + onClick = { onEpisodeClick(episode) }, + onPlay = { onEpisodePlay(episode) } + ) + + if (index < episodes.size - 1) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + } + } +} + +@Composable +private fun EpisodeItem( + episode: PodcastEpisode, + onClick: () -> Unit, + onPlay: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = episode.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row { + episode.publishedAt?.let { timestamp -> + Text( + text = formatDate(timestamp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + episode.duration?.let { duration -> + Text( + text = " \u2022 ${formatDuration(duration)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + episode.description?.let { description -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton(onClick = onPlay) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play episode", + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + +private fun formatDate(timestamp: Long): String { + val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + return dateFormat.format(Date(timestamp)) +} + +private fun formatDuration(seconds: Double): String { + val totalSeconds = seconds.toLong() + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + + return if (hours > 0) { + "${hours}h ${minutes}m" + } else { + "${minutes}m" + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt new file mode 100644 index 00000000..9ffd7fdf --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt @@ -0,0 +1,208 @@ +package com.makd.afinity.ui.audiobookshelf.item.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.data.models.audiobookshelf.MediaProgress +import kotlin.time.Duration.Companion.seconds + +@Composable +fun ItemHeader( + item: LibraryItem, + progress: MediaProgress?, + serverUrl: String?, + onPlay: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + val coverUrl = if (serverUrl != null && item.media.coverPath != null) { + "$serverUrl/api/items/${item.id}/cover" + } else null + + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = "Cover", + modifier = Modifier + .size(140.dp) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop + ) + } else { + Card( + modifier = Modifier.size(140.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + ) {} + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = item.media.metadata.title ?: "Unknown Title", + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + item.media.metadata.authorName?.let { author -> + Text( + text = "by $author", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + item.media.metadata.narratorName?.let { narrator -> + Text( + text = "Narrated by $narrator", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + item.media.metadata.seriesName?.let { series -> + Text( + text = series, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + item.media.duration?.let { duration -> + Text( + text = formatDuration(duration), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + progress?.let { prog -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + LinearProgressIndicator( + progress = { prog.progress.toFloat() }, + modifier = Modifier.fillMaxWidth(), + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatDuration(prog.currentTime), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${(prog.progress * 100).toInt()}% complete", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatDuration(prog.duration), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onPlay, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (progress != null && progress.progress > 0) "Continue" else "Play" + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + item.media.metadata.description?.let { description -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = "Description", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +private fun formatDuration(seconds: Double): String { + val totalSeconds = seconds.toLong() + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val secs = totalSeconds % 60 + + return if (hours > 0) { + "${hours}h ${minutes}m" + } else { + "${minutes}m ${secs}s" + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt new file mode 100644 index 00000000..e410685f --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt @@ -0,0 +1,232 @@ +package com.makd.afinity.ui.audiobookshelf.libraries + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.data.repository.ItemWithProgress +import com.makd.afinity.ui.audiobookshelf.libraries.components.LibraryCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudiobookshelfLibrariesScreen( + onNavigateBack: () -> Unit, + onNavigateToLibrary: (String) -> Unit, + onNavigateToItem: (String) -> Unit, + onNavigateToLogin: () -> Unit, + viewModel: AudiobookshelfLibrariesViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val libraries by viewModel.libraries.collectAsStateWithLifecycle() + val inProgressItems by viewModel.inProgressItems.collectAsStateWithLifecycle() + val isAuthenticated by viewModel.isAuthenticated.collectAsStateWithLifecycle() + + if (!isAuthenticated) { + onNavigateToLogin() + return + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Audiobookshelf") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refreshLibraries, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (libraries.isEmpty() && !uiState.isRefreshing) { + EmptyState() + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (inProgressItems.isNotEmpty()) { + item { + Text( + text = "Continue Listening", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + item { + ContinueListeningRow( + items = inProgressItems, + onItemClick = { onNavigateToItem(it.item.id) } + ) + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + + item { + Text( + text = "Libraries", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + items(libraries, key = { it.id }) { library -> + LibraryCard( + library = library, + onClick = { onNavigateToLibrary(library.id) } + ) + } + } + } + + AnimatedVisibility( + visible = uiState.error != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = uiState.error ?: "", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No libraries found", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Pull to refresh", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ContinueListeningRow( + items: List, + onItemClick: (ItemWithProgress) -> Unit +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(items, key = { it.item.id }) { itemWithProgress -> + ContinueListeningCard( + item = itemWithProgress, + onClick = { onItemClick(itemWithProgress) } + ) + } + } +} + +@Composable +private fun ContinueListeningCard( + item: ItemWithProgress, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier + .width(160.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Text( + text = item.item.media.metadata.title ?: "Unknown", + style = MaterialTheme.typography.bodyMedium, + maxLines = 2 + ) + + item.item.media.metadata.authorName?.let { author -> + Text( + text = author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + + item.progress?.let { progress -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${(progress.progress * 100).toInt()}% complete", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt new file mode 100644 index 00000000..2403bd1e --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt @@ -0,0 +1,73 @@ +package com.makd.afinity.ui.audiobookshelf.libraries + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.audiobookshelf.Library +import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.ItemWithProgress +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AudiobookshelfLibrariesViewModel @Inject constructor( + private val audiobookshelfRepository: AudiobookshelfRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AudiobookshelfLibrariesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val libraries: StateFlow> = audiobookshelfRepository.getLibrariesFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val inProgressItems: StateFlow> = + audiobookshelfRepository.getInProgressItemsFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val isAuthenticated = audiobookshelfRepository.isAuthenticated + + init { + refreshLibraries() + } + + fun refreshLibraries() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true, error = null) + + val result = audiobookshelfRepository.refreshLibraries() + + result.fold( + onSuccess = { libraries -> + _uiState.value = _uiState.value.copy( + isRefreshing = false + ) + Timber.d("Refreshed ${libraries.size} libraries") + + audiobookshelfRepository.refreshProgress() + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = error.message + ) + Timber.e(error, "Failed to refresh libraries") + } + ) + } + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } +} + +data class AudiobookshelfLibrariesUiState( + val isRefreshing: Boolean = false, + val error: String? = null +) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt new file mode 100644 index 00000000..8d5427fd --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt @@ -0,0 +1,93 @@ +package com.makd.afinity.ui.audiobookshelf.libraries.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Podcasts +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.makd.afinity.data.models.audiobookshelf.Library + +@Composable +fun LibraryCard( + library: Library, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = when (library.mediaType.lowercase()) { + "podcast" -> Icons.Filled.Podcasts + else -> Icons.AutoMirrored.Filled.MenuBook + }, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = library.name, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = when (library.mediaType.lowercase()) { + "podcast" -> "Podcasts" + else -> "Audiobooks" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + library.stats?.let { stats -> + stats.totalItems?.let { count -> + Text( + text = "$count items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = "Open library", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt new file mode 100644 index 00000000..a710bc58 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt @@ -0,0 +1,197 @@ +package com.makd.afinity.ui.audiobookshelf.library + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.ui.audiobookshelf.library.components.AudiobookCard +import com.makd.afinity.ui.audiobookshelf.library.components.PodcastCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudiobookshelfLibraryScreen( + onNavigateBack: () -> Unit, + onNavigateToItem: (String) -> Unit, + viewModel: AudiobookshelfLibraryViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val library by viewModel.library.collectAsStateWithLifecycle() + val items by viewModel.items.collectAsStateWithLifecycle() + val config by viewModel.currentConfig.collectAsStateWithLifecycle() + + val displayItems = uiState.searchResults ?: items + val isPodcastLibrary = library?.mediaType?.lowercase() == "podcast" + + Scaffold( + topBar = { + TopAppBar( + title = { Text(library?.name ?: "Library") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = viewModel::search, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Search...") }, + leadingIcon = { + Icon(Icons.Filled.Search, contentDescription = null) + }, + trailingIcon = { + if (uiState.searchQuery.isNotEmpty()) { + IconButton(onClick = viewModel::clearSearch) { + Icon(Icons.Filled.Clear, contentDescription = "Clear search") + } + } else if (uiState.isSearching) { + CircularProgressIndicator( + modifier = Modifier.padding(8.dp), + strokeWidth = 2.dp + ) + } + }, + singleLine = true + ) + + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refreshItems, + modifier = Modifier.fillMaxSize() + ) { + if (displayItems.isEmpty() && !uiState.isRefreshing) { + EmptyState( + isSearching = uiState.searchQuery.isNotEmpty() + ) + } else { + if (isPodcastLibrary) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(displayItems, key = { it.id }) { item -> + PodcastCard( + item = item, + serverUrl = config?.serverUrl, + onClick = { onNavigateToItem(item.id) } + ) + } + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(displayItems, key = { it.id }) { item -> + AudiobookCard( + item = item, + serverUrl = config?.serverUrl, + onClick = { onNavigateToItem(item.id) } + ) + } + } + } + } + + androidx.compose.animation.AnimatedVisibility( + visible = uiState.error != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = uiState.error ?: "", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + } +} + +@Composable +private fun EmptyState(isSearching: Boolean) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (isSearching) "No results found" else "No items in library", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (isSearching) "Try a different search term" else "Pull to refresh", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryViewModel.kt new file mode 100644 index 00000000..85658cfe --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryViewModel.kt @@ -0,0 +1,137 @@ +package com.makd.afinity.ui.audiobookshelf.library + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.audiobookshelf.Library +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.data.repository.AudiobookshelfConfig +import com.makd.afinity.data.repository.AudiobookshelfRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AudiobookshelfLibraryViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val audiobookshelfRepository: AudiobookshelfRepository +) : ViewModel() { + + private val libraryId: String = savedStateHandle.get("libraryId") ?: "" + + private val _uiState = MutableStateFlow(AudiobookshelfLibraryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _library = MutableStateFlow(null) + val library: StateFlow = _library.asStateFlow() + + val items: StateFlow> = + audiobookshelfRepository.getLibraryItemsFlow(libraryId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val currentConfig: StateFlow = audiobookshelfRepository.currentConfig + + init { + loadLibrary() + refreshItems() + } + + private fun loadLibrary() { + viewModelScope.launch { + val result = audiobookshelfRepository.getLibrary(libraryId) + result.fold( + onSuccess = { library -> + _library.value = library + }, + onFailure = { error -> + Timber.e(error, "Failed to load library") + } + ) + } + } + + fun refreshItems() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true, error = null) + + val result = audiobookshelfRepository.refreshLibraryItems(libraryId) + + result.fold( + onSuccess = { items -> + _uiState.value = _uiState.value.copy( + isRefreshing = false, + totalItems = items.size + ) + Timber.d("Refreshed ${items.size} items for library $libraryId") + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = error.message + ) + Timber.e(error, "Failed to refresh items") + } + ) + } + } + + fun search(query: String) { + if (query.isBlank()) { + _uiState.value = _uiState.value.copy(searchQuery = "", searchResults = null) + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + searchQuery = query, + isSearching = true + ) + + val result = audiobookshelfRepository.searchLibrary(libraryId, query) + + result.fold( + onSuccess = { searchResponse -> + val searchItems = (searchResponse.book?.map { it.libraryItem } ?: emptyList()) + + (searchResponse.podcast?.map { it.libraryItem } ?: emptyList()) + + _uiState.value = _uiState.value.copy( + isSearching = false, + searchResults = searchItems + ) + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isSearching = false, + error = error.message + ) + } + ) + } + } + + fun clearSearch() { + _uiState.value = _uiState.value.copy( + searchQuery = "", + searchResults = null + ) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } +} + +data class AudiobookshelfLibraryUiState( + val isRefreshing: Boolean = false, + val isSearching: Boolean = false, + val searchQuery: String = "", + val searchResults: List? = null, + val totalItems: Int = 0, + val error: String? = null +) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt new file mode 100644 index 00000000..12ce1f14 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt @@ -0,0 +1,96 @@ +package com.makd.afinity.ui.audiobookshelf.library.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.makd.afinity.data.models.audiobookshelf.LibraryItem + +@Composable +fun AudiobookCard( + item: LibraryItem, + serverUrl: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column { + val coverUrl = if (serverUrl != null && item.media.coverPath != null) { + "$serverUrl/api/items/${item.id}/cover" + } else null + + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = "Cover for ${item.media.metadata.title}", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop + ) + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + ) {} + } + + Column( + modifier = Modifier.padding(8.dp) + ) { + Text( + text = item.media.metadata.title ?: "Unknown Title", + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + item.media.metadata.authorName?.let { author -> + Text( + text = author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + item.userMediaProgress?.let { progress -> + if (progress.progress > 0 && !progress.isFinished) { + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { progress.progress.toFloat() }, + modifier = Modifier.fillMaxWidth(), + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt new file mode 100644 index 00000000..f894196a --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt @@ -0,0 +1,107 @@ +package com.makd.afinity.ui.audiobookshelf.library.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.makd.afinity.data.models.audiobookshelf.LibraryItem + +@Composable +fun PodcastCard( + item: LibraryItem, + serverUrl: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val coverUrl = if (serverUrl != null && item.media.coverPath != null) { + "$serverUrl/api/items/${item.id}/cover" + } else null + + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = "Cover for ${item.media.metadata.title}", + modifier = Modifier + .size(64.dp) + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Crop + ) + } else { + Card( + modifier = Modifier.size(64.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + ) {} + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = item.media.metadata.title ?: "Unknown Podcast", + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + item.media.metadata.authorName?.let { author -> + Text( + text = author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + item.media.episodes?.size?.let { count -> + Text( + text = "$count episodes", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = "Open podcast", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt new file mode 100644 index 00000000..9f9aba8a --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt @@ -0,0 +1,327 @@ +package com.makd.afinity.ui.audiobookshelf.login + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.data.repository.AudiobookshelfConfig + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudiobookshelfLoginScreen( + onNavigateBack: () -> Unit, + onLoginSuccess: () -> Unit, + viewModel: AudiobookshelfLoginViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isAuthenticated by viewModel.isAuthenticated.collectAsStateWithLifecycle() + val currentConfig by viewModel.currentConfig.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.isLoggedIn) { + if (uiState.isLoggedIn) { + onLoginSuccess() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Audiobookshelf") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + if (isAuthenticated && currentConfig != null) { + ConnectedCard( + config = currentConfig!!, + onLogout = viewModel::logout, + isLoggingOut = uiState.isLoggingIn + ) + } else { + LoginForm( + uiState = uiState, + onServerUrlChange = viewModel::updateServerUrl, + onUsernameChange = viewModel::updateUsername, + onPasswordChange = viewModel::updatePassword, + onTestConnection = viewModel::testConnection, + onLogin = viewModel::login + ) + } + + AnimatedVisibility( + visible = uiState.error != null, + enter = fadeIn(), + exit = fadeOut() + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = uiState.error ?: "", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun ConnectedCard( + config: AudiobookshelfConfig, + onLogout: () -> Unit, + isLoggingOut: Boolean +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Connected", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Server: ${config.serverUrl}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + text = "User: ${config.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = onLogout, + enabled = !isLoggingOut, + modifier = Modifier.fillMaxWidth() + ) { + if (isLoggingOut) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Disconnect") + } + } + } +} + +@Composable +private fun LoginForm( + uiState: AudiobookshelfLoginUiState, + onServerUrlChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onTestConnection: () -> Unit, + onLogin: () -> Unit +) { + val focusManager = LocalFocusManager.current + var passwordVisible by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Connect to Audiobookshelf", + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "Enter your Audiobookshelf server details to access your audiobooks and podcasts.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(4.dp)) + + OutlinedTextField( + value = uiState.serverUrl, + onValueChange = onServerUrlChange, + label = { Text("Server URL") }, + placeholder = { Text("http://192.168.1.100:13378") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ), + trailingIcon = { + if (uiState.connectionTestSuccess) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Connected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) + + OutlinedTextField( + value = uiState.username, + onValueChange = onUsernameChange, + label = { Text("Username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ) + ) + + OutlinedTextField( + value = uiState.password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + onLogin() + } + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = onLogin, + enabled = !uiState.isLoggingIn && uiState.serverUrl.isNotBlank() && uiState.username.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoggingIn) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Connect") + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginViewModel.kt new file mode 100644 index 00000000..58b512d1 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginViewModel.kt @@ -0,0 +1,171 @@ +package com.makd.afinity.ui.audiobookshelf.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.repository.AudiobookshelfRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AudiobookshelfLoginViewModel @Inject constructor( + private val audiobookshelfRepository: AudiobookshelfRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AudiobookshelfLoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val isAuthenticated = audiobookshelfRepository.isAuthenticated + val currentConfig = audiobookshelfRepository.currentConfig + + fun updateServerUrl(url: String) { + _uiState.value = _uiState.value.copy( + serverUrl = url.trim(), + error = null + ) + } + + fun updateUsername(username: String) { + _uiState.value = _uiState.value.copy( + username = username, + error = null + ) + } + + fun updatePassword(password: String) { + _uiState.value = _uiState.value.copy( + password = password, + error = null + ) + } + + fun testConnection() { + val serverUrl = _uiState.value.serverUrl + if (serverUrl.isBlank()) { + _uiState.value = _uiState.value.copy(error = "Server URL is required") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isTestingConnection = true, + error = null, + connectionTestSuccess = false + ) + + try { + audiobookshelfRepository.setServerUrl(normalizeUrl(serverUrl)) + _uiState.value = _uiState.value.copy( + isTestingConnection = false, + connectionTestSuccess = true + ) + Timber.d("Server URL set: $serverUrl") + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isTestingConnection = false, + connectionTestSuccess = false, + error = "Failed to set server URL: ${e.message}" + ) + Timber.e(e, "Failed to test connection") + } + } + } + + fun login() { + val currentState = _uiState.value + + if (currentState.serverUrl.isBlank()) { + _uiState.value = currentState.copy(error = "Server URL is required") + return + } + if (currentState.username.isBlank()) { + _uiState.value = currentState.copy(error = "Username is required") + return + } + + viewModelScope.launch { + _uiState.value = currentState.copy( + isLoggingIn = true, + error = null + ) + + val result = audiobookshelfRepository.login( + serverUrl = normalizeUrl(currentState.serverUrl), + username = currentState.username, + password = currentState.password + ) + + result.fold( + onSuccess = { user -> + _uiState.value = _uiState.value.copy( + isLoggingIn = false, + isLoggedIn = true + ) + Timber.d("Audiobookshelf login successful for user: ${user.username}") + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isLoggingIn = false, + error = "Login failed: ${error.message}" + ) + Timber.e(error, "Audiobookshelf login failed") + } + ) + } + } + + fun logout() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoggingIn = true) + + val result = audiobookshelfRepository.logout() + + result.fold( + onSuccess = { + _uiState.value = AudiobookshelfLoginUiState() + Timber.d("Audiobookshelf logout successful") + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isLoggingIn = false, + error = "Logout failed: ${error.message}" + ) + Timber.e(error, "Audiobookshelf logout failed") + } + ) + } + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + private fun normalizeUrl(url: String): String { + var normalized = url.trim() + + if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) { + normalized = "http://$normalized" + } + + if (normalized.endsWith("/")) { + normalized = normalized.dropLast(1) + } + + return normalized + } +} + +data class AudiobookshelfLoginUiState( + val serverUrl: String = "", + val username: String = "", + val password: String = "", + val isTestingConnection: Boolean = false, + val connectionTestSuccess: Boolean = false, + val isLoggingIn: Boolean = false, + val isLoggedIn: Boolean = false, + val error: String? = null +) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt new file mode 100644 index 00000000..ae4fb33b --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt @@ -0,0 +1,263 @@ +package com.makd.afinity.ui.audiobookshelf.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import com.makd.afinity.ui.audiobookshelf.player.components.ChapterSelector +import com.makd.afinity.ui.audiobookshelf.player.components.PlaybackSpeedSelector +import com.makd.afinity.ui.audiobookshelf.player.components.PlayerControls +import com.makd.afinity.ui.audiobookshelf.player.components.SleepTimerDialog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudiobookshelfPlayerScreen( + onNavigateBack: () -> Unit, + viewModel: AudiobookshelfPlayerViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val playbackState by viewModel.playbackState.collectAsState() + + + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar(error) + viewModel.clearError() + } + } + + val coverUrl = playbackState.coverUrl + + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + topBar = { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Filled.ExpandMore, + contentDescription = "Minimize player" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + }, + containerColor = MaterialTheme.colorScheme.surface + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth(0.7f) + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = playbackState.displayTitle, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = playbackState.displayTitle.ifEmpty { "Unknown Title" }, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + if (playbackState.displayAuthor != null) { + Text( + text = playbackState.displayAuthor!!, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (playbackState.currentChapter != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = playbackState.currentChapter!!.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + PlayerControls( + currentTime = playbackState.currentTime, + duration = playbackState.duration, + isPlaying = playbackState.isPlaying, + isBuffering = playbackState.isBuffering, + onPlayPauseClick = viewModel::togglePlayPause, + onSkipForward = viewModel::skipForward, + onSkipBackward = viewModel::skipBackward, + onSeek = viewModel::seekTo, + onPreviousChapter = if (playbackState.chapters.isNotEmpty() && + playbackState.currentChapterIndex > 0 + ) { + { viewModel.seekToChapter(playbackState.currentChapterIndex - 1) } + } else null, + onNextChapter = if (playbackState.chapters.isNotEmpty() && + playbackState.currentChapterIndex < playbackState.chapters.lastIndex + ) { + { viewModel.seekToChapter(playbackState.currentChapterIndex + 1) } + } else null + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + if (playbackState.chapters.isNotEmpty()) { + IconButton(onClick = viewModel::showChapterSelector) { + Icon( + imageVector = Icons.Filled.List, + contentDescription = "Chapters" + ) + } + } + + TextButton(onClick = viewModel::showSpeedSelector) { + Icon( + imageVector = Icons.Filled.Speed, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = "${playbackState.playbackSpeed}x") + } + + IconButton(onClick = viewModel::showSleepTimerDialog) { + Icon( + imageVector = Icons.Filled.Bedtime, + contentDescription = "Sleep timer", + tint = if (playbackState.sleepTimerEndTime != null) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + + if (uiState.showChapterSelector) { + ChapterSelector( + chapters = playbackState.chapters, + currentChapterIndex = playbackState.currentChapterIndex, + onChapterSelected = viewModel::seekToChapter, + onDismiss = viewModel::dismissChapterSelector + ) + } + + if (uiState.showSpeedSelector) { + PlaybackSpeedSelector( + currentSpeed = playbackState.playbackSpeed, + onSpeedSelected = viewModel::setPlaybackSpeed, + onDismiss = viewModel::dismissSpeedSelector + ) + } + + if (uiState.showSleepTimerDialog) { + SleepTimerDialog( + currentTimerEndTime = playbackState.sleepTimerEndTime, + onTimerSelected = viewModel::setSleepTimer, + onCancelTimer = viewModel::cancelSleepTimer, + onDismiss = viewModel::dismissSleepTimerDialog + ) + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt new file mode 100644 index 00000000..47203c3f --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt @@ -0,0 +1,161 @@ +package com.makd.afinity.ui.audiobookshelf.player + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.player.audiobookshelf.AudiobookshelfPlaybackManager +import com.makd.afinity.player.audiobookshelf.AudiobookshelfPlayer +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AudiobookshelfPlayerViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val audiobookshelfRepository: AudiobookshelfRepository, + private val audiobookshelfPlayer: AudiobookshelfPlayer, + val playbackManager: AudiobookshelfPlaybackManager +) : ViewModel() { + + private val itemId: String = savedStateHandle.get("itemId") ?: "" + private val episodeId: String? = savedStateHandle.get("episodeId") + + private val _uiState = MutableStateFlow(AudiobookshelfPlayerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val playbackState = playbackManager.playbackState + val currentConfig = audiobookshelfRepository.currentConfig + + init { + startPlayback() + } + + private fun startPlayback() { + val currentState = playbackManager.playbackState.value + if (currentState.sessionId != null && currentState.itemId == itemId) { + Timber.d("Resuming existing playback session for item: $itemId") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + val result = audiobookshelfRepository.startPlaybackSession(itemId, episodeId) + + result.fold( + onSuccess = { session -> + val serverUrl = currentConfig.value?.serverUrl + if (serverUrl != null) { + audiobookshelfPlayer.loadSession(session, serverUrl) + audiobookshelfPlayer.play() + _uiState.value = _uiState.value.copy(isLoading = false) + Timber.d("Started playback session: ${session.id}") + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Server URL not available" + ) + } + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = error.message + ) + Timber.e(error, "Failed to start playback session") + } + ) + } + } + + fun togglePlayPause() { + if (audiobookshelfPlayer.isPlaying()) { + audiobookshelfPlayer.pause() + } else { + audiobookshelfPlayer.play() + } + } + + fun seekTo(positionSeconds: Double) { + audiobookshelfPlayer.seekToPosition(positionSeconds) + } + + fun skipForward() { + audiobookshelfPlayer.skipForward(30) + } + + fun skipBackward() { + audiobookshelfPlayer.skipBackward(30) + } + + fun seekToChapter(chapterIndex: Int) { + audiobookshelfPlayer.seekToChapter(chapterIndex) + dismissChapterSelector() + } + + fun setPlaybackSpeed(speed: Float) { + audiobookshelfPlayer.setPlaybackSpeed(speed) + dismissSpeedSelector() + } + + fun setSleepTimer(minutes: Int) { + audiobookshelfPlayer.setSleepTimer(minutes) + dismissSleepTimerDialog() + } + + fun cancelSleepTimer() { + audiobookshelfPlayer.cancelSleepTimer() + } + + fun showChapterSelector() { + _uiState.value = _uiState.value.copy(showChapterSelector = true) + } + + fun dismissChapterSelector() { + _uiState.value = _uiState.value.copy(showChapterSelector = false) + } + + fun showSpeedSelector() { + _uiState.value = _uiState.value.copy(showSpeedSelector = true) + } + + fun dismissSpeedSelector() { + _uiState.value = _uiState.value.copy(showSpeedSelector = false) + } + + fun showSleepTimerDialog() { + _uiState.value = _uiState.value.copy(showSleepTimerDialog = true) + } + + fun dismissSleepTimerDialog() { + _uiState.value = _uiState.value.copy(showSleepTimerDialog = false) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + override fun onCleared() { + super.onCleared() + } + + fun stopPlayback() { + viewModelScope.launch { + audiobookshelfPlayer.pause() + audiobookshelfPlayer.closeSession() + } + } +} + +data class AudiobookshelfPlayerUiState( + val isLoading: Boolean = false, + val showChapterSelector: Boolean = false, + val showSpeedSelector: Boolean = false, + val showSleepTimerDialog: Boolean = false, + val error: String? = null +) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt new file mode 100644 index 00000000..120d4738 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt @@ -0,0 +1,169 @@ +package com.makd.afinity.ui.audiobookshelf.player.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.makd.afinity.data.models.audiobookshelf.BookChapter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChapterSelector( + chapters: List, + currentChapterIndex: Int, + onChapterSelected: (Int) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val listState = rememberLazyListState() + + LaunchedEffect(currentChapterIndex) { + if (currentChapterIndex >= 0 && currentChapterIndex < chapters.size) { + listState.animateScrollToItem(currentChapterIndex) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + Text( + text = "Chapters", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + state = listState, + modifier = Modifier.weight(1f, fill = false) + ) { + itemsIndexed(chapters) { index, chapter -> + ChapterItem( + chapter = chapter, + index = index, + isCurrentChapter = index == currentChapterIndex, + onClick = { onChapterSelected(index) } + ) + + if (index < chapters.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } + } + } + } +} + +@Composable +private fun ChapterItem( + chapter: BookChapter, + index: Int, + isCurrentChapter: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isCurrentChapter) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Currently playing", + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = chapter.title, + style = MaterialTheme.typography.bodyLarge, + color = if (isCurrentChapter) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = formatChapterDuration(chapter.start, chapter.end), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private fun formatChapterDuration(start: Double, end: Double): String { + val startFormatted = formatTime(start) + val duration = end - start + val durationFormatted = formatDuration(duration) + return "$startFormatted • $durationFormatted" +} + +private fun formatTime(seconds: Double): String { + val totalSeconds = seconds.toLong().coerceAtLeast(0) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val secs = totalSeconds % 60 + + return if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, secs) + } else { + String.format("%d:%02d", minutes, secs) + } +} + +private fun formatDuration(seconds: Double): String { + val totalSeconds = seconds.toLong().coerceAtLeast(0) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + + return when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m" + else -> "${totalSeconds}s" + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt new file mode 100644 index 00000000..6d8c2e74 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt @@ -0,0 +1,140 @@ +package com.makd.afinity.ui.audiobookshelf.player.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +@Composable +fun MiniPlayer( + title: String, + author: String?, + coverUrl: String?, + currentTime: Double, + duration: Double, + isPlaying: Boolean, + isBuffering: Boolean, + onPlayPauseClick: () -> Unit, + onCloseClick: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val progress = if (duration > 0) (currentTime / duration).toFloat().coerceIn(0f, 1f) else 0f + + Surface( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(2.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (author != null) { + Text( + text = author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton(onClick = onPlayPauseClick) { + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + modifier = Modifier.size(28.dp) + ) + } + } + + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close player", + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt new file mode 100644 index 00000000..d470f76d --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt @@ -0,0 +1,112 @@ +package com.makd.afinity.ui.audiobookshelf.player.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun PlaybackSpeedSelector( + currentSpeed: Float, + onSpeedSelected: (Float) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val sheetState = rememberModalBottomSheetState() + + val speeds = listOf( + 0.5f, 0.75f, 1.0f, 1.1f, 1.25f, 1.5f, 1.75f, 2.0f, 2.5f, 3.0f + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = "Playback Speed", + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(16.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + speeds.forEach { speed -> + SpeedChip( + speed = speed, + isSelected = speed == currentSpeed, + onClick = { onSpeedSelected(speed) } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Current: ${formatSpeed(currentSpeed)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SpeedChip( + speed: Float, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + FilterChip( + selected = isSelected, + onClick = onClick, + label = { + Text( + text = formatSpeed(speed), + style = MaterialTheme.typography.labelLarge + ) + }, + modifier = modifier, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) +} + +private fun formatSpeed(speed: Float): String { + return if (speed == speed.toLong().toFloat()) { + "${speed.toLong()}x" + } else { + "${speed}x" + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt new file mode 100644 index 00000000..f185de2e --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt @@ -0,0 +1,165 @@ +package com.makd.afinity.ui.audiobookshelf.player.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forward30 +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay30 +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PlayerControls( + currentTime: Double, + duration: Double, + isPlaying: Boolean, + isBuffering: Boolean, + onPlayPauseClick: () -> Unit, + onSkipForward: () -> Unit, + onSkipBackward: () -> Unit, + onSeek: (Double) -> Unit, + onPreviousChapter: (() -> Unit)? = null, + onNextChapter: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + var sliderPosition by remember(currentTime) { mutableFloatStateOf(currentTime.toFloat()) } + var isDragging by remember { mutableFloatStateOf(0f) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Slider( + value = if (isDragging > 0) sliderPosition else currentTime.toFloat(), + onValueChange = { value -> + sliderPosition = value + isDragging = 1f + }, + onValueChangeFinished = { + onSeek(sliderPosition.toDouble()) + isDragging = 0f + }, + valueRange = 0f..duration.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatTime(currentTime), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "-${formatTime(duration - currentTime)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { onPreviousChapter?.invoke() }, + enabled = onPreviousChapter != null + ) { + Icon( + imageVector = Icons.Filled.SkipPrevious, + contentDescription = "Previous chapter", + modifier = Modifier.size(32.dp) + ) + } + + IconButton(onClick = onSkipBackward) { + Icon( + imageVector = Icons.Filled.Replay30, + contentDescription = "Skip backward 30 seconds", + modifier = Modifier.size(40.dp) + ) + } + + FilledIconButton( + onClick = onPlayPauseClick, + modifier = Modifier.size(72.dp) + ) { + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 3.dp + ) + } else { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + modifier = Modifier.size(40.dp) + ) + } + } + + IconButton(onClick = onSkipForward) { + Icon( + imageVector = Icons.Filled.Forward30, + contentDescription = "Skip forward 30 seconds", + modifier = Modifier.size(40.dp) + ) + } + + IconButton( + onClick = { onNextChapter?.invoke() }, + enabled = onNextChapter != null + ) { + Icon( + imageVector = Icons.Filled.SkipNext, + contentDescription = "Next chapter", + modifier = Modifier.size(32.dp) + ) + } + } + } +} + +private fun formatTime(seconds: Double): String { + val totalSeconds = seconds.toLong().coerceAtLeast(0) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val secs = totalSeconds % 60 + + return if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, secs) + } else { + String.format("%d:%02d", minutes, secs) + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt new file mode 100644 index 00000000..5293b51c --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt @@ -0,0 +1,164 @@ +package com.makd.afinity.ui.audiobookshelf.player.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material.icons.filled.TimerOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SleepTimerDialog( + currentTimerEndTime: Long?, + onTimerSelected: (Int) -> Unit, + onCancelTimer: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val sheetState = rememberModalBottomSheetState() + + val timerOptions = listOf( + 5 to "5 minutes", + 10 to "10 minutes", + 15 to "15 minutes", + 30 to "30 minutes", + 45 to "45 minutes", + 60 to "1 hour", + 90 to "1 hour 30 minutes", + 120 to "2 hours" + ) + + val isTimerActive = + currentTimerEndTime != null && currentTimerEndTime > System.currentTimeMillis() + val remainingMinutes = if (isTimerActive && currentTimerEndTime != null) { + ((currentTimerEndTime - System.currentTimeMillis()) / 60000).toInt().coerceAtLeast(1) + } else { + null + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + Text( + text = "Sleep Timer", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + if (isTimerActive && remainingMinutes != null) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Timer active: $remainingMinutes min remaining", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onCancelTimer) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.TimerOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Cancel timer", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + Text( + text = "Or set a new timer:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + timerOptions.forEach { (minutes, label) -> + TimerOptionItem( + label = label, + onClick = { onTimerSelected(minutes) } + ) + } + } + } +} + +@Composable +private fun TimerOptionItem( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge + ) + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt index 3f027037..e105bdd7 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt @@ -47,6 +47,7 @@ import com.makd.afinity.data.repository.download.JellyfinDownloadRepository import com.makd.afinity.data.repository.media.MediaRepository import com.makd.afinity.data.repository.playback.PlaybackRepository import com.makd.afinity.data.repository.segments.SegmentsRepository +import com.makd.afinity.player.audiobookshelf.AudiobookshelfPlayer import com.makd.afinity.player.mpv.MPVPlayer import com.makd.afinity.ui.player.utils.VolumeManager import dagger.hilt.android.lifecycle.HiltViewModel @@ -82,7 +83,8 @@ class PlayerViewModel @Inject constructor( private val playlistManager: PlaylistManager, private val downloadRepository: JellyfinDownloadRepository, private val appDataRepository: AppDataRepository, - private val apiClient: ApiClient + private val apiClient: ApiClient, + private val audiobookshelfPlayer: AudiobookshelfPlayer ) : ViewModel(), Player.Listener { lateinit var player: Player @@ -643,6 +645,16 @@ class PlayerViewModel @Inject constructor( fun getPlayerView(): PlayerView? = playerView + private fun stopAudiobookshelfIfPlaying() { + if (audiobookshelfPlayer.isPlaying()) { + Timber.d("Stopping Audiobookshelf playback before starting Jellyfin playback") + audiobookshelfPlayer.pause() + viewModelScope.launch { + audiobookshelfPlayer.closeSession() + } + } + } + private suspend fun loadMedia( item: AfinityItem, mediaSourceId: String, @@ -650,6 +662,7 @@ class PlayerViewModel @Inject constructor( subtitleStreamIndex: Int?, startPositionMs: Long ) { + stopAudiobookshelfIfPlaying() try { val fullItem: AfinityItem = if (item.sources.isEmpty()) { Timber.d("Item ${item.name} has no sources, fetching full details...") @@ -848,6 +861,7 @@ class PlayerViewModel @Inject constructor( channelName: String, streamUrl: String ) { + stopAudiobookshelfIfPlaying() mpvLiveAutoHideTriggered = false try { diff --git a/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt b/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt index 19a601c0..4e61794b 100644 --- a/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt @@ -75,6 +75,7 @@ fun SettingsScreen( onPlayerOptionsClick: () -> Unit, onAppearanceOptionsClick: () -> Unit, onServerManagementClick: () -> Unit, + onAudiobookshelfClick: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel() ) { @@ -85,9 +86,11 @@ fun SettingsScreen( val effectiveOfflineMode by viewModel.effectiveOfflineMode.collectAsStateWithLifecycle() val isNetworkAvailable by viewModel.isNetworkAvailable.collectAsStateWithLifecycle() val isJellyseerrAuthenticated by viewModel.isJellyseerrAuthenticated.collectAsStateWithLifecycle() + val isAudiobookshelfAuthenticated by viewModel.isAudiobookshelfAuthenticated.collectAsStateWithLifecycle() var showLogoutDialog by remember { mutableStateOf(false) } var showJellyseerrLogoutDialog by remember { mutableStateOf(false) } + var showAudiobookshelfLogoutDialog by remember { mutableStateOf(false) } var showJellyseerrBottomSheet by remember { mutableStateOf(false) } var showSessionSwitcherSheet by remember { mutableStateOf(false) } val jellyseerrSheetState = rememberModalBottomSheetState() @@ -113,6 +116,16 @@ fun SettingsScreen( ) } + if (showAudiobookshelfLogoutDialog) { + AudiobookshelfLogoutConfirmationDialog( + onConfirm = { + showAudiobookshelfLogoutDialog = false + viewModel.logoutFromAudiobookshelf() + }, + onDismiss = { showAudiobookshelfLogoutDialog = false } + ) + } + if (showJellyseerrBottomSheet) { JellyseerrBottomSheet( onDismiss = { showJellyseerrBottomSheet = false }, @@ -230,6 +243,19 @@ fun SettingsScreen( } ) SettingsDivider() + SettingsSwitchItem( + icon = painterResource(id = R.drawable.ic_headphones), + title = stringResource(R.string.pref_audiobookshelf), + subtitle = if (isAudiobookshelfAuthenticated) stringResource(R.string.audiobookshelf_connected) else stringResource( + R.string.audiobookshelf_connect + ), + checked = isAudiobookshelfAuthenticated, + onCheckedChange = { enabled -> + if (enabled) onAudiobookshelfClick() else showAudiobookshelfLogoutDialog = + true + } + ) + SettingsDivider() SettingsItem( icon = painterResource(id = R.drawable.ic_download), title = stringResource(R.string.pref_downloads), @@ -612,6 +638,38 @@ private fun LogoutConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Uni ) } +@Composable +private fun AudiobookshelfLogoutConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_headphones), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + stringResource(R.string.dialog_disconnect_audiobookshelf_title), + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold) + ) + }, + text = { + Text( + stringResource(R.string.dialog_disconnect_audiobookshelf_message), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button(onClick = onConfirm) { Text(stringResource(R.string.action_disconnect)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + shape = RoundedCornerShape(28.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) +} + @Composable private fun JellyseerrLogoutConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { AlertDialog( diff --git a/app/src/main/java/com/makd/afinity/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/makd/afinity/ui/settings/SettingsViewModel.kt index 9b55f375..99405ae9 100644 --- a/app/src/main/java/com/makd/afinity/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/settings/SettingsViewModel.kt @@ -12,6 +12,7 @@ import com.makd.afinity.data.models.player.MpvVideoOutput import com.makd.afinity.data.models.player.VideoZoomMode import com.makd.afinity.data.models.user.User import com.makd.afinity.data.repository.AppDataRepository +import com.makd.afinity.data.repository.AudiobookshelfRepository import com.makd.afinity.data.repository.JellyseerrRepository import com.makd.afinity.data.repository.PreferencesRepository import com.makd.afinity.data.repository.auth.AuthRepository @@ -40,7 +41,8 @@ class SettingsViewModel @Inject constructor( private val serverRepository: ServerRepository, private val offlineModeManager: OfflineModeManager, private val networkConnectivityMonitor: NetworkConnectivityMonitor, - private val jellyseerrRepository: JellyseerrRepository + private val jellyseerrRepository: JellyseerrRepository, + private val audiobookshelfRepository: AudiobookshelfRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -75,6 +77,9 @@ class SettingsViewModel @Inject constructor( val isJellyseerrAuthenticated: StateFlow = jellyseerrRepository.isAuthenticated .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isAudiobookshelfAuthenticated: StateFlow = audiobookshelfRepository.isAuthenticated + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + init { loadSettings() } @@ -449,6 +454,12 @@ class SettingsViewModel @Inject constructor( } catch (e: Exception) { Timber.w(e, "Failed to logout from Jellyseerr during AFinity logout") } + + try { + audiobookshelfRepository.logout() + } catch (e: Exception) { + Timber.w(e, "Failed to logout from Audiobookshelf during AFinity logout") + } } onLogoutComplete() @@ -479,6 +490,23 @@ class SettingsViewModel @Inject constructor( } } + fun logoutFromAudiobookshelf() { + viewModelScope.launch { + try { + audiobookshelfRepository.logout() + Timber.d("Audiobookshelf logout successful") + } catch (e: Exception) { + Timber.e(e, "Failed to logout from Audiobookshelf") + _uiState.value = _uiState.value.copy( + error = context.getString( + R.string.error_audiobookshelf_logout_failed_fmt, + e.message + ) + ) + } + } + } + fun clearError() { _uiState.value = _uiState.value.copy(error = null) } diff --git a/app/src/main/res/drawable/ic_headphones_filled.xml b/app/src/main/res/drawable/ic_headphones_filled.xml new file mode 100644 index 00000000..2dd7d5e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_headphones_filled.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a7fb3d1..601289ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -646,6 +646,13 @@ Connected via Seerr Connect to request content + Audiobookshelf + Connected to Audiobookshelf + Connect to browse audiobooks + Disconnect Audiobookshelf + Disconnecting will remove your Audiobookshelf configuration. You can reconnect at any time. + Failed to disconnect from Audiobookshelf: %1$s + Downloads Manage offline content diff --git a/gradle.properties b/gradle.properties index e893161d..7805ec99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,4 +24,14 @@ android.nonTransitiveRClass=true # App Configuration app.name=AFinity app.versionName=0.5.4-beta -app.versionCode=35 \ No newline at end of file +app.versionCode=35 +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eeee853f..e1a8c2f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] aboutlibrariesCore = "13.2.1" android-desugar-jdk-libs = "2.1.5" -agp = "8.13.2" +agp = "9.0.0" blurhash = "0.3.0" coilCompose = "3.3.0" composePagerIndicator = "0.0.8" @@ -12,34 +12,34 @@ coreSplashscreen = "1.2.0" jellyfinCore = "1.7.1" kotlin = "2.3.0" coreKtx = "1.17.0" -kotlinxSerializationJson = "1.9.0" +kotlinxSerializationJson = "1.10.0" lifecycleRuntimeCompose = "2.10.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.2" -composeBom = "2025.12.01" -hilt = "2.57.2" +activityCompose = "1.12.3" +composeBom = "2026.01.01" +hilt = "2.59.1" hilt-navigation-compose = "1.3.0" ksp = "2.3.4" material3 = "1.4.0" material3AdaptiveNavigationSuite = "1.4.0" -media3ExoplayerHls = "1.9.0" -media3Session = "1.9.0" +media3ExoplayerHls = "1.9.1" +media3Session = "1.9.1" timber = "5.0.1" libmpv = "0.5.1" media3-ffmpeg-decoder = "1.9.0+1" -navigation = "2.9.6" +navigation = "2.9.7" okhttp = "5.3.2" retrofit = "3.0.0" -media3 = "1.9.0" +media3 = "1.9.1" preference = "1.2.1" room = "2.8.4" datastore = "1.2.0" okhttp-logging = "5.3.2" -paging = "3.3.6" +paging = "3.4.0" tinkAndroid = "1.20.0" -work = "2.11.0" +work = "2.11.1" hilt-work = "1.3.0" -ui = "1.10.0" +ui = "1.10.2" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibrariesCore" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 719a2719..eff1adfc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Sep 04 16:21:38 IST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From adf55a947cce41511a6ba2612aebaf742cb10497 Mon Sep 17 00:00:00 2001 From: MakD Date: Wed, 4 Feb 2026 15:32:51 +0530 Subject: [PATCH 03/19] refactor(UI): Adjust MiniPlayer layout for proper display This commit replaces a `Column` with a `Box` in the main navigation layout. This ensures that the `NavHost` content and the `MiniPlayer` can correctly overlap. By aligning the `MiniPlayer` to the bottom of the `Box`, it now properly displays as an overlay on top of other screens, rather than pushing the main content area up. The `weight` modifier on the `NavHost` was removed as it is no longer necessary within a `Box`. --- .../java/com/makd/afinity/navigation/MainNavigation.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt index b9041ea5..982f0365 100644 --- a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt +++ b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt @@ -5,8 +5,9 @@ package com.makd.afinity.navigation import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Alignment import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -241,11 +242,11 @@ fun MainNavigation( ?.startsWith("audiobookshelf/player/") == true val showMiniPlayer = audiobookshelfPlaybackState.sessionId != null && !isOnAudiobookshelfPlayer - Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { NavHost( navController = navController, startDestination = Destination.HOME.route, - modifier = Modifier.weight(1f) + modifier = Modifier.fillMaxSize() ) { composable(Destination.HOME.route) { HomeScreen( @@ -756,7 +757,8 @@ fun MainNavigation( AnimatedVisibility( visible = showMiniPlayer, enter = slideInVertically { it }, - exit = slideOutVertically { it } + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomCenter) ) { MiniPlayer( title = audiobookshelfPlaybackState.displayTitle, From 1f7861593cf70665ae0b702693b524b40cca525a Mon Sep 17 00:00:00 2001 From: MakD Date: Wed, 4 Feb 2026 16:06:36 +0530 Subject: [PATCH 04/19] refactor(UI): Replace top app bar in Audiobookshelf screen This commit replaces the standard Material `TopAppBar` in the Audiobookshelf libraries screen with the custom `AfinityTopAppBar`. This change provides a consistent look and feel with other parts of the app, featuring a bolded title and icons for navigation to Search and Settings. The previous "Back" button has been removed, and the `PullToRefreshBox` has been simplified to a standard `Box`, as the refresh functionality was no longer implemented. ### Key Changes: * **`AudiobookshelfLibrariesScreen.kt`**: * Replaced `TopAppBar` with the `AfinityTopAppBar` composable. * Removed the `PullToRefreshBox` wrapper. * Updated the screen to accept `NavController` and `MainUiState` to handle top bar actions and display the user's profile image. * **`MainNavigation.kt`**: * Updated the call to `AudiobookshelfLibrariesScreen` to pass the required `navController` and `mainUiState` parameters. * Removed the now-unused `onNavigateBack` lambda. --- .../makd/afinity/navigation/MainNavigation.kt | 5 +- .../AudiobookshelfLibrariesScreen.kt | 47 ++++++++++--------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt index 982f0365..cd06173b 100644 --- a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt +++ b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt @@ -690,7 +690,6 @@ fun MainNavigation( composable(Destination.AUDIOBOOKSHELF_LIBRARIES_ROUTE) { AudiobookshelfLibrariesScreen( - onNavigateBack = { navController.popBackStack() }, onNavigateToLibrary = { libraryId -> navController.navigate( Destination.createAudiobookshelfLibraryRoute( @@ -703,7 +702,9 @@ fun MainNavigation( }, onNavigateToLogin = { navController.navigate(Destination.createAudiobookshelfLoginRoute()) - } + }, + navController = navController, + mainUiState = mainUiState ) } diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt index e410685f..5edf8763 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt @@ -16,19 +16,15 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.ui.text.font.FontWeight +import com.makd.afinity.ui.components.AfinityTopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -36,16 +32,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import com.makd.afinity.data.repository.ItemWithProgress +import com.makd.afinity.navigation.Destination import com.makd.afinity.ui.audiobookshelf.libraries.components.LibraryCard +import com.makd.afinity.ui.main.MainUiState @OptIn(ExperimentalMaterial3Api::class) @Composable fun AudiobookshelfLibrariesScreen( - onNavigateBack: () -> Unit, onNavigateToLibrary: (String) -> Unit, onNavigateToItem: (String) -> Unit, onNavigateToLogin: () -> Unit, + navController: NavController, + mainUiState: MainUiState, viewModel: AudiobookshelfLibrariesViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -60,27 +60,32 @@ fun AudiobookshelfLibrariesScreen( Scaffold( topBar = { - TopAppBar( - title = { Text("Audiobookshelf") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - } + AfinityTopAppBar( + title = { + Text( + text = "Audiobooks", + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onBackground + ) + }, + onSearchClick = { + navController.navigate(Destination.createSearchRoute()) + }, + onProfileClick = { + navController.navigate(Destination.createSettingsRoute()) + }, + userProfileImageUrl = mainUiState.userProfileImageUrl ) } ) { paddingValues -> - PullToRefreshBox( - isRefreshing = uiState.isRefreshing, - onRefresh = viewModel::refreshLibraries, + Box( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { - if (libraries.isEmpty() && !uiState.isRefreshing) { + if (libraries.isEmpty()) { EmptyState() } else { LazyColumn( From 27e5fe693575dc565bd7e6174111257f72510275 Mon Sep 17 00:00:00 2001 From: MakD Date: Wed, 4 Feb 2026 21:03:14 +0530 Subject: [PATCH 05/19] feat(audiobookshelf): Overhaul libraries screen with tabbed navigation This commit redesigns the Audiobookshelf libraries screen, replacing the previous list-based layout with a modern, tab-based interface. This provides a more organized and discoverable user experience. The new layout features dynamic tabs for "Home", "Series", and each individual library, allowing users to quickly pivot between different views of their content. ### Key Changes: * **Tabbed Navigation**: * Implemented `HorizontalPager` with filter chips for navigating between "Home", "Series", and library-specific tabs. * Items for each library are now loaded on-demand as the user selects the corresponding tab. * **New "Home" Tab**: * Introduced `AudiobookshelfHomeTab.kt` to display personalized sections from the server, such as "Continue Listening" or "Recently Added". * These sections are aggregated from all available libraries into a single, unified view. * **New "Series" Tab**: * Added `AudiobookshelfSeriesTab.kt` to group books by series. * Series from all libraries are fetched and displayed in a single, sorted list, each with a horizontally scrolling row of its books. * **ViewModel and Repository**: * `AudiobookshelfLibrariesViewModel` now fetches and manages state for personalized sections, series, and per-library item lists. * `AudiobookshelfRepository` and `AudiobookshelfApiService` were updated to include methods for fetching personalized content and series data. * **UI/UX Improvements**: * Redesigned the `AudiobookCard` for a cleaner look with improved typography and elevation. * Replaced the old "headphones" icon for the Audiobookshelf navigation entry with a new "books" icon for better representation. --- .../audiobookshelf/AudiobookshelfLibrary.kt | 50 +- .../data/network/AudiobookshelfApiService.kt | 13 + .../repository/AudiobookshelfRepository.kt | 3 + .../AudiobookshelfRepositoryImpl.kt | 30 + .../makd/afinity/navigation/Destination.kt | 4 +- .../makd/afinity/navigation/MainNavigation.kt | 1022 ++++++++--------- .../libraries/AudiobookshelfHomeTab.kt | 110 ++ .../AudiobookshelfLibrariesScreen.kt | 308 +++-- .../AudiobookshelfLibrariesViewModel.kt | 146 ++- .../libraries/AudiobookshelfSeriesTab.kt | 113 ++ .../library/components/AudiobookCard.kt | 117 +- .../makd/afinity/ui/item/ItemDetailScreen.kt | 4 + app/src/main/res/drawable/ic_books.xml | 40 +- app/src/main/res/drawable/ic_books_filled.xml | 9 + 14 files changed, 1262 insertions(+), 707 deletions(-) create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfHomeTab.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfSeriesTab.kt create mode 100644 app/src/main/res/drawable/ic_books_filled.xml diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt index be60bf60..dcf4acfc 100644 --- a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt +++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AudiobookshelfLibrary.kt @@ -2,6 +2,7 @@ package com.makd.afinity.data.models.audiobookshelf import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray @Serializable data class Library( @@ -157,6 +158,48 @@ data class LibraryItemsResponse( val include: String? = null ) +@Serializable +data class AudiobookshelfSeries( + @SerialName("id") + val id: String, + @SerialName("name") + val name: String, + @SerialName("nameIgnorePrefix") + val nameIgnorePrefix: String? = null, + @SerialName("description") + val description: String? = null, + @SerialName("addedAt") + val addedAt: Long? = null, + @SerialName("updatedAt") + val updatedAt: Long? = null, + @SerialName("libraryId") + val libraryId: String? = null, + @SerialName("books") + val books: List = emptyList() +) + +@Serializable +data class SeriesListResponse( + @SerialName("results") + val results: List, + @SerialName("total") + val total: Int, + @SerialName("limit") + val limit: Int, + @SerialName("page") + val page: Int, + @SerialName("sortBy") + val sortBy: String? = null, + @SerialName("sortDesc") + val sortDesc: Boolean? = null, + @SerialName("filterBy") + val filterBy: String? = null, + @SerialName("minified") + val minified: Boolean? = null, + @SerialName("include") + val include: String? = null +) + @Serializable data class PersonalizedView( @SerialName("id") @@ -168,10 +211,5 @@ data class PersonalizedView( @SerialName("type") val type: String, @SerialName("entities") - val entities: List -) - -@Serializable -data class PersonalizedResponse( - val items: List + val entities: JsonArray = JsonArray(emptyList()) ) diff --git a/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt b/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt index 63819477..b2aa1048 100644 --- a/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt +++ b/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt @@ -14,6 +14,7 @@ import com.makd.afinity.data.models.audiobookshelf.LoginResponse import com.makd.afinity.data.models.audiobookshelf.MediaProgress import com.makd.afinity.data.models.audiobookshelf.MediaProgressSyncData import com.makd.afinity.data.models.audiobookshelf.PersonalizedView +import com.makd.afinity.data.models.audiobookshelf.SeriesListResponse import com.makd.afinity.data.models.audiobookshelf.PlaybackSession import com.makd.afinity.data.models.audiobookshelf.PlaybackSessionRequest import com.makd.afinity.data.models.audiobookshelf.ProgressUpdateRequest @@ -58,6 +59,18 @@ interface AudiobookshelfApiService { @Query("collapseseries") collapseseries: Int? = null ): Response + @GET("api/libraries/{libraryId}/series") + suspend fun getSeries( + @Path("libraryId") id: String, + @Query("sort") sort: String = "name", + @Query("desc") desc: Int = 0, + @Query("filter") filter: String = "all", + @Query("limit") limit: Int = 100, + @Query("page") page: Int = 0, + @Query("minified") minified: Int = 1, + @Query("include") include: String = "progress" + ): Response + @GET("api/libraries/{libraryId}/personalized") suspend fun getPersonalized( @Path("libraryId") id: String, diff --git a/app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt index 4c46e66d..dce81010 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/AudiobookshelfRepository.kt @@ -1,5 +1,6 @@ package com.makd.afinity.data.repository +import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfSeries import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfUser import com.makd.afinity.data.models.audiobookshelf.Library import com.makd.afinity.data.models.audiobookshelf.LibraryItem @@ -62,6 +63,8 @@ interface AudiobookshelfRepository { suspend fun searchLibrary(libraryId: String, query: String): Result + suspend fun getSeries(libraryId: String, limit: Int = 100, page: Int = 0): Result> + suspend fun getPersonalized(libraryId: String): Result> fun getInProgressItemsFlow(): Flow> diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt index a7f17814..4b7ab438 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.makd.afinity.data.database.entities.AudiobookshelfConfigEntity import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity import com.makd.afinity.data.database.entities.AudiobookshelfLibraryEntity import com.makd.afinity.data.database.entities.AudiobookshelfProgressEntity +import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfSeries import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfUser import com.makd.afinity.data.models.audiobookshelf.DeviceInfo import com.makd.afinity.data.models.audiobookshelf.Library @@ -452,6 +453,35 @@ class AudiobookshelfRepositoryImpl @Inject constructor( } } + override suspend fun getSeries( + libraryId: String, + limit: Int, + page: Int + ): Result> { + return withContext(Dispatchers.IO) { + try { + if (!networkConnectivityMonitor.isCurrentlyConnected()) { + return@withContext Result.failure(Exception("No network connection")) + } + + val response = apiService.get().getSeries( + id = libraryId, + limit = limit, + page = page + ) + + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!.results) + } else { + Result.failure(Exception("Failed to fetch series: ${response.message()}")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get series for library $libraryId") + Result.failure(e) + } + } + } + override suspend fun getPersonalized(libraryId: String): Result> { return withContext(Dispatchers.IO) { try { diff --git a/app/src/main/java/com/makd/afinity/navigation/Destination.kt b/app/src/main/java/com/makd/afinity/navigation/Destination.kt index e2ee41a5..c8c47064 100644 --- a/app/src/main/java/com/makd/afinity/navigation/Destination.kt +++ b/app/src/main/java/com/makd/afinity/navigation/Destination.kt @@ -41,8 +41,8 @@ enum class Destination( AUDIOBOOKS( route = "audiobookshelf/libraries", title = "Audiobooks", - selectedIconRes = R.drawable.ic_headphones_filled, - unselectedIconRes = R.drawable.ic_headphones + selectedIconRes = R.drawable.ic_books_filled, + unselectedIconRes = R.drawable.ic_books ), LIVE_TV( route = "live_tv", diff --git a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt index cd06173b..fd1158d5 100644 --- a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt +++ b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt @@ -240,560 +240,556 @@ fun MainNavigation( ) { val isOnAudiobookshelfPlayer = currentDestination?.route ?.startsWith("audiobookshelf/player/") == true - val showMiniPlayer = audiobookshelfPlaybackState.sessionId != null && !isOnAudiobookshelfPlayer + val showMiniPlayer = + audiobookshelfPlaybackState.sessionId != null && !isOnAudiobookshelfPlayer Box(modifier = Modifier.fillMaxSize()) { - NavHost( - navController = navController, - startDestination = Destination.HOME.route, - modifier = Modifier.fillMaxSize() - ) { - composable(Destination.HOME.route) { - HomeScreen( - onItemClick = { item -> - val route = Destination.createItemDetailRoute(item.id.toString()) - navController.navigate(route) - }, - onPlayClick = { item -> - coroutineScope.launch { - try { - val playableItem = viewModel.resolvePlayableItem(item) - - if (playableItem == null) { - Timber.w("Could not resolve playable item for: ${item.name}") - return@launch + NavHost( + navController = navController, + startDestination = Destination.HOME.route, + modifier = Modifier.fillMaxSize() + ) { + composable(Destination.HOME.route) { + HomeScreen( + onItemClick = { item -> + val route = Destination.createItemDetailRoute(item.id.toString()) + navController.navigate(route) + }, + onPlayClick = { item -> + coroutineScope.launch { + try { + val playableItem = viewModel.resolvePlayableItem(item) + + if (playableItem == null) { + Timber.w("Could not resolve playable item for: ${item.name}") + return@launch + } + + PlayerLauncher.launch( + context = navController.context, + itemId = playableItem.id, + mediaSourceId = playableItem.sources.firstOrNull()?.id + ?: "", + audioStreamIndex = null, + subtitleStreamIndex = null, + startPositionMs = 0L + ) + } catch (e: Exception) { + Timber.e(e, "Failed to handle play click for: ${item.name}") } - - PlayerLauncher.launch( - context = navController.context, - itemId = playableItem.id, - mediaSourceId = playableItem.sources.firstOrNull()?.id ?: "", - audioStreamIndex = null, - subtitleStreamIndex = null, - startPositionMs = 0L - ) - } catch (e: Exception) { - Timber.e(e, "Failed to handle play click for: ${item.name}") } - } - }, - onProfileClick = { - val route = Destination.createSettingsRoute() - navController.navigate(route) - }, - navController = navController, - mainUiState = mainUiState, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } - - composable(Destination.LIBRARIES.route) { - LibrariesScreen( - onLibraryClick = { library -> - val route = Destination.createLibraryContentRoute( - libraryId = library.id.toString(), - libraryName = library.name - ) - navController.navigate(route) - }, - onProfileClick = { - val route = Destination.createSettingsRoute() - navController.navigate(route) - }, - navController = navController, - mainUiState = mainUiState, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + }, + onProfileClick = { + val route = Destination.createSettingsRoute() + navController.navigate(route) + }, + navController = navController, + mainUiState = mainUiState, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable( - route = Destination.LIBRARY_CONTENT_ROUTE, - arguments = listOf( - navArgument("libraryId") { type = NavType.StringType }, - navArgument("libraryName") { type = NavType.StringType } - ) - ) { - LibraryContentScreen( - onItemClick = { item -> - val route = Destination.createItemDetailRoute(item.id.toString()) - navController.navigate(route) - }, - onProfileClick = { - val route = Destination.createSettingsRoute() - navController.navigate(route) - }, - navController = navController, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable(Destination.LIBRARIES.route) { + LibrariesScreen( + onLibraryClick = { library -> + val route = Destination.createLibraryContentRoute( + libraryId = library.id.toString(), + libraryName = library.name + ) + navController.navigate(route) + }, + onProfileClick = { + val route = Destination.createSettingsRoute() + navController.navigate(route) + }, + navController = navController, + mainUiState = mainUiState, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable( - route = Destination.STUDIO_CONTENT_ROUTE, - arguments = listOf( - navArgument("studioName") { type = NavType.StringType } - ) - ) { - LibraryContentScreen( - onItemClick = { item -> - val route = Destination.createItemDetailRoute(item.id.toString()) - navController.navigate(route) - }, - onProfileClick = { - val route = Destination.createSettingsRoute() - navController.navigate(route) - }, - navController = navController, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable( + route = Destination.LIBRARY_CONTENT_ROUTE, + arguments = listOf( + navArgument("libraryId") { type = NavType.StringType }, + navArgument("libraryName") { type = NavType.StringType } + ) + ) { + LibraryContentScreen( + onItemClick = { item -> + val route = Destination.createItemDetailRoute(item.id.toString()) + navController.navigate(route) + }, + onProfileClick = { + val route = Destination.createSettingsRoute() + navController.navigate(route) + }, + navController = navController, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable( - route = Destination.ITEM_DETAIL_ROUTE, - arguments = listOf( - navArgument("itemId") { type = NavType.StringType } - ) - ) { - ItemDetailScreen( - navController = navController, - onPlayClick = { item, selection -> - PlayerLauncher.launch( - context = navController.context, - itemId = item.id, - mediaSourceId = selection?.mediaSourceId - ?: item.sources.firstOrNull()?.id ?: "", - audioStreamIndex = selection?.audioStreamIndex, - subtitleStreamIndex = selection?.subtitleStreamIndex, - startPositionMs = selection?.startPositionMs ?: 0L - ) - }, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable( + route = Destination.STUDIO_CONTENT_ROUTE, + arguments = listOf( + navArgument("studioName") { type = NavType.StringType } + ) + ) { + LibraryContentScreen( + onItemClick = { item -> + val route = Destination.createItemDetailRoute(item.id.toString()) + navController.navigate(route) + }, + onProfileClick = { + val route = Destination.createSettingsRoute() + navController.navigate(route) + }, + navController = navController, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable( - route = Destination.EPISODE_LIST_ROUTE, - arguments = listOf( - navArgument("seasonId") { type = NavType.StringType }, - navArgument("seasonName") { type = NavType.StringType } - ) - ) { backStackEntry -> - ItemDetailScreen( - onPlayClick = { item, selection -> - if (selection != null) { + composable( + route = Destination.ITEM_DETAIL_ROUTE, + arguments = listOf( + navArgument("itemId") { type = NavType.StringType } + ) + ) { + ItemDetailScreen( + navController = navController, + onPlayClick = { item, selection -> PlayerLauncher.launch( context = navController.context, itemId = item.id, - mediaSourceId = selection.mediaSourceId, - audioStreamIndex = selection.audioStreamIndex, - subtitleStreamIndex = selection.subtitleStreamIndex, - startPositionMs = selection.startPositionMs + mediaSourceId = selection?.mediaSourceId + ?: item.sources.firstOrNull()?.id ?: "", + audioStreamIndex = selection?.audioStreamIndex, + subtitleStreamIndex = selection?.subtitleStreamIndex, + startPositionMs = selection?.startPositionMs ?: 0L ) - } - }, - navController = navController, - widthSizeClass = widthSizeClass - ) - } + }, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable( - route = Destination.PERSON_ROUTE, - arguments = listOf( - navArgument("personId") { type = NavType.StringType } - ) - ) { - PersonScreen( - navController = navController, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable( + route = Destination.EPISODE_LIST_ROUTE, + arguments = listOf( + navArgument("seasonId") { type = NavType.StringType }, + navArgument("seasonName") { type = NavType.StringType } + ) + ) { backStackEntry -> + ItemDetailScreen( + onPlayClick = { item, selection -> + if (selection != null) { + PlayerLauncher.launch( + context = navController.context, + itemId = item.id, + mediaSourceId = selection.mediaSourceId, + audioStreamIndex = selection.audioStreamIndex, + subtitleStreamIndex = selection.subtitleStreamIndex, + startPositionMs = selection.startPositionMs + ) + } + }, + navController = navController, + widthSizeClass = widthSizeClass + ) + } - composable(Destination.FAVORITES.route) { - FavoritesScreen( - onItemClick = { item -> - val route = Destination.createItemDetailRoute(item.id.toString()) - navController.navigate(route) - }, - onPersonClick = { personId -> - val route = Destination.createPersonRoute(personId) - navController.navigate(route) - }, - modifier = Modifier.fillMaxSize(), - mainUiState = mainUiState, - navController = navController, - widthSizeClass = widthSizeClass - ) - } + composable( + route = Destination.PERSON_ROUTE, + arguments = listOf( + navArgument("personId") { type = NavType.StringType } + ) + ) { + PersonScreen( + navController = navController, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable(Destination.WATCHLIST.route) { - WatchlistScreen( - onItemClick = { item -> - val route = Destination.createItemDetailRoute(item.id.toString()) - navController.navigate(route) - }, - modifier = Modifier.fillMaxSize(), - mainUiState = mainUiState, - navController = navController, - widthSizeClass = widthSizeClass - ) - } + composable(Destination.FAVORITES.route) { + FavoritesScreen( + onItemClick = { item -> + val route = Destination.createItemDetailRoute(item.id.toString()) + navController.navigate(route) + }, + onPersonClick = { personId -> + val route = Destination.createPersonRoute(personId) + navController.navigate(route) + }, + modifier = Modifier.fillMaxSize(), + mainUiState = mainUiState, + navController = navController, + widthSizeClass = widthSizeClass + ) + } - composable(Destination.REQUESTS.route) { - RequestsScreen( - onSearchClick = { - navController.navigate(Destination.SEARCH_ROUTE) - }, - onProfileClick = { - val route = Destination.createSettingsRoute() - navController.navigate(route) - }, - mainUiState = mainUiState, - onNavigateToFilteredMedia = { filterParams -> - val route = Destination.createFilteredMediaRoute( - filterType = filterParams.type.name, - filterId = filterParams.id, - filterName = filterParams.name - ) - navController.navigate(route) - }, - onItemClick = { jellyfinItemId -> - val route = Destination.createItemDetailRoute(jellyfinItemId) - navController.navigate(route) - }, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable(Destination.WATCHLIST.route) { + WatchlistScreen( + onItemClick = { item -> + val route = Destination.createItemDetailRoute(item.id.toString()) + navController.navigate(route) + }, + modifier = Modifier.fillMaxSize(), + mainUiState = mainUiState, + navController = navController, + widthSizeClass = widthSizeClass + ) + } - composable(Destination.LIVE_TV.route) { - com.makd.afinity.ui.livetv.LiveTvScreen( - navController = navController, - mainUiState = mainUiState, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable(Destination.REQUESTS.route) { + RequestsScreen( + onSearchClick = { + navController.navigate(Destination.SEARCH_ROUTE) + }, + onProfileClick = { + val route = Destination.createSettingsRoute() + navController.navigate(route) + }, + mainUiState = mainUiState, + onNavigateToFilteredMedia = { filterParams -> + val route = Destination.createFilteredMediaRoute( + filterType = filterParams.type.name, + filterId = filterParams.id, + filterName = filterParams.name + ) + navController.navigate(route) + }, + onItemClick = { jellyfinItemId -> + val route = Destination.createItemDetailRoute(jellyfinItemId) + navController.navigate(route) + }, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable( - route = Destination.FILTERED_MEDIA_ROUTE, - arguments = listOf( - navArgument("filterType") { type = NavType.StringType }, - navArgument("filterId") { type = NavType.IntType }, - navArgument("filterName") { type = NavType.StringType } - ) - ) { backStackEntry -> - val filterTypeString = - backStackEntry.arguments?.getString("filterType") ?: return@composable - val filterId = backStackEntry.arguments?.getInt("filterId") ?: return@composable - val filterName = - backStackEntry.arguments?.getString("filterName") ?: return@composable - - val filterType = FilterType.valueOf(filterTypeString) - val filterParams = FilterParams(filterType, filterId, filterName) - - FilteredMediaScreen( - filterParams = filterParams, - onSearchClick = { - navController.navigate(Destination.SEARCH_ROUTE) - }, - onProfileClick = { - val route = Destination.createSettingsRoute() - navController.navigate(route) - }, - mainUiState = mainUiState, - onItemClick = { jellyfinItemId -> - val route = Destination.createItemDetailRoute(jellyfinItemId) - navController.navigate(route) - }, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable(Destination.LIVE_TV.route) { + com.makd.afinity.ui.livetv.LiveTvScreen( + navController = navController, + mainUiState = mainUiState, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable(Destination.SEARCH_ROUTE) { - SearchScreen( - onBackClick = { - navController.popBackStack() - }, - onItemClick = { item -> - val route = Destination.createItemDetailRoute(item.id.toString()) - navController.navigate(route) - }, - onGenreClick = { genre -> - val route = Destination.createGenreResultsRoute(genre) - navController.navigate(route) - }, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable( + route = Destination.FILTERED_MEDIA_ROUTE, + arguments = listOf( + navArgument("filterType") { type = NavType.StringType }, + navArgument("filterId") { type = NavType.IntType }, + navArgument("filterName") { type = NavType.StringType } + ) + ) { backStackEntry -> + val filterTypeString = + backStackEntry.arguments?.getString("filterType") ?: return@composable + val filterId = backStackEntry.arguments?.getInt("filterId") ?: return@composable + val filterName = + backStackEntry.arguments?.getString("filterName") ?: return@composable + + val filterType = FilterType.valueOf(filterTypeString) + val filterParams = FilterParams(filterType, filterId, filterName) + + FilteredMediaScreen( + filterParams = filterParams, + onSearchClick = { + navController.navigate(Destination.SEARCH_ROUTE) + }, + onProfileClick = { + val route = Destination.createSettingsRoute() + navController.navigate(route) + }, + mainUiState = mainUiState, + onItemClick = { jellyfinItemId -> + val route = Destination.createItemDetailRoute(jellyfinItemId) + navController.navigate(route) + }, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable( - route = Destination.GENRE_RESULTS_ROUTE, - arguments = listOf( - navArgument("genre") { type = NavType.StringType } - ) - ) { - GenreResultsScreen( - genre = it.arguments?.getString("genre") ?: "", - onBackClick = { - navController.popBackStack() - }, - onItemClick = { item -> - val route = Destination.createItemDetailRoute(item.id.toString()) - navController.navigate(route) - }, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + composable(Destination.SEARCH_ROUTE) { + SearchScreen( + onBackClick = { + navController.popBackStack() + }, + onItemClick = { item -> + val route = Destination.createItemDetailRoute(item.id.toString()) + navController.navigate(route) + }, + onGenreClick = { genre -> + val route = Destination.createGenreResultsRoute(genre) + navController.navigate(route) + }, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable(Destination.SETTINGS_ROUTE) { - SettingsScreen( - navController = navController, - onBackClick = { - navController.popBackStack() - }, - onLogoutComplete = { - // Logout handled by MainActivity observing auth state - }, - onLicensesClick = { - val route = Destination.createLicensesRoute() - navController.navigate(route) - }, - onDownloadClick = { - val route = Destination.createDownloadSettingsRoute() - navController.navigate(route) - }, - onPlayerOptionsClick = { - val route = Destination.createPlayerOptionsRoute() - navController.navigate(route) - }, - onAppearanceOptionsClick = { - val route = Destination.createAppearanceOptionsRoute() - navController.navigate(route) - }, - onServerManagementClick = { - val route = Destination.createServerManagementRoute() - navController.navigate(route) - }, - onAudiobookshelfClick = { - navController.navigate(Destination.createAudiobookshelfLoginRoute()) - } - ) - } + composable( + route = Destination.GENRE_RESULTS_ROUTE, + arguments = listOf( + navArgument("genre") { type = NavType.StringType } + ) + ) { + GenreResultsScreen( + genre = it.arguments?.getString("genre") ?: "", + onBackClick = { + navController.popBackStack() + }, + onItemClick = { item -> + val route = Destination.createItemDetailRoute(item.id.toString()) + navController.navigate(route) + }, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable(Destination.DOWNLOAD_SETTINGS_ROUTE) { - DownloadSettingsScreen( - onBackClick = { - navController.popBackStack() - }, - offlineModeManager = offlineModeManager, - modifier = Modifier.fillMaxSize() - ) - } + composable(Destination.SETTINGS_ROUTE) { + SettingsScreen( + navController = navController, + onBackClick = { + navController.popBackStack() + }, + onLogoutComplete = { + // Logout handled by MainActivity observing auth state + }, + onLicensesClick = { + val route = Destination.createLicensesRoute() + navController.navigate(route) + }, + onDownloadClick = { + val route = Destination.createDownloadSettingsRoute() + navController.navigate(route) + }, + onPlayerOptionsClick = { + val route = Destination.createPlayerOptionsRoute() + navController.navigate(route) + }, + onAppearanceOptionsClick = { + val route = Destination.createAppearanceOptionsRoute() + navController.navigate(route) + }, + onServerManagementClick = { + val route = Destination.createServerManagementRoute() + navController.navigate(route) + }, + onAudiobookshelfClick = { + navController.navigate(Destination.createAudiobookshelfLoginRoute()) + } + ) + } - composable(Destination.PLAYER_OPTIONS_ROUTE) { - PlayerOptionsScreen( - onBackClick = { - navController.popBackStack() - }, - modifier = Modifier.fillMaxSize() - ) - } + composable(Destination.DOWNLOAD_SETTINGS_ROUTE) { + DownloadSettingsScreen( + onBackClick = { + navController.popBackStack() + }, + offlineModeManager = offlineModeManager, + modifier = Modifier.fillMaxSize() + ) + } - composable(Destination.APPEARANCE_OPTIONS_ROUTE) { - AppearanceOptionsScreen( - onBackClick = { - navController.popBackStack() - } - ) - } + composable(Destination.PLAYER_OPTIONS_ROUTE) { + PlayerOptionsScreen( + onBackClick = { + navController.popBackStack() + }, + modifier = Modifier.fillMaxSize() + ) + } - composable(Destination.LICENSES_ROUTE) { - LicensesScreen( - onBackClick = { - navController.popBackStack() - } - ) - } + composable(Destination.APPEARANCE_OPTIONS_ROUTE) { + AppearanceOptionsScreen( + onBackClick = { + navController.popBackStack() + } + ) + } - composable(Destination.SERVER_MANAGEMENT_ROUTE) { - ServerManagementScreen( - onBackClick = { - navController.popBackStack() - }, - onAddServerClick = { - val route = Destination.createAddEditServerRoute(serverId = null) - navController.navigate(route) - }, - onEditServerClick = { serverId -> - val route = Destination.createAddEditServerRoute(serverId = serverId) - navController.navigate(route) - } - ) - } + composable(Destination.LICENSES_ROUTE) { + LicensesScreen( + onBackClick = { + navController.popBackStack() + } + ) + } - composable( - route = Destination.ADD_EDIT_SERVER_ROUTE, - arguments = listOf( - navArgument("serverId") { - type = NavType.StringType - nullable = true - defaultValue = null - } - ) - ) { - AddEditServerScreen( - onBackClick = { - navController.popBackStack() - } - ) - } + composable(Destination.SERVER_MANAGEMENT_ROUTE) { + ServerManagementScreen( + onBackClick = { + navController.popBackStack() + }, + onAddServerClick = { + val route = Destination.createAddEditServerRoute(serverId = null) + navController.navigate(route) + }, + onEditServerClick = { serverId -> + val route = Destination.createAddEditServerRoute(serverId = serverId) + navController.navigate(route) + } + ) + } - composable( - route = Destination.LOGIN_ROUTE, - arguments = listOf( - navArgument("serverUrl") { - type = NavType.StringType - nullable = true - defaultValue = null - } - ) - ) { - LoginScreen( - onLoginSuccess = { - navController.navigate(Destination.HOME.route) { - popUpTo(0) { inclusive = true } + composable( + route = Destination.ADD_EDIT_SERVER_ROUTE, + arguments = listOf( + navArgument("serverId") { + type = NavType.StringType + nullable = true + defaultValue = null } - }, - modifier = Modifier.fillMaxSize(), - widthSizeClass = widthSizeClass - ) - } + ) + ) { + AddEditServerScreen( + onBackClick = { + navController.popBackStack() + } + ) + } - composable(Destination.AUDIOBOOKSHELF_LOGIN_ROUTE) { - AudiobookshelfLoginScreen( - onNavigateBack = { navController.popBackStack() }, - onLoginSuccess = { - navController.navigate(Destination.createAudiobookshelfLibrariesRoute()) { - popUpTo(Destination.AUDIOBOOKSHELF_LOGIN_ROUTE) { inclusive = true } + composable( + route = Destination.LOGIN_ROUTE, + arguments = listOf( + navArgument("serverUrl") { + type = NavType.StringType + nullable = true + defaultValue = null } - } - ) - } + ) + ) { + LoginScreen( + onLoginSuccess = { + navController.navigate(Destination.HOME.route) { + popUpTo(0) { inclusive = true } + } + }, + modifier = Modifier.fillMaxSize(), + widthSizeClass = widthSizeClass + ) + } - composable(Destination.AUDIOBOOKSHELF_LIBRARIES_ROUTE) { - AudiobookshelfLibrariesScreen( - onNavigateToLibrary = { libraryId -> - navController.navigate( - Destination.createAudiobookshelfLibraryRoute( - libraryId + composable(Destination.AUDIOBOOKSHELF_LOGIN_ROUTE) { + AudiobookshelfLoginScreen( + onNavigateBack = { navController.popBackStack() }, + onLoginSuccess = { + navController.navigate(Destination.createAudiobookshelfLibrariesRoute()) { + popUpTo(Destination.AUDIOBOOKSHELF_LOGIN_ROUTE) { inclusive = true } + } + } + ) + } + + composable(Destination.AUDIOBOOKSHELF_LIBRARIES_ROUTE) { + AudiobookshelfLibrariesScreen( + onNavigateToItem = { itemId -> + navController.navigate(Destination.createAudiobookshelfItemRoute(itemId)) + }, + onNavigateToLogin = { + navController.navigate(Destination.createAudiobookshelfLoginRoute()) + }, + navController = navController, + mainUiState = mainUiState, + widthSizeClass = widthSizeClass + ) + } + + composable( + route = Destination.AUDIOBOOKSHELF_LIBRARY_ROUTE, + arguments = listOf( + navArgument("libraryId") { type = NavType.StringType } + ) + ) { + AudiobookshelfLibraryScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToItem = { itemId -> + navController.navigate(Destination.createAudiobookshelfItemRoute(itemId)) + } + ) + } + + composable( + route = Destination.AUDIOBOOKSHELF_ITEM_ROUTE, + arguments = listOf( + navArgument("itemId") { type = NavType.StringType } + ) + ) { + AudiobookshelfItemScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToPlayer = { itemId, episodeId -> + navController.navigate( + Destination.createAudiobookshelfPlayerRoute(itemId, episodeId) ) - ) - }, - onNavigateToItem = { itemId -> - navController.navigate(Destination.createAudiobookshelfItemRoute(itemId)) - }, - onNavigateToLogin = { - navController.navigate(Destination.createAudiobookshelfLoginRoute()) - }, - navController = navController, - mainUiState = mainUiState - ) - } + } + ) + } - composable( - route = Destination.AUDIOBOOKSHELF_LIBRARY_ROUTE, - arguments = listOf( - navArgument("libraryId") { type = NavType.StringType } - ) - ) { - AudiobookshelfLibraryScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToItem = { itemId -> - navController.navigate(Destination.createAudiobookshelfItemRoute(itemId)) - } - ) + composable( + route = Destination.AUDIOBOOKSHELF_PLAYER_ROUTE, + arguments = listOf( + navArgument("itemId") { type = NavType.StringType }, + navArgument("episodeId") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { + AudiobookshelfPlayerScreen( + onNavigateBack = { navController.popBackStack() } + ) + } } - composable( - route = Destination.AUDIOBOOKSHELF_ITEM_ROUTE, - arguments = listOf( - navArgument("itemId") { type = NavType.StringType } - ) + AnimatedVisibility( + visible = showMiniPlayer, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomCenter) ) { - AudiobookshelfItemScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToPlayer = { itemId, episodeId -> - navController.navigate( - Destination.createAudiobookshelfPlayerRoute(itemId, episodeId) - ) - } - ) - } - - composable( - route = Destination.AUDIOBOOKSHELF_PLAYER_ROUTE, - arguments = listOf( - navArgument("itemId") { type = NavType.StringType }, - navArgument("episodeId") { - type = NavType.StringType - nullable = true - defaultValue = null + MiniPlayer( + title = audiobookshelfPlaybackState.displayTitle, + author = audiobookshelfPlaybackState.displayAuthor, + coverUrl = audiobookshelfPlaybackState.coverUrl, + currentTime = audiobookshelfPlaybackState.currentTime, + duration = audiobookshelfPlaybackState.duration, + isPlaying = audiobookshelfPlaybackState.isPlaying, + isBuffering = audiobookshelfPlaybackState.isBuffering, + onPlayPauseClick = { + if (viewModel.audiobookshelfPlayer.isPlaying()) { + viewModel.audiobookshelfPlayer.pause() + } else { + viewModel.audiobookshelfPlayer.play() + } + }, + onCloseClick = { + viewModel.audiobookshelfPlayer.pause() + coroutineScope.launch { + viewModel.audiobookshelfPlayer.closeSession() + } + }, + onClick = { + val itemId = audiobookshelfPlaybackState.itemId + val episodeId = audiobookshelfPlaybackState.episodeId + if (itemId != null) { + navController.navigate( + Destination.createAudiobookshelfPlayerRoute(itemId, episodeId) + ) + } } ) - ) { - AudiobookshelfPlayerScreen( - onNavigateBack = { navController.popBackStack() } - ) } } - - AnimatedVisibility( - visible = showMiniPlayer, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomCenter) - ) { - MiniPlayer( - title = audiobookshelfPlaybackState.displayTitle, - author = audiobookshelfPlaybackState.displayAuthor, - coverUrl = audiobookshelfPlaybackState.coverUrl, - currentTime = audiobookshelfPlaybackState.currentTime, - duration = audiobookshelfPlaybackState.duration, - isPlaying = audiobookshelfPlaybackState.isPlaying, - isBuffering = audiobookshelfPlaybackState.isBuffering, - onPlayPauseClick = { - if (viewModel.audiobookshelfPlayer.isPlaying()) { - viewModel.audiobookshelfPlayer.pause() - } else { - viewModel.audiobookshelfPlayer.play() - } - }, - onCloseClick = { - viewModel.audiobookshelfPlayer.pause() - coroutineScope.launch { - viewModel.audiobookshelfPlayer.closeSession() - } - }, - onClick = { - val itemId = audiobookshelfPlaybackState.itemId - val episodeId = audiobookshelfPlaybackState.episodeId - if (itemId != null) { - navController.navigate( - Destination.createAudiobookshelfPlayerRoute(itemId, episodeId) - ) - } - } - ) - } - } } GlobalUpdateDialog(updateManager = updateManager) } \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfHomeTab.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfHomeTab.kt new file mode 100644 index 00000000..32cc5e7c --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfHomeTab.kt @@ -0,0 +1,110 @@ +package com.makd.afinity.ui.audiobookshelf.libraries + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.ui.audiobookshelf.library.components.AudiobookCard +import com.makd.afinity.ui.theme.CardDimensions +import com.makd.afinity.ui.theme.CardDimensions.portraitWidth + +@Composable +fun AudiobookshelfHomeTab( + sections: List, + serverUrl: String?, + onItemClick: (LibraryItem) -> Unit, + isLoading: Boolean, + widthSizeClass: WindowWidthSizeClass +) { + when { + isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + sections.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No personalized content available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + val cardWidth = widthSizeClass.portraitWidth + val cardHeight = CardDimensions.calculateHeight(cardWidth, 1f) + val fixedRowHeight = cardHeight + 8.dp + 20.dp + 18.dp + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + sections.forEach { section -> + item(key = section.id) { + Column { + Spacer(modifier = Modifier.height(24.dp)) + Column( + modifier = Modifier.padding(horizontal = 14.dp) + ) { + Text( + text = section.label, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 16.dp) + ) + + val uniqueItems = section.items.distinctBy { it.id } + + LazyRow( + modifier = Modifier.height(fixedRowHeight), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 0.dp) + ) { + items( + items = uniqueItems, + key = { item -> "${section.id}_${item.id}" } + ) { item -> + AudiobookCard( + item = item, + serverUrl = serverUrl, + onClick = { onItemClick(item) }, + modifier = Modifier.width(cardWidth) + ) + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt index 5edf8763..5cd4fb28 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt @@ -7,57 +7,85 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.ui.text.font.FontWeight -import com.makd.afinity.ui.components.AfinityTopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import com.makd.afinity.data.repository.ItemWithProgress +import com.makd.afinity.R import com.makd.afinity.navigation.Destination -import com.makd.afinity.ui.audiobookshelf.libraries.components.LibraryCard +import com.makd.afinity.ui.audiobookshelf.library.components.AudiobookCard +import com.makd.afinity.ui.components.AfinityTopAppBar import com.makd.afinity.ui.main.MainUiState +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun AudiobookshelfLibrariesScreen( - onNavigateToLibrary: (String) -> Unit, onNavigateToItem: (String) -> Unit, onNavigateToLogin: () -> Unit, navController: NavController, mainUiState: MainUiState, + widthSizeClass: WindowWidthSizeClass, viewModel: AudiobookshelfLibrariesViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val libraries by viewModel.libraries.collectAsStateWithLifecycle() - val inProgressItems by viewModel.inProgressItems.collectAsStateWithLifecycle() val isAuthenticated by viewModel.isAuthenticated.collectAsStateWithLifecycle() + val personalizedSections by viewModel.personalizedSections.collectAsStateWithLifecycle() + val libraryItems by viewModel.libraryItems.collectAsStateWithLifecycle() + val allSeries by viewModel.allSeries.collectAsStateWithLifecycle() + val config by viewModel.currentConfig.collectAsStateWithLifecycle() if (!isAuthenticated) { onNavigateToLogin() return } + val tabCount = 2 + libraries.size + val pagerState = rememberPagerState(pageCount = { tabCount }) + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage >= 2) { + val libraryIndex = pagerState.currentPage - 2 + if (libraryIndex < libraries.size) { + viewModel.loadLibraryItems(libraries[libraryIndex].id) + } + } + } + Scaffold( topBar = { AfinityTopAppBar( @@ -85,48 +113,136 @@ fun AudiobookshelfLibrariesScreen( .fillMaxSize() .padding(paddingValues) ) { - if (libraries.isEmpty()) { - EmptyState() - } else { - LazyColumn( + if (libraries.isEmpty() && !uiState.isRefreshing) { + Box( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + contentAlignment = Alignment.Center ) { - if (inProgressItems.isNotEmpty()) { + if (uiState.isRefreshing) { + CircularProgressIndicator() + } else { + Text( + text = "No libraries found", + style = MaterialTheme.typography.titleMedium + ) + } + } + } else { + Column(modifier = Modifier.fillMaxSize()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { item { - Text( - text = "Continue Listening", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp) + NavigationChip( + selected = pagerState.currentPage == 0, + label = "Home", + iconResId = R.drawable.ic_home_filled, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(0) + } + } ) } - item { - ContinueListeningRow( - items = inProgressItems, - onItemClick = { onNavigateToItem(it.item.id) } + NavigationChip( + selected = pagerState.currentPage == 1, + label = "Series", + iconResId = R.drawable.ic_books_filled, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(1) + } + } ) } - - item { - Spacer(modifier = Modifier.height(16.dp)) + itemsIndexed(libraries) { index, library -> + NavigationChip( + selected = pagerState.currentPage == index + 2, + label = library.name, + iconResId = R.drawable.ic_headphones_filled, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index + 2) + } + } + ) } } - item { - Text( - text = "Libraries", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - } + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + when (page) { + 0 -> { + AudiobookshelfHomeTab( + sections = personalizedSections, + serverUrl = config?.serverUrl, + onItemClick = { item -> onNavigateToItem(item.id) }, + isLoading = uiState.isLoadingPersonalized, + widthSizeClass = widthSizeClass + ) + } - items(libraries, key = { it.id }) { library -> - LibraryCard( - library = library, - onClick = { onNavigateToLibrary(library.id) } - ) + 1 -> { + AudiobookshelfSeriesTab( + seriesList = allSeries, + serverUrl = config?.serverUrl, + onItemClick = { item -> onNavigateToItem(item.id) }, + isLoading = uiState.isLoadingSeries, + widthSizeClass = widthSizeClass + ) + } + + else -> { + val libraryIndex = page - 2 + if (libraryIndex < libraries.size) { + val library = libraries[libraryIndex] + val items = libraryItems[library.id] + + if (items == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (items.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No items in library", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(items, key = { it.id }) { item -> + AudiobookCard( + item = item, + serverUrl = config?.serverUrl, + onClick = { onNavigateToItem(item.id) } + ) + } + } + } + } + } + } } } } @@ -156,82 +272,48 @@ fun AudiobookshelfLibrariesScreen( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun EmptyState() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "No libraries found", - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Pull to refresh", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun ContinueListeningRow( - items: List, - onItemClick: (ItemWithProgress) -> Unit -) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(items, key = { it.item.id }) { itemWithProgress -> - ContinueListeningCard( - item = itemWithProgress, - onClick = { onItemClick(itemWithProgress) } - ) - } - } -} - -@Composable -private fun ContinueListeningCard( - item: ItemWithProgress, +private fun NavigationChip( + selected: Boolean, + label: String, + iconResId: Int, onClick: () -> Unit ) { - Card( + FilterChip( + selected = selected, onClick = onClick, - modifier = Modifier - .width(160.dp) - ) { - Column( - modifier = Modifier.padding(12.dp) - ) { + label = { Text( - text = item.item.media.metadata.title ?: "Unknown", - style = MaterialTheme.typography.bodyMedium, - maxLines = 2 - ) - - item.item.media.metadata.authorName?.let { author -> - Text( - text = author, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 + text = label, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium ) - } - - item.progress?.let { progress -> - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "${(progress.progress * 100).toInt()}% complete", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - } - } - } + ) + }, + leadingIcon = { + Icon( + painter = painterResource(id = iconResId), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + }, + shape = CircleShape, + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = if (selected) Color.Transparent else MaterialTheme.colorScheme.outline.copy( + alpha = 0.3f + ), + selectedBorderColor = Color.Transparent + ), + colors = FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + iconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) } diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt index 2403bd1e..5644eff9 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt @@ -2,9 +2,11 @@ package com.makd.afinity.ui.audiobookshelf.libraries import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfSeries import com.makd.afinity.data.models.audiobookshelf.Library +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.data.repository.AudiobookshelfConfig import com.makd.afinity.data.repository.AudiobookshelfRepository -import com.makd.afinity.data.repository.ItemWithProgress import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -12,26 +14,43 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import timber.log.Timber import javax.inject.Inject +data class PersonalizedSection( + val id: String, + val label: String, + val items: List +) + @HiltViewModel class AudiobookshelfLibrariesViewModel @Inject constructor( private val audiobookshelfRepository: AudiobookshelfRepository ) : ViewModel() { + private val json = Json { ignoreUnknownKeys = true } + private val _uiState = MutableStateFlow(AudiobookshelfLibrariesUiState()) val uiState: StateFlow = _uiState.asStateFlow() val libraries: StateFlow> = audiobookshelfRepository.getLibrariesFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val inProgressItems: StateFlow> = - audiobookshelfRepository.getInProgressItemsFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val currentConfig: StateFlow = audiobookshelfRepository.currentConfig val isAuthenticated = audiobookshelfRepository.isAuthenticated + private val _personalizedSections = MutableStateFlow>(emptyList()) + val personalizedSections: StateFlow> = + _personalizedSections.asStateFlow() + + private val _libraryItems = MutableStateFlow>>(emptyMap()) + val libraryItems: StateFlow>> = _libraryItems.asStateFlow() + + private val _allSeries = MutableStateFlow>(emptyList()) + val allSeries: StateFlow> = _allSeries.asStateFlow() + init { refreshLibraries() } @@ -44,12 +63,12 @@ class AudiobookshelfLibrariesViewModel @Inject constructor( result.fold( onSuccess = { libraries -> - _uiState.value = _uiState.value.copy( - isRefreshing = false - ) + _uiState.value = _uiState.value.copy(isRefreshing = false) Timber.d("Refreshed ${libraries.size} libraries") audiobookshelfRepository.refreshProgress() + loadPersonalizedData(libraries) + loadAllSeries(libraries) }, onFailure = { error -> _uiState.value = _uiState.value.copy( @@ -62,6 +81,117 @@ class AudiobookshelfLibrariesViewModel @Inject constructor( } } + private fun loadPersonalizedData(libraryList: List) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingPersonalized = true) + + val allSections = mutableMapOf() + + for (library in libraryList) { + try { + val result = audiobookshelfRepository.getPersonalized(library.id) + result.fold( + onSuccess = { views -> + for (view in views) { + if (view.type == "authors") continue + + val items = view.entities.mapNotNull { element -> + try { + json.decodeFromJsonElement( + LibraryItem.serializer(), + element + ) + } catch (e: Exception) { + Timber.d("Skipping non-LibraryItem entity in section ${view.id}") + null + } + } + + if (items.isEmpty()) continue + + val existing = allSections[view.id] + if (existing != null) { + val mergedItems = (existing.items + items) + .distinctBy { it.id } + allSections[view.id] = existing.copy(items = mergedItems) + } else { + allSections[view.id] = PersonalizedSection( + id = view.id, + label = view.label, + items = items + ) + } + } + }, + onFailure = { error -> + Timber.e( + error, + "Failed to load personalized data for library ${library.id}" + ) + } + ) + } catch (e: Exception) { + Timber.e(e, "Error loading personalized data for library ${library.id}") + } + } + + _personalizedSections.value = allSections.values.toList() + _uiState.value = _uiState.value.copy(isLoadingPersonalized = false) + } + } + + private fun loadAllSeries(libraryList: List) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingSeries = true) + + val allSeriesMap = mutableMapOf() + + for (library in libraryList) { + try { + val result = audiobookshelfRepository.getSeries(library.id) + result.fold( + onSuccess = { seriesList -> + for (series in seriesList) { + val existing = allSeriesMap[series.id] + if (existing != null) { + val mergedBooks = (existing.books + series.books) + .distinctBy { it.id } + allSeriesMap[series.id] = existing.copy(books = mergedBooks) + } else { + allSeriesMap[series.id] = series + } + } + }, + onFailure = { error -> + Timber.e(error, "Failed to load series for library ${library.id}") + } + ) + } catch (e: Exception) { + Timber.e(e, "Error loading series for library ${library.id}") + } + } + + _allSeries.value = allSeriesMap.values.sortedBy { it.nameIgnorePrefix ?: it.name } + _uiState.value = _uiState.value.copy(isLoadingSeries = false) + } + } + + fun loadLibraryItems(libraryId: String) { + if (_libraryItems.value.containsKey(libraryId)) return + + viewModelScope.launch { + val result = audiobookshelfRepository.refreshLibraryItems(libraryId) + result.fold( + onSuccess = { items -> + _libraryItems.value = _libraryItems.value + (libraryId to items) + }, + onFailure = { error -> + Timber.e(error, "Failed to load items for library $libraryId") + } + ) + } + } + fun clearError() { _uiState.value = _uiState.value.copy(error = null) } @@ -69,5 +199,7 @@ class AudiobookshelfLibrariesViewModel @Inject constructor( data class AudiobookshelfLibrariesUiState( val isRefreshing: Boolean = false, + val isLoadingPersonalized: Boolean = false, + val isLoadingSeries: Boolean = false, val error: String? = null ) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfSeriesTab.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfSeriesTab.kt new file mode 100644 index 00000000..f63d227b --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfSeriesTab.kt @@ -0,0 +1,113 @@ +package com.makd.afinity.ui.audiobookshelf.libraries + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfSeries +import com.makd.afinity.data.models.audiobookshelf.LibraryItem +import com.makd.afinity.ui.audiobookshelf.library.components.AudiobookCard +import com.makd.afinity.ui.theme.CardDimensions +import com.makd.afinity.ui.theme.CardDimensions.portraitWidth + +@Composable +fun AudiobookshelfSeriesTab( + seriesList: List, + serverUrl: String?, + onItemClick: (LibraryItem) -> Unit, + isLoading: Boolean, + widthSizeClass: WindowWidthSizeClass +) { + when { + isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + seriesList.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No series found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + val cardWidth = widthSizeClass.portraitWidth + val cardHeight = CardDimensions.calculateHeight(cardWidth, 1f) + val fixedRowHeight = cardHeight + 8.dp + 20.dp + 18.dp + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + seriesList.forEach { series -> + if (series.books.isNotEmpty()) { + item(key = series.id) { + Column { + Spacer(modifier = Modifier.height(24.dp)) + Column( + modifier = Modifier.padding(horizontal = 14.dp) + ) { + Text( + text = series.name, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 16.dp) + ) + + val uniqueBooks = series.books.distinctBy { it.id } + + LazyRow( + modifier = Modifier.height(fixedRowHeight), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 0.dp) + ) { + items( + items = uniqueBooks, + key = { item -> "${series.id}_${item.id}" } + ) { item -> + AudiobookCard( + item = item, + serverUrl = serverUrl, + onClick = { onItemClick(item) }, + modifier = Modifier.width(cardWidth) + ) + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt index 12ce1f14..eeccccb7 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/AudiobookCard.kt @@ -1,11 +1,13 @@ package com.makd.afinity.ui.audiobookshelf.library.components +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.LinearProgressIndicator @@ -13,9 +15,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.makd.afinity.data.models.audiobookshelf.LibraryItem @@ -27,69 +30,65 @@ fun AudiobookCard( onClick: () -> Unit, modifier: Modifier = Modifier ) { - Card( - onClick = onClick, - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column { - val coverUrl = if (serverUrl != null && item.media.coverPath != null) { - "$serverUrl/api/items/${item.id}/cover" - } else null + val coverUrl = if (serverUrl != null && item.media.coverPath != null) { + "$serverUrl/api/items/${item.id}/cover" + } else null - if (coverUrl != null) { - AsyncImage( - model = coverUrl, - contentDescription = "Cover for ${item.media.metadata.title}", - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop - ) - } else { - Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + Column( + modifier = modifier + ) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = "Cover for ${item.media.metadata.title}", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) - ) {} + } } + } - Column( - modifier = Modifier.padding(8.dp) - ) { - Text( - text = item.media.metadata.title ?: "Unknown Title", - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + Spacer(modifier = Modifier.height(8.dp)) - item.media.metadata.authorName?.let { author -> - Text( - text = author, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + Text( + text = item.media.metadata.title ?: "Unknown Title", + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) - item.userMediaProgress?.let { progress -> - if (progress.progress > 0 && !progress.isFinished) { - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { progress.progress.toFloat() }, - modifier = Modifier.fillMaxWidth(), - trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ) - } - } + item.media.metadata.authorName?.let { author -> + Text( + text = author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + item.userMediaProgress?.let { progress -> + if (progress.progress > 0 && !progress.isFinished) { + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { progress.progress.toFloat() }, + modifier = Modifier.fillMaxWidth(), + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) } } } diff --git a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt index 2564b3da..f62fdd12 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt @@ -1,7 +1,10 @@ +@file:OptIn(UnstableApi::class) + package com.makd.afinity.ui.item import android.content.Context import android.content.res.Configuration +import androidx.annotation.OptIn import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -53,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController import androidx.paging.PagingData import com.makd.afinity.R diff --git a/app/src/main/res/drawable/ic_books.xml b/app/src/main/res/drawable/ic_books.xml index f5c45b73..a2e4b854 100644 --- a/app/src/main/res/drawable/ic_books.xml +++ b/app/src/main/res/drawable/ic_books.xml @@ -1,10 +1,36 @@ - - + android:viewportWidth="24" + android:viewportHeight="24"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_books_filled.xml b/app/src/main/res/drawable/ic_books_filled.xml new file mode 100644 index 00000000..fb02561c --- /dev/null +++ b/app/src/main/res/drawable/ic_books_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file From f44386fe8549814f3cab06debee3df9969136cb5 Mon Sep 17 00:00:00 2001 From: MakD Date: Wed, 4 Feb 2026 22:50:03 +0530 Subject: [PATCH 06/19] refactor(UI): Redesign item details screen with hero header and landscape layout This commit completely redesigns the item details screen (for both audiobooks and podcasts) to create a more modern and immersive user experience. The most significant change is the introduction of a "hero" header, which displays a full-width, blurred version of the cover art at the top of the screen, overlaid with a gradient. The main content, including the cover image, title, author, and play button, is now presented over this hero background, creating a more visually engaging layout. The screen now also supports a landscape orientation, providing a split-view layout with item details on one side and the chapter/episode list on the other for better use of space on wider screens. ### Key Changes: * **`ItemHeader.kt`**: * Rebuilt from the ground up to feature a blurred hero background (`ItemHeroBackground`). * Redesigned the main content area (`ItemHeaderContent`) with improved typography, spacing, and a more prominent, stylized "Play" button. * The book/podcast description is now a collapsible `ExpandableSynopsis` that supports "Show More"/"Show Less" functionality and renders basic HTML content. * Playback progress is now shown with a circular indicator and "time left" text instead of a linear progress bar. * **`ChapterList.kt` & `EpisodeList.kt`**: * List items are now styled with rounded corners and background colors for the current item, improving clarity. * Visual refresh of icons, typography, and spacing for a cleaner look. * Removed dividers in favor of spacing between items. * **`AudiobookshelfItemScreen.kt`**: * Implemented a landscape layout that shows item details and the chapter/episode list side-by-side. * Removed the `TopAppBar`, replacing it with a floating, semi-transparent back button for a cleaner, full-screen look. * The main content area is now a `LazyColumn` for better performance with long chapter or episode lists. --- .../item/AudiobookshelfItemScreen.kt | 289 +++++++++---- .../item/components/ChapterList.kt | 140 ++++--- .../item/components/EpisodeList.kt | 77 ++-- .../item/components/ItemHeader.kt | 380 +++++++++++++----- 4 files changed, 612 insertions(+), 274 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt index 48421066..9826c5ca 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt @@ -1,13 +1,25 @@ package com.makd.afinity.ui.audiobookshelf.item +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -15,25 +27,27 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.makd.afinity.ui.audiobookshelf.item.components.ChapterList import com.makd.afinity.ui.audiobookshelf.item.components.EpisodeList import com.makd.afinity.ui.audiobookshelf.item.components.ItemHeader +import com.makd.afinity.ui.audiobookshelf.item.components.ItemHeaderContent +import com.makd.afinity.ui.audiobookshelf.item.components.ItemHeroBackground -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AudiobookshelfItemScreen( onNavigateBack: () -> Unit, @@ -46,40 +60,106 @@ fun AudiobookshelfItemScreen( val config by viewModel.currentConfig.collectAsStateWithLifecycle() val isPodcast = item?.mediaType?.lowercase() == "podcast" + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - Scaffold( - topBar = { - TopAppBar( - title = { Text(item?.media?.metadata?.title ?: "Item") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - } - ) - } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - when { - uiState.isLoading -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) - } + Box(modifier = Modifier.fillMaxSize()) { + when { + uiState.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + item != null -> { + if (isLandscape) { + val coverUrl = + if (config?.serverUrl != null && item?.media?.coverPath != null) { + "${config?.serverUrl}/api/items/${item?.id}/cover" + } else null - item != null -> { - androidx.compose.foundation.layout.Column( + ItemHeroBackground(coverUrl = coverUrl) + + Row( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.displayCutout) ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp) + ) { + ItemHeaderContent( + item = item!!, + progress = progress, + coverUrl = coverUrl, + onPlay = { onNavigateToPlayer(viewModel.itemId, null) } + ) + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)) + ) { + Spacer(modifier = Modifier.statusBarsPadding()) + + val showEpisodesHeader = isPodcast && uiState.episodes.isNotEmpty() + val showChaptersHeader = !isPodcast && uiState.chapters.isNotEmpty() + + if (showEpisodesHeader || showChaptersHeader) { + Text( + text = if (showEpisodesHeader) "EPISODES" else "CHAPTERS", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 20.dp, + end = 16.dp, + top = 20.dp, + bottom = 12.dp + ) + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + ) { + if (isPodcast && uiState.episodes.isNotEmpty()) { + item { + EpisodeList( + episodes = uiState.episodes, + onEpisodeClick = { /* Details */ }, + onEpisodePlay = { episode -> + onNavigateToPlayer(viewModel.itemId, episode.id) + }, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp) + ) + } + } else if (!isPodcast && uiState.chapters.isNotEmpty()) { + item { + ChapterList( + chapters = uiState.chapters, + currentPosition = progress?.currentTime, + onChapterClick = { chapter -> + onNavigateToPlayer(viewModel.itemId, null) + }, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } + } + } + } + + } else { + Column(modifier = Modifier.fillMaxSize()) { + ItemHeader( item = item!!, progress = progress, @@ -89,65 +169,108 @@ fun AudiobookshelfItemScreen( } ) - if (isPodcast && uiState.episodes.isNotEmpty()) { - EpisodeList( - episodes = uiState.episodes, - onEpisodeClick = { episode -> - // Could show episode details - }, - onEpisodePlay = { episode -> - onNavigateToPlayer(viewModel.itemId, episode.id) - }, - modifier = Modifier.padding(top = 16.dp) - ) - } else if (!isPodcast && uiState.chapters.isNotEmpty()) { - ChapterList( - chapters = uiState.chapters, - currentPosition = progress?.currentTime, - onChapterClick = { chapter -> - // Could seek to chapter - onNavigateToPlayer(viewModel.itemId, null) - }, - modifier = Modifier.padding(top = 16.dp) + val showEpisodesHeader = isPodcast && uiState.episodes.isNotEmpty() + val showChaptersHeader = !isPodcast && uiState.chapters.isNotEmpty() + + if (showEpisodesHeader || showChaptersHeader) { + Text( + text = if (showEpisodesHeader) "EPISODES" else "CHAPTERS", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding( + start = 20.dp, + end = 16.dp, + top = 12.dp, + bottom = 12.dp + ) ) } - Spacer( - modifier = Modifier.padding(bottom = 32.dp) - ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + ) { + if (isPodcast && uiState.episodes.isNotEmpty()) { + item { + EpisodeList( + episodes = uiState.episodes, + onEpisodeClick = { /* Details */ }, + onEpisodePlay = { episode -> + onNavigateToPlayer(viewModel.itemId, episode.id) + }, + modifier = Modifier.padding(top = 16.dp, bottom = 16.dp) + ) + } + } else if (!isPodcast && uiState.chapters.isNotEmpty()) { + item { + ChapterList( + chapters = uiState.chapters, + currentPosition = progress?.currentTime, + onChapterClick = { chapter -> + onNavigateToPlayer(viewModel.itemId, null) + }, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } else { + item { Spacer(modifier = Modifier.padding(32.dp)) } + } + } } } + } - uiState.error != null -> { - Text( - text = "Failed to load item", - modifier = Modifier.align(Alignment.Center), - style = MaterialTheme.typography.bodyLarge - ) - } + uiState.error != null -> { + Text( + text = "Failed to load item", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyLarge + ) } + } - AnimatedVisibility( - visible = uiState.error != null, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.BottomCenter) + IconButton( + onClick = onNavigateBack, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(8.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + + AnimatedVisibility( + visible = uiState.error != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(WindowInsets.navigationBars.asPaddingValues()), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Text( - text = uiState.error ?: "", - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onErrorContainer - ) - } + Text( + text = uiState.error ?: "", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt index b9f7c71c..33837cb6 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt @@ -1,22 +1,29 @@ package com.makd.afinity.ui.audiobookshelf.item.components +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.PlayCircle -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Equalizer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.makd.afinity.data.models.audiobookshelf.BookChapter @@ -28,32 +35,30 @@ fun ChapterList( onChapterClick: (BookChapter) -> Unit, modifier: Modifier = Modifier ) { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = "Chapters (${chapters.size})", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - - chapters.forEachIndexed { index, chapter -> - val isCurrentChapter = currentPosition?.let { pos -> - pos >= chapter.start && pos < chapter.end - } ?: false - - val isCompleted = currentPosition?.let { pos -> - pos >= chapter.end - } ?: false + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + chapters.forEachIndexed { index, chapter -> + val isCurrentChapter = currentPosition?.let { pos -> + pos >= chapter.start && pos < chapter.end + } ?: false - ChapterItem( - chapter = chapter, - index = index + 1, - isCurrentChapter = isCurrentChapter, - isCompleted = isCompleted, - onClick = { onChapterClick(chapter) } - ) + val isCompleted = currentPosition?.let { pos -> + pos >= chapter.end + } ?: false - if (index < chapters.size - 1) { - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + ChapterItem( + chapter = chapter, + index = index + 1, + isCurrentChapter = isCurrentChapter, + isCompleted = isCompleted, + onClick = { onChapterClick(chapter) } + ) } } } @@ -67,61 +72,86 @@ private fun ChapterItem( isCompleted: Boolean, onClick: () -> Unit ) { + val backgroundColor = if (isCurrentChapter) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + } + + val textColor = if (isCurrentChapter) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + + val iconTint = if (isCurrentChapter) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val contentAlpha = if (isCompleted && !isCurrentChapter) 0.5f else 1f + Row( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(vertical = 12.dp, horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - when { - isCurrentChapter -> { + Box( + modifier = Modifier.width(32.dp), + contentAlignment = Alignment.CenterStart + ) { + if (isCurrentChapter) { Icon( - imageVector = Icons.Filled.PlayCircle, - contentDescription = "Currently playing", - tint = MaterialTheme.colorScheme.primary + imageVector = Icons.Filled.Equalizer, + contentDescription = "Playing", + tint = iconTint, + modifier = Modifier.size(20.dp) ) - } - - isCompleted -> { + } else if (isCompleted) { Icon( - imageVector = Icons.Filled.CheckCircle, + imageVector = Icons.Filled.Check, contentDescription = "Completed", - tint = MaterialTheme.colorScheme.outline + tint = iconTint.copy(alpha = 0.5f), + modifier = Modifier.size(18.dp) ) - } - - else -> { + } else { Text( text = index.toString(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(24.dp) + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + fontWeight = FontWeight.Medium, + modifier = Modifier.align(Alignment.Center) ) } } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(8.dp)) - Column( - modifier = Modifier.weight(1f) - ) { + Column(modifier = Modifier.weight(1f)) { Text( text = chapter.title, style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = if (isCurrentChapter) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface + fontWeight = if (isCurrentChapter) FontWeight.Bold else FontWeight.Normal, + color = textColor.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(16.dp)) Text( text = formatChapterDuration(chapter.end - chapter.start), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (isCurrentChapter) textColor.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = contentAlpha + ), + fontWeight = FontWeight.Medium ) } } @@ -131,4 +161,4 @@ private fun formatChapterDuration(seconds: Double): String { val minutes = totalSeconds / 60 val secs = totalSeconds % 60 return "${minutes}:${secs.toString().padStart(2, '0')}" -} +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt index 4a56dd26..177a5549 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt @@ -1,23 +1,28 @@ package com.makd.afinity.ui.audiobookshelf.item.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.makd.afinity.data.models.audiobookshelf.PodcastEpisode @@ -32,22 +37,20 @@ fun EpisodeList( onEpisodePlay: (PodcastEpisode) -> Unit, modifier: Modifier = Modifier ) { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = "Episodes (${episodes.size})", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - - episodes.forEachIndexed { index, episode -> - EpisodeItem( - episode = episode, - onClick = { onEpisodeClick(episode) }, - onPlay = { onEpisodePlay(episode) } - ) - - if (index < episodes.size - 1) { - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + episodes.forEach { episode -> + EpisodeItem( + episode = episode, + onClick = { onEpisodeClick(episode) }, + onPlay = { onEpisodePlay(episode) } + ) } } } @@ -62,8 +65,9 @@ private fun EpisodeItem( Row( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(vertical = 12.dp, horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Column( @@ -72,13 +76,15 @@ private fun EpisodeItem( Text( text = episode.title, style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) - Row { + Row(verticalAlignment = Alignment.CenterVertically) { episode.publishedAt?.let { timestamp -> Text( text = formatDate(timestamp), @@ -88,8 +94,15 @@ private fun EpisodeItem( } episode.duration?.let { duration -> + if (episode.publishedAt != null) { + Text( + text = " • ", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Text( - text = " \u2022 ${formatDuration(duration)}", + text = formatDuration(duration), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -97,24 +110,32 @@ private fun EpisodeItem( } episode.description?.let { description -> - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(6.dp)) Text( text = description, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.2 ) } } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(12.dp)) - IconButton(onClick = onPlay) { + FilledIconButton( + onClick = onPlay, + modifier = Modifier.size(40.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.primary + ) + ) { Icon( imageVector = Icons.Filled.PlayArrow, contentDescription = "Play episode", - tint = MaterialTheme.colorScheme.primary + modifier = Modifier.size(24.dp) ) } } @@ -135,4 +156,4 @@ private fun formatDuration(seconds: Double): String { } else { "${minutes}m" } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt index 9ffd7fdf..3f73ac0e 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt @@ -1,34 +1,57 @@ package com.makd.afinity.ui.audiobookshelf.item.components +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade import com.makd.afinity.data.models.audiobookshelf.LibraryItem import com.makd.afinity.data.models.audiobookshelf.MediaProgress -import kotlin.time.Duration.Companion.seconds +import com.makd.afinity.ui.utils.htmlToAnnotatedString @Composable fun ItemHeader( @@ -38,28 +61,106 @@ fun ItemHeader( onPlay: () -> Unit, modifier: Modifier = Modifier ) { - Column(modifier = modifier.fillMaxWidth()) { + val coverUrl = if (serverUrl != null && item.media.coverPath != null) { + "$serverUrl/api/items/${item.id}/cover" + } else null + + Box(modifier = modifier.fillMaxWidth()) { + Box(modifier = Modifier + .fillMaxWidth() + .height(450.dp)) { + ItemHeroBackground(coverUrl = coverUrl) + } + + ItemHeaderContent( + item = item, + progress = progress, + coverUrl = coverUrl, + onPlay = onPlay + ) + } +} + +@Composable +fun ItemHeroBackground( + coverUrl: String?, + modifier: Modifier = Modifier +) { + if (coverUrl != null) { + Box(modifier = modifier.fillMaxSize()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(coverUrl) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .blur(radius = 30.dp) + .background(Color.Black), + alpha = 0.6f, + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.surface + ), + startY = 100f + ) + ) + ) + } + } else { + Box(modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface)) + } +} + +@Composable +fun ItemHeaderContent( + item: LibraryItem, + progress: MediaProgress?, + coverUrl: String?, + onPlay: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.statusBarsPadding()) + Spacer(modifier = Modifier.height(140.dp)) + Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom ) { - val coverUrl = if (serverUrl != null && item.media.coverPath != null) { - "$serverUrl/api/items/${item.id}/cover" - } else null - if (coverUrl != null) { AsyncImage( model = coverUrl, contentDescription = "Cover", modifier = Modifier - .size(140.dp) - .clip(MaterialTheme.shapes.medium), + .width(160.dp) + .aspectRatio(1f) + .shadow(12.dp, RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)), contentScale = ContentScale.Crop ) } else { Card( - modifier = Modifier.size(140.dp), + modifier = Modifier + .width(160.dp) + .aspectRatio(1f), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest ) @@ -69,127 +170,196 @@ fun ItemHeader( Spacer(modifier = Modifier.width(16.dp)) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .padding(bottom = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { + item.media.metadata.seriesName?.let { series -> + Text( + text = series.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + Text( text = item.media.metadata.title ?: "Unknown Title", - style = MaterialTheme.typography.titleLarge, - maxLines = 2, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 3, overflow = TextOverflow.Ellipsis ) item.media.metadata.authorName?.let { author -> Text( - text = "by $author", + text = author, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium ) } - item.media.metadata.narratorName?.let { narrator -> - Text( - text = "Narrated by $narrator", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + item.media.duration?.let { duration -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = formatDuration(duration), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - item.media.metadata.seriesName?.let { series -> - Text( - text = series, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } + if (progress != null && progress.progress > 0) { + Text( + text = " • ", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.width(4.dp)) - item.media.duration?.let { duration -> - Text( - text = formatDuration(duration), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } + CircularProgressIndicator( + progress = { progress.progress.toFloat() }, + modifier = Modifier.size(12.dp), + color = Color(0xFFFFC107), + strokeWidth = 2.dp, + trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + ) - progress?.let { prog -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - LinearProgressIndicator( - progress = { prog.progress.toFloat() }, - modifier = Modifier.fillMaxWidth(), - trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ) + Spacer(modifier = Modifier.width(6.dp)) - Spacer(modifier = Modifier.height(4.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = formatDuration(prog.currentTime), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "${(prog.progress * 100).toInt()}% complete", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = formatDuration(prog.duration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + val remainingSeconds = progress.duration - progress.currentTime + Text( + text = "${formatDuration(remainingSeconds)} left", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + fontWeight = FontWeight.SemiBold + ) + } + } } } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) Button( onClick = onPlay, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .height(56.dp), + shape = RoundedCornerShape(28.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp) ) { - Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (progress != null && progress.progress > 0) "Continue Listening" else "Play", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + item.media.metadata.description?.let { description -> + ExpandableSynopsis(description = description) + } + } +} + +@Composable +private fun ExpandableSynopsis( + description: String, + modifier: Modifier = Modifier +) { + var isExpanded by remember { mutableStateOf(false) } + var isEllipsized by remember { mutableStateOf(false) } + + val containsHtml = remember(description) { + description.contains("<", ignoreCase = true) && + (description.contains("href=", ignoreCase = true) || + description.contains("", ignoreCase = true) || + description.contains("", ignoreCase = true)) + } + + val linkColor = MaterialTheme.colorScheme.primary + val annotatedText = remember(description, linkColor) { + if (containsHtml) htmlToAnnotatedString(description, linkColor) else null + } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Synopsis", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + + val textStyle = MaterialTheme.typography.bodyMedium + val textColor = MaterialTheme.colorScheme.onSurfaceVariant + val lineHeight = 20.sp + val maxLines = if (isExpanded) Int.MAX_VALUE else 4 + val overflow = if (isExpanded) TextOverflow.Visible else TextOverflow.Ellipsis + val animModifier = Modifier.animateContentSize() + + if (containsHtml && annotatedText != null) { + Text( + text = annotatedText, + style = textStyle, + color = textColor, + lineHeight = lineHeight, + maxLines = maxLines, + overflow = overflow, + modifier = animModifier, + onTextLayout = { result -> + if (!isExpanded) isEllipsized = result.hasVisualOverflow + } ) - Spacer(modifier = Modifier.width(8.dp)) + } else { Text( - text = if (progress != null && progress.progress > 0) "Continue" else "Play" + text = description, + style = textStyle, + color = textColor, + lineHeight = lineHeight, + maxLines = maxLines, + overflow = overflow, + modifier = animModifier, + onTextLayout = { result -> + if (!isExpanded) isEllipsized = result.hasVisualOverflow + } ) } - Spacer(modifier = Modifier.height(16.dp)) - - item.media.metadata.description?.let { description -> - Column( + if (isEllipsized || isExpanded) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (isExpanded) "Show Less" else "Show More", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Text( - text = "Description", - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { isExpanded = !isExpanded } + .padding(vertical = 4.dp) + ) } } } @@ -198,11 +368,5 @@ private fun formatDuration(seconds: Double): String { val totalSeconds = seconds.toLong() val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 - val secs = totalSeconds % 60 - - return if (hours > 0) { - "${hours}h ${minutes}m" - } else { - "${minutes}m ${secs}s" - } -} + return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" +} \ No newline at end of file From 926402237a42b0c80d94ff1da9a5eaeccbf67330 Mon Sep 17 00:00:00 2001 From: MakD Date: Thu, 5 Feb 2026 00:35:41 +0530 Subject: [PATCH 07/19] feat(UI): Display narrator in audiobook item header This commit enhances the item details screen for audiobooks by adding the narrator's name to the header section. It also slightly adjusts the author's name display by prepending it with "by " for better clarity. ### Key Changes: * **`ItemHeader.kt`**: * Added a new `Text` component to display "Narrated by: [Narrator Name]" when the narrator information is available. * `buildAnnotatedString` is used to style "Narrated by: " differently from the narrator's name itself. * The author's name is now prefixed with "by " (e.g., "by John Doe"). --- .../item/components/ItemHeader.kt | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt index 3f73ac0e..404b381f 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt @@ -42,8 +42,11 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage @@ -194,7 +197,7 @@ fun ItemHeaderContent( item.media.metadata.authorName?.let { author -> Text( - text = author, + text = "by $author", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium @@ -278,6 +281,33 @@ fun ItemHeaderContent( item.media.metadata.description?.let { description -> ExpandableSynopsis(description = description) } + + Spacer(modifier = Modifier.height(8.dp)) + + item.media.metadata.narratorName?.let { narrator -> + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Normal + ) + ) { + append("Narrated by: ") + } + + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + ) { + append(narrator) + } + }, + style = MaterialTheme.typography.bodyMedium + ) + } } } From ff0bd9bff048904a497f79862cb5cbe47cd34fc4 Mon Sep 17 00:00:00 2001 From: MakD Date: Thu, 5 Feb 2026 17:48:03 +0530 Subject: [PATCH 08/19] feat(audiobook): Add background playback and media notifications This commit introduces background playback for audiobooks using a `MediaSessionService`, allowing playback to continue when the app is not in the foreground. It also adds media notifications with playback controls. To support this, the app now requests the `POST_NOTIFICATIONS` permission on Android 13 (TIRAMISU) and higher to display the media controls. A `WAKE_LOCK` permission has also been added to ensure uninterrupted playback. ### Key Changes: * **`AudiobookshelfPlayerService`**: A new `MediaSessionService` that manages the `ExoPlayer` instance and integrates with the Android media system. * **Media Notifications**: The service displays a media style notification with playback controls (play/pause, etc.) and artwork. * **Permission Handling**: * `MainActivity` now requests the `POST_NOTIFICATIONS` permission on startup for Android 13+. * The `WAKE_LOCK` permission was added to `AndroidManifest.xml`. * **`AudiobookshelfPlayer`**: * Now starts `AudiobookshelfPlayerService` upon initialization. * `ExoPlayer` is configured with `WAKE_MODE_NETWORK` to maintain network connectivity during playback. * Media metadata (title, author, artwork) is now passed to `ExoPlayer` to be displayed in the media notification. * **Lifecycle Management**: Improved player and service lifecycle handling to prevent crashes and ensure resources are released correctly. --- app/src/main/AndroidManifest.xml | 1 + .../java/com/makd/afinity/MainActivity.kt | 29 +++++++++++++ .../audiobookshelf/AudiobookshelfPlayer.kt | 30 ++++++++++++- .../AudiobookshelfPlayerService.kt | 42 ++++++++++++------- 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c1cba647..c9697f67 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/app/src/main/java/com/makd/afinity/MainActivity.kt b/app/src/main/java/com/makd/afinity/MainActivity.kt index 15217fcb..ae118e59 100644 --- a/app/src/main/java/com/makd/afinity/MainActivity.kt +++ b/app/src/main/java/com/makd/afinity/MainActivity.kt @@ -1,9 +1,13 @@ package com.makd.afinity +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -16,6 +20,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -33,6 +38,7 @@ import com.makd.afinity.ui.theme.AFinityTheme import com.makd.afinity.ui.theme.ThemeMode import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint @@ -49,10 +55,21 @@ class MainActivity : ComponentActivity() { @Inject lateinit var updateScheduler: UpdateScheduler + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + Timber.d("Notification permission granted") + } else { + Timber.w("Notification permission denied - Media controls will not show") + } + } + override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() + checkNotificationPermission() WindowCompat.setDecorFitsSystemWindows(window, false) @@ -97,6 +114,18 @@ class MainActivity : ComponentActivity() { } } } + + private fun checkNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } } @Composable diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt index d942bcf7..2d251f6e 100644 --- a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt @@ -1,7 +1,9 @@ package com.makd.afinity.player.audiobookshelf import android.content.Context +import android.content.Intent import androidx.annotation.OptIn +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -22,6 +24,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import androidx.core.net.toUri @Singleton class AudiobookshelfPlayer @Inject constructor( @@ -31,7 +34,8 @@ class AudiobookshelfPlayer @Inject constructor( private val audiobookshelfRepository: AudiobookshelfRepository, private val securePreferencesRepository: SecurePreferencesRepository ) { - private var exoPlayer: ExoPlayer? = null + var exoPlayer: ExoPlayer? = null + private set private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var positionUpdateJob: Job? = null private var sleepTimerJob: Job? = null @@ -108,12 +112,20 @@ class AudiobookshelfPlayer @Inject constructor( exoPlayer = ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) .build() .apply { addListener(playerListener) playWhenReady = false } + try { + val intent = Intent(context, AudiobookshelfPlayerService::class.java) + context.startService(intent) + } catch (e: Exception) { + Timber.e(e, "Failed to start AudiobookshelfPlayerService") + } + Timber.d("AudiobookshelfPlayer initialized") } @@ -135,9 +147,13 @@ class AudiobookshelfPlayer @Inject constructor( } suspend fun loadSession(session: PlaybackSession, baseUrl: String) { - release() + stopPositionUpdates() + cancelSleepTimer() initialize() + exoPlayer?.stop() + exoPlayer?.clearMediaItems() + currentSession = session serverUrl = baseUrl @@ -157,9 +173,19 @@ class AudiobookshelfPlayer @Inject constructor( "$baseUrl${track.contentUrl}" } + val artUrl = "$baseUrl/api/items/${session.libraryItemId}/cover?token=${securePreferencesRepository.getCachedAudiobookshelfToken()}" + + val metadata = androidx.media3.common.MediaMetadata.Builder() + .setTitle(session.displayTitle) + .setArtist(session.displayAuthor) + .setArtworkUri(artUrl.toUri()) + .setDisplayTitle(session.displayTitle) + .build() + MediaItem.Builder() .setUri(url) .setMediaId(track.index.toString()) + .setMediaMetadata(metadata) .build() } diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt index 9b15f70c..d540b9e9 100644 --- a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt @@ -3,14 +3,19 @@ package com.makd.afinity.player.audiobookshelf import android.app.PendingIntent import android.content.Intent import androidx.annotation.OptIn -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.makd.afinity.MainActivity +import com.makd.afinity.R +import com.makd.afinity.util.NetworkConnectivityMonitor import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -23,22 +28,21 @@ class AudiobookshelfPlayerService : MediaSessionService() { @Inject lateinit var playbackManager: AudiobookshelfPlaybackManager + @Inject + lateinit var networkConnectivityMonitor: NetworkConnectivityMonitor + private var mediaSession: MediaSession? = null + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + @OptIn(UnstableApi::class) override fun onCreate() { super.onCreate() - val player = ExoPlayer.Builder(this) - .setAudioAttributes( - AudioAttributes.Builder() - .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) - .setUsage(C.USAGE_MEDIA) - .build(), - true - ) - .setHandleAudioBecomingNoisy(true) - .build() + audiobookshelfPlayer.initialize() + + val player = audiobookshelfPlayer.exoPlayer + ?: throw IllegalStateException("Player must be initialized") val sessionActivityPendingIntent = PendingIntent.getActivity( this, @@ -51,6 +55,16 @@ class AudiobookshelfPlayerService : MediaSessionService() { .setSessionActivity(sessionActivityPendingIntent) .build() + val notificationProvider = DefaultMediaNotificationProvider(this) + notificationProvider.setSmallIcon(R.drawable.ic_headphones_filled) + setMediaNotificationProvider(notificationProvider) + + serviceScope.launch { + networkConnectivityMonitor.isNetworkAvailable.collect { isConnected -> + Timber.d("Service Network Monitor: Connected = $isConnected") + } + } + Timber.d("AudiobookshelfPlayerService created") } @@ -60,12 +74,12 @@ class AudiobookshelfPlayerService : MediaSessionService() { override fun onDestroy() { mediaSession?.run { - player.release() release() } mediaSession = null Timber.d("AudiobookshelfPlayerService destroyed") + serviceScope.cancel() super.onDestroy() } From 1357a5d631ee13f32f4f317897114939b225c8ae Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 04:15:04 +0530 Subject: [PATCH 09/19] refactor(player): Redesign Audiobookshelf player UI and underlying service This commit introduces a complete visual and architectural overhaul of the Audiobookshelf player. The player screen now features a modern, media-centric design with dynamic background colors based on cover art. It also refactors the playback logic to use a `MediaSessionService` for more robust background audio handling and system integration. ### Key Changes: * **feat(player): Redesigned Player UI** * The player screen (`AudiobookshelfPlayerScreen.kt`) is fully redesigned with a dynamic, blurred background that adapts to the dominant color of the audiobook cover. * A new layout is implemented for both portrait and landscape orientations. * The playback controls, sleep timer, playback speed, and chapter selection dialogs have been restyled for a more modern and intuitive user experience. * **refactor(player): Implement `MediaSessionService`** * Playback is now managed by `AudiobookshelfPlayerService`, a `MediaSessionService`, instead of being directly controlled within the `AudiobookshelfPlayer` class. * This change improves background playback stability, enables system media integrations (like lock screen controls), and follows Android best practices. * `AudiobookshelfPlayer` now acts as a controller, delegating playback commands to the service via `MediaController`. * **chore: Replace Material Icons with custom vector drawables** * Removed the `material-icons-extended` dependency. * Added a set of custom vector drawable icons to replace the previously used Material Icons, reducing dependency overhead and providing a more consistent visual style. * **build: Add `androidx.palette` dependency** * The `androidx.palette:palette-ktx` library was added to enable the extraction of dominant colors from cover art for the new dynamic UI. --- app/build.gradle.kts | 2 +- .../audiobookshelf/AudiobookshelfPlayer.kt | 357 ++++------- .../AudiobookshelfPlayerService.kt | 160 ++++- .../item/AudiobookshelfItemScreen.kt | 9 +- .../item/components/ChapterList.kt | 9 +- .../item/components/EpisodeList.kt | 6 +- .../item/components/ItemHeader.kt | 6 +- .../libraries/components/LibraryCard.kt | 14 +- .../library/AudiobookshelfLibraryScreen.kt | 23 +- .../library/components/PodcastCard.kt | 6 +- .../login/AudiobookshelfLoginScreen.kt | 18 +- .../player/AudiobookshelfPlayerScreen.kt | 580 +++++++++++++----- .../player/AudiobookshelfPlayerViewModel.kt | 1 - .../player/components/ChapterSelector.kt | 147 +++-- .../player/components/MiniPlayer.kt | 12 +- .../components/PlaybackSpeedSelector.kt | 208 +++++-- .../player/components/PlayerControls.kt | 170 +++-- .../player/components/SleepTimerDialog.kt | 205 ++++--- .../player/util/DominantColorState.kt | 57 ++ .../main/res/drawable/ic_apple_podcast.xml | 24 + app/src/main/res/drawable/ic_book.xml | 24 + app/src/main/res/drawable/ic_clock_off.xml | 24 + .../main/res/drawable/ic_clock_outline.xml | 18 + app/src/main/res/drawable/ic_minus.xml | 18 + app/src/main/res/drawable/ic_minus_alt.xml | 12 + app/src/main/res/drawable/ic_minus_filled.xml | 9 + app/src/main/res/drawable/ic_moon.xml | 12 + app/src/main/res/drawable/ic_moon_filled.xml | 9 + app/src/main/res/drawable/ic_options.xml | 24 + app/src/main/res/drawable/ic_playlist_alt.xml | 36 ++ app/src/main/res/drawable/ic_plus_alt.xml | 18 + .../res/drawable/ic_rewind_backward_30.xml | 30 + app/src/main/res/drawable/ic_timer.xml | 24 + app/src/main/res/drawable/ic_timer_off.xml | 36 ++ app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 3 +- 36 files changed, 1554 insertions(+), 758 deletions(-) create mode 100644 app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/util/DominantColorState.kt create mode 100644 app/src/main/res/drawable/ic_apple_podcast.xml create mode 100644 app/src/main/res/drawable/ic_book.xml create mode 100644 app/src/main/res/drawable/ic_clock_off.xml create mode 100644 app/src/main/res/drawable/ic_clock_outline.xml create mode 100644 app/src/main/res/drawable/ic_minus.xml create mode 100644 app/src/main/res/drawable/ic_minus_alt.xml create mode 100644 app/src/main/res/drawable/ic_minus_filled.xml create mode 100644 app/src/main/res/drawable/ic_moon.xml create mode 100644 app/src/main/res/drawable/ic_moon_filled.xml create mode 100644 app/src/main/res/drawable/ic_options.xml create mode 100644 app/src/main/res/drawable/ic_playlist_alt.xml create mode 100644 app/src/main/res/drawable/ic_plus_alt.xml create mode 100644 app/src/main/res/drawable/ic_rewind_backward_30.xml create mode 100644 app/src/main/res/drawable/ic_timer.xml create mode 100644 app/src/main/res/drawable/ic_timer_off.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48952d0c..eaf4ed18 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -124,13 +124,13 @@ dependencies { implementation(libs.androidx.hilt.work) implementation(libs.androidx.ui) ksp(libs.androidx.hilt.compiler) + implementation(libs.androidx.palette.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3.window.size.class1) implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.compose.m3) diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt index 2d251f6e..dd2dd59d 100644 --- a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt @@ -1,16 +1,14 @@ package com.makd.afinity.player.audiobookshelf +import android.content.ComponentName import android.content.Context -import android.content.Intent -import androidx.annotation.OptIn -import androidx.media3.common.C +import android.net.Uri import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.common.MediaMetadata +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors import com.makd.afinity.data.models.audiobookshelf.PlaybackSession import com.makd.afinity.data.repository.AudiobookshelfRepository import com.makd.afinity.data.repository.SecurePreferencesRepository @@ -21,225 +19,119 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton -import androidx.core.net.toUri +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException @Singleton class AudiobookshelfPlayer @Inject constructor( @ApplicationContext private val context: Context, private val playbackManager: AudiobookshelfPlaybackManager, - private val progressSyncer: AudiobookshelfProgressSyncer, - private val audiobookshelfRepository: AudiobookshelfRepository, - private val securePreferencesRepository: SecurePreferencesRepository + private val securePreferencesRepository: SecurePreferencesRepository, + private val audiobookshelfRepository: AudiobookshelfRepository ) { - var exoPlayer: ExoPlayer? = null - private set + private var mediaController: MediaController? = null + private var controllerFuture: ListenableFuture? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var positionUpdateJob: Job? = null private var sleepTimerJob: Job? = null - private var currentSession: PlaybackSession? = null - private var serverUrl: String? = null - - private val playerListener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - playbackManager.updatePlayingState(isPlaying) - - if (isPlaying) { - startPositionUpdates() - progressSyncer.startSyncing() - } else { - stopPositionUpdates() - scope.launch { - progressSyncer.syncNow() - } - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - Player.STATE_BUFFERING -> { - playbackManager.updateBufferingState(true) - } - - Player.STATE_READY -> { - playbackManager.updateBufferingState(false) - } - - Player.STATE_ENDED -> { - playbackManager.updatePlayingState(false) - stopPositionUpdates() - scope.launch { - progressSyncer.syncNow() - audiobookshelfRepository.updateProgress( - itemId = currentSession?.libraryItemId ?: return@launch, - episodeId = currentSession?.episodeId, - currentTime = playbackManager.playbackState.value.duration, - duration = playbackManager.playbackState.value.duration, - isFinished = true - ) - } - } + private suspend fun getConnectedController(): MediaController? { + if (mediaController != null) return mediaController - Player.STATE_IDLE -> { - } - } - } + val sessionToken = SessionToken( + context, + ComponentName(context, AudiobookshelfPlayerService::class.java) + ) - override fun onPlayerError(error: PlaybackException) { - Timber.e(error, "Player error: ${error.message}") - playbackManager.updatePlayingState(false) - playbackManager.updateBufferingState(false) - } - } - - @OptIn(UnstableApi::class) - fun initialize() { - if (exoPlayer != null) return + val future = MediaController.Builder(context, sessionToken).buildAsync() + controllerFuture = future - val token = securePreferencesRepository.getCachedAudiobookshelfToken() - val dataSourceFactory = DefaultHttpDataSource.Factory() - .setDefaultRequestProperties( - buildMap { - if (token != null) { - put("Authorization", "Bearer $token") - } - } - ) - - exoPlayer = ExoPlayer.Builder(context) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_NETWORK) - .build() - .apply { - addListener(playerListener) - playWhenReady = false - } - - try { - val intent = Intent(context, AudiobookshelfPlayerService::class.java) - context.startService(intent) + return try { + mediaController = future.await() + Timber.d("MediaController connected to Service") + mediaController } catch (e: Exception) { - Timber.e(e, "Failed to start AudiobookshelfPlayerService") + Timber.e(e, "Failed to connect MediaController") + null } - - Timber.d("AudiobookshelfPlayer initialized") } - fun release() { - stopPositionUpdates() - progressSyncer.stopSyncing() - cancelSleepTimer() - - exoPlayer?.apply { - removeListener(playerListener) - release() - } - exoPlayer = null + fun loadSession(session: PlaybackSession, baseUrl: String) { + scope.launch { + val controller = getConnectedController() ?: return@launch - playbackManager.clearSession() - currentSession = null - - Timber.d("AudiobookshelfPlayer released") - } - - suspend fun loadSession(session: PlaybackSession, baseUrl: String) { - stopPositionUpdates() - cancelSleepTimer() - initialize() + val token = securePreferencesRepository.getCachedAudiobookshelfToken() + playbackManager.setSession(session, baseUrl, token) - exoPlayer?.stop() - exoPlayer?.clearMediaItems() - - currentSession = session - serverUrl = baseUrl - - val token = securePreferencesRepository.getCachedAudiobookshelfToken() - playbackManager.setSession(session, baseUrl, token) - - val audioTracks = session.audioTracks - if (audioTracks.isNullOrEmpty()) { - Timber.e("No audio tracks in session") - return - } - - val mediaItems = audioTracks.map { track -> - val url = if (track.contentUrl?.startsWith("http") == true) { - track.contentUrl - } else { - "$baseUrl${track.contentUrl}" + val audioTracks = session.audioTracks + if (audioTracks.isNullOrEmpty()) { + Timber.e("No audio tracks found in session") + return@launch } - val artUrl = "$baseUrl/api/items/${session.libraryItemId}/cover?token=${securePreferencesRepository.getCachedAudiobookshelfToken()}" + val mediaItems = audioTracks.map { track -> + val url = if (track.contentUrl?.startsWith("http") == true) track.contentUrl + else "$baseUrl${track.contentUrl}" + + val artUrl = if (baseUrl.isNotEmpty()) { + "$baseUrl/api/items/${session.libraryItemId}/cover?token=$token" + } else session.coverPath + + val metadata = MediaMetadata.Builder() + .setTitle(session.displayTitle) + .setArtist(session.displayAuthor) + .setArtworkUri(Uri.parse(artUrl)) + .build() + + MediaItem.Builder() + .setUri(url) + .setMediaId(track.index.toString()) + .setMediaMetadata(metadata) + .build() + } - val metadata = androidx.media3.common.MediaMetadata.Builder() - .setTitle(session.displayTitle) - .setArtist(session.displayAuthor) - .setArtworkUri(artUrl.toUri()) - .setDisplayTitle(session.displayTitle) - .build() + Timber.d("Sending ${mediaItems.size} items to player") - MediaItem.Builder() - .setUri(url) - .setMediaId(track.index.toString()) - .setMediaMetadata(metadata) - .build() - } + controller.stop() + controller.clearMediaItems() + controller.setMediaItems(mediaItems) + controller.prepare() - exoPlayer?.apply { - setMediaItems(mediaItems) - prepare() - val startTime = session.currentTime - if (startTime > 0) { - seekToPosition(startTime) + if (session.currentTime > 0) { + seekToPosition(session.currentTime) } + controller.play() } - - Timber.d("Loaded session with ${audioTracks.size} tracks, starting at ${session.currentTime}s") } - fun play() { - exoPlayer?.play() - } + fun play() = mediaController?.play() - fun pause() { - exoPlayer?.pause() - } + fun pause() = mediaController?.pause() + + fun isPlaying(): Boolean = mediaController?.isPlaying == true - fun seekTo(positionMs: Long) { - exoPlayer?.seekTo(positionMs) - updatePosition() + fun setPlaybackSpeed(speed: Float) { + mediaController?.setPlaybackSpeed(speed) + playbackManager.updatePlaybackSpeed(speed) } fun seekToPosition(positionSeconds: Double) { - val audioTracks = currentSession?.audioTracks ?: return + val controller = mediaController ?: return + val audioTracks = playbackManager.currentSession.value?.audioTracks ?: return var accumulatedDuration = 0.0 for ((index, track) in audioTracks.withIndex()) { if (positionSeconds < accumulatedDuration + track.duration) { val positionInTrack = positionSeconds - accumulatedDuration - exoPlayer?.apply { - seekTo(index, (positionInTrack * 1000).toLong()) - } + controller.seekTo(index, (positionInTrack * 1000).toLong()) break } accumulatedDuration += track.duration } - - updatePosition() - } - - fun skipForward(seconds: Int = 30) { - val currentPosition = getCurrentPositionSeconds() - seekToPosition(currentPosition + seconds) - } - - fun skipBackward(seconds: Int = 30) { - val currentPosition = getCurrentPositionSeconds() - seekToPosition((currentPosition - seconds).coerceAtLeast(0.0)) } fun seekToChapter(chapterIndex: Int) { @@ -249,19 +141,22 @@ class AudiobookshelfPlayer @Inject constructor( } } - fun setPlaybackSpeed(speed: Float) { - exoPlayer?.setPlaybackSpeed(speed) - playbackManager.updatePlaybackSpeed(speed) + fun skipForward(seconds: Int = 30) { + val current = playbackManager.playbackState.value.currentTime + seekToPosition(current + seconds) + } + + fun skipBackward(seconds: Int = 30) { + val current = playbackManager.playbackState.value.currentTime + seekToPosition((current - seconds).coerceAtLeast(0.0)) } fun setSleepTimer(durationMinutes: Int) { cancelSleepTimer() - if (durationMinutes <= 0) { playbackManager.setSleepTimer(null) return } - val endTime = System.currentTimeMillis() + (durationMinutes * 60 * 1000L) playbackManager.setSleepTimer(endTime) @@ -269,10 +164,8 @@ class AudiobookshelfPlayer @Inject constructor( delay(durationMinutes * 60 * 1000L) pause() playbackManager.setSleepTimer(null) - Timber.d("Sleep timer triggered - pausing playback") + Timber.d("Sleep timer triggered") } - - Timber.d("Sleep timer set for $durationMinutes minutes") } fun cancelSleepTimer() { @@ -281,61 +174,43 @@ class AudiobookshelfPlayer @Inject constructor( playbackManager.setSleepTimer(null) } - fun getCurrentPositionSeconds(): Double { - val player = exoPlayer ?: return 0.0 - val audioTracks = currentSession?.audioTracks ?: return 0.0 - var totalPosition = 0.0 - val currentMediaItemIndex = player.currentMediaItemIndex - - for (i in 0 until currentMediaItemIndex) { - totalPosition += audioTracks.getOrNull(i)?.duration ?: 0.0 - } - - totalPosition += player.currentPosition / 1000.0 - - return totalPosition - } - - private fun startPositionUpdates() { - stopPositionUpdates() + fun closeSession() { + cancelSleepTimer() + mediaController?.stop() + mediaController?.clearMediaItems() - positionUpdateJob = scope.launch { - while (true) { - updatePosition() - delay(1000) - } - } - } + controllerFuture?.let { MediaController.releaseFuture(it) } + mediaController = null + controllerFuture = null - private fun stopPositionUpdates() { - positionUpdateJob?.cancel() - positionUpdateJob = null + playbackManager.clearSession() + Timber.d("Session closed") } - private fun updatePosition() { - val position = getCurrentPositionSeconds() - playbackManager.updatePosition(position) + fun release() { + closeSession() } +} - fun isPlaying(): Boolean = exoPlayer?.isPlaying == true - - suspend fun closeSession() { - val state = playbackManager.playbackState.value - val sessionId = state.sessionId ?: return - - progressSyncer.stopSyncing() +private suspend fun ListenableFuture.await(): T { + return suspendCancellableCoroutine { continuation -> + addListener( + { + try { + continuation.resume(get()) + } catch (e: Exception) { + if (isCancelled) { + continuation.cancel(e) + } else { + continuation.resumeWithException(e) + } + } + }, + MoreExecutors.directExecutor() + ) - try { - audiobookshelfRepository.closePlaybackSession( - sessionId = sessionId, - currentTime = state.currentTime, - timeListened = 0.0, - duration = state.duration - ) - } catch (e: Exception) { - Timber.e(e, "Failed to close session") + continuation.invokeOnCancellation { + cancel(false) } - - playbackManager.clearSession() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt index d540b9e9..438f1202 100644 --- a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayerService.kt @@ -3,18 +3,31 @@ package com.makd.afinity.player.audiobookshelf import android.app.PendingIntent import android.content.Intent import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import com.makd.afinity.MainActivity import com.makd.afinity.R +import com.makd.afinity.data.repository.SecurePreferencesRepository import com.makd.afinity.util.NetworkConnectivityMonitor import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -22,72 +35,163 @@ import javax.inject.Inject @AndroidEntryPoint class AudiobookshelfPlayerService : MediaSessionService() { - @Inject - lateinit var audiobookshelfPlayer: AudiobookshelfPlayer - @Inject lateinit var playbackManager: AudiobookshelfPlaybackManager - + @Inject + lateinit var progressSyncer: AudiobookshelfProgressSyncer + @Inject + lateinit var securePreferencesRepository: SecurePreferencesRepository @Inject lateinit var networkConnectivityMonitor: NetworkConnectivityMonitor private var mediaSession: MediaSession? = null + private var exoPlayer: ExoPlayer? = null private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var positionUpdateJob: Job? = null @OptIn(UnstableApi::class) override fun onCreate() { super.onCreate() - audiobookshelfPlayer.initialize() + val token = securePreferencesRepository.getCachedAudiobookshelfToken() + val dataSourceFactory = DefaultHttpDataSource.Factory() + .setAllowCrossProtocolRedirects(true) + .setDefaultRequestProperties(buildMap { + if (token != null) put("Authorization", "Bearer $token") + }) + + exoPlayer = ExoPlayer.Builder(this) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .build() - val player = audiobookshelfPlayer.exoPlayer - ?: throw IllegalStateException("Player must be initialized") + exoPlayer?.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + playbackManager.updatePlayingState(isPlaying) + if (isPlaying) { + startPositionUpdates() + progressSyncer.startSyncing() + } else { + stopPositionUpdates() + serviceScope.launch { progressSyncer.syncNow() } + progressSyncer.stopSyncing() + } + } - val sessionActivityPendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> playbackManager.updateBufferingState(true) + Player.STATE_READY -> playbackManager.updateBufferingState(false) + Player.STATE_ENDED -> { + playbackManager.updatePlayingState(false) + stopPositionUpdates() + serviceScope.launch { progressSyncer.syncNow() } + } + + else -> {} + } + } + + override fun onPlayerError(error: PlaybackException) { + Timber.e(error, "Player error") + playbackManager.updatePlayingState(false) + } + }) + + val sessionIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, sessionIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) - mediaSession = MediaSession.Builder(this, player) - .setSessionActivity(sessionActivityPendingIntent) + mediaSession = MediaSession.Builder(this, exoPlayer!!) + .setSessionActivity(pendingIntent) + .setCallback(CustomMediaSessionCallback()) .build() - val notificationProvider = DefaultMediaNotificationProvider(this) - notificationProvider.setSmallIcon(R.drawable.ic_headphones_filled) - setMediaNotificationProvider(notificationProvider) + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setChannelId("afinity_audiobook_playback") + .setChannelName(R.string.playback_channel_name) + .build().apply { + setSmallIcon(R.drawable.ic_launcher_monochrome) + } + ) + } - serviceScope.launch { - networkConnectivityMonitor.isNetworkAvailable.collect { isConnected -> - Timber.d("Service Network Monitor: Connected = $isConnected") - } + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession + } + + @UnstableApi + private inner class CustomMediaSessionCallback : MediaSession.Callback { + @UnstableApi + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() } - Timber.d("AudiobookshelfPlayerService created") + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture { + Timber.d("System requested playback resumption") + return Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition( + emptyList(), + 0, + 0L + ) + ) + } } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - return mediaSession + private fun startPositionUpdates() { + stopPositionUpdates() + positionUpdateJob = serviceScope.launch { + while (isActive) { + val player = exoPlayer ?: break + + val currentMediaItemIndex = player.currentMediaItemIndex + val audioTracks = playbackManager.playbackState.value.audioTracks + var totalPosition = 0.0 + + for (i in 0 until currentMediaItemIndex) { + totalPosition += audioTracks.getOrNull(i)?.duration ?: 0.0 + } + totalPosition += player.currentPosition / 1000.0 + + playbackManager.updatePosition(totalPosition) + delay(1000) + } + } + } + + private fun stopPositionUpdates() { + positionUpdateJob?.cancel() + positionUpdateJob = null } override fun onDestroy() { mediaSession?.run { + player.release() release() + mediaSession = null } - mediaSession = null - - Timber.d("AudiobookshelfPlayerService destroyed") + stopPositionUpdates() serviceScope.cancel() super.onDestroy() } override fun onTaskRemoved(rootIntent: Intent?) { val player = mediaSession?.player - if (player == null || !player.playWhenReady || player.mediaItemCount == 0) { stopSelf() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt index 9826c5ca..661ae6f7 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/AudiobookshelfItemScreen.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -38,10 +36,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.R import com.makd.afinity.ui.audiobookshelf.item.components.ChapterList import com.makd.afinity.ui.audiobookshelf.item.components.EpisodeList import com.makd.afinity.ui.audiobookshelf.item.components.ItemHeader @@ -247,8 +247,9 @@ fun AudiobookshelfItemScreen( ) ) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + painter = painterResource(id = R.drawable.ic_chevron_left), + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground ) } diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt index 33837cb6..54c73c14 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ChapterList.kt @@ -12,9 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Equalizer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -23,9 +20,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.makd.afinity.R import com.makd.afinity.data.models.audiobookshelf.BookChapter @Composable @@ -107,14 +106,14 @@ private fun ChapterItem( ) { if (isCurrentChapter) { Icon( - imageVector = Icons.Filled.Equalizer, + painter = painterResource(id = R.drawable.ic_audio), contentDescription = "Playing", tint = iconTint, modifier = Modifier.size(20.dp) ) } else if (isCompleted) { Icon( - imageVector = Icons.Filled.Check, + painter = painterResource(id = R.drawable.ic_check), contentDescription = "Completed", tint = iconTint.copy(alpha = 0.5f), modifier = Modifier.size(18.dp) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt index 177a5549..068392dd 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/EpisodeList.kt @@ -11,8 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults @@ -22,9 +20,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.makd.afinity.R import com.makd.afinity.data.models.audiobookshelf.PodcastEpisode import java.text.SimpleDateFormat import java.util.Date @@ -133,7 +133,7 @@ private fun EpisodeItem( ) ) { Icon( - imageVector = Icons.Filled.PlayArrow, + painter = painterResource(id = R.drawable.ic_player_play_filled), contentDescription = "Play episode", modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt index 404b381f..6e7e5e58 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/item/components/ItemHeader.kt @@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -42,6 +40,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -52,6 +51,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import com.makd.afinity.R import com.makd.afinity.data.models.audiobookshelf.LibraryItem import com.makd.afinity.data.models.audiobookshelf.MediaProgress import com.makd.afinity.ui.utils.htmlToAnnotatedString @@ -263,7 +263,7 @@ fun ItemHeaderContent( horizontalArrangement = Arrangement.Center ) { Icon( - imageVector = Icons.Filled.PlayArrow, + painter = painterResource(id = R.drawable.ic_player_play_filled), contentDescription = null, modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt index 8d5427fd..2e3e1a1d 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/components/LibraryCard.kt @@ -8,10 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Podcasts import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -20,7 +16,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.makd.afinity.R import com.makd.afinity.data.models.audiobookshelf.Library @Composable @@ -44,9 +42,9 @@ fun LibraryCard( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = when (library.mediaType.lowercase()) { - "podcast" -> Icons.Filled.Podcasts - else -> Icons.AutoMirrored.Filled.MenuBook + painter = when (library.mediaType.lowercase()) { + "podcast" -> painterResource(id = R.drawable.ic_apple_podcast) + else -> painterResource(id = R.drawable.ic_book) }, contentDescription = null, modifier = Modifier.size(40.dp), @@ -84,7 +82,7 @@ fun LibraryCard( } Icon( - imageVector = Icons.Filled.ChevronRight, + painter = painterResource(id = R.drawable.ic_chevron_right), contentDescription = "Open library", tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt index a710bc58..8b1183f6 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/AudiobookshelfLibraryScreen.kt @@ -17,10 +17,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -37,9 +33,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.R import com.makd.afinity.data.models.audiobookshelf.LibraryItem import com.makd.afinity.ui.audiobookshelf.library.components.AudiobookCard import com.makd.afinity.ui.audiobookshelf.library.components.PodcastCard @@ -66,7 +64,7 @@ fun AudiobookshelfLibraryScreen( navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + painterResource(id = R.drawable.ic_chevron_left), contentDescription = "Back" ) } @@ -84,15 +82,24 @@ fun AudiobookshelfLibraryScreen( onValueChange = viewModel::search, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding( + horizontal = 16.dp, + vertical = 8.dp + ), placeholder = { Text("Search...") }, leadingIcon = { - Icon(Icons.Filled.Search, contentDescription = null) + Icon( + painterResource(id = R.drawable.ic_search), + contentDescription = null + ) }, trailingIcon = { if (uiState.searchQuery.isNotEmpty()) { IconButton(onClick = viewModel::clearSearch) { - Icon(Icons.Filled.Clear, contentDescription = "Clear search") + Icon( + painterResource(id = R.drawable.ic_clear), + contentDescription = "Clear search" + ) } } else if (uiState.isSearching) { CircularProgressIndicator( diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt index f894196a..4ac368b6 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/library/components/PodcastCard.kt @@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -19,9 +17,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.makd.afinity.R import com.makd.afinity.data.models.audiobookshelf.LibraryItem @Composable @@ -98,7 +98,7 @@ fun PodcastCard( } Icon( - imageVector = Icons.Filled.ChevronRight, + painterResource(id = R.drawable.ic_chevron_right), contentDescription = "Open podcast", tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt index 9f9aba8a..62a986c7 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/login/AudiobookshelfLoginScreen.kt @@ -17,12 +17,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -46,6 +40,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -53,6 +48,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.R import com.makd.afinity.data.repository.AudiobookshelfConfig @OptIn(ExperimentalMaterial3Api::class) @@ -79,7 +75,7 @@ fun AudiobookshelfLoginScreen( navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + painterResource(id = R.drawable.ic_chevron_left), contentDescription = "Back" ) } @@ -159,7 +155,7 @@ private fun ConnectedCard( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Filled.CheckCircle, + painterResource(id = R.drawable.ic_circle_check), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp) @@ -255,7 +251,7 @@ private fun LoginForm( trailingIcon = { if (uiState.connectionTestSuccess) { Icon( - imageVector = Icons.Filled.Check, + painterResource(id = R.drawable.ic_check), contentDescription = "Connected", tint = MaterialTheme.colorScheme.primary ) @@ -298,7 +294,9 @@ private fun LoginForm( trailingIcon = { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( - imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + painter = if (passwordVisible) painterResource(id = R.drawable.ic_visibility) else painterResource( + id = R.drawable.ic_visibility_off + ), contentDescription = if (passwordVisible) "Hide password" else "Show password" ) } diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt index ae4fb33b..76686907 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerScreen.kt @@ -1,39 +1,34 @@ package com.makd.afinity.ui.audiobookshelf.player +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Bedtime -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -42,18 +37,25 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import coil3.compose.AsyncImage +import com.makd.afinity.R +import com.makd.afinity.player.audiobookshelf.AudiobookshelfPlaybackState import com.makd.afinity.ui.audiobookshelf.player.components.ChapterSelector import com.makd.afinity.ui.audiobookshelf.player.components.PlaybackSpeedSelector import com.makd.afinity.ui.audiobookshelf.player.components.PlayerControls import com.makd.afinity.ui.audiobookshelf.player.components.SleepTimerDialog +import com.makd.afinity.ui.audiobookshelf.player.util.rememberDominantColor @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -63,177 +65,61 @@ fun AudiobookshelfPlayerScreen( ) { val uiState by viewModel.uiState.collectAsState() val playbackState by viewModel.playbackState.collectAsState() - - val snackbarHostState = remember { SnackbarHostState() } + val defaultColor = MaterialTheme.colorScheme.surface + val dominantColor = rememberDominantColor(playbackState.coverUrl, defaultColor) + val animatedColor by animateColorAsState( + targetValue = dominantColor, + animationSpec = tween(durationMillis = 800), + label = "color" + ) + LaunchedEffect(uiState.error) { - uiState.error?.let { error -> - snackbarHostState.showSnackbar(error) + uiState.error?.let { + snackbarHostState.showSnackbar(it) viewModel.clearError() } } - val coverUrl = playbackState.coverUrl + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE Scaffold( - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - }, - topBar = { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.Filled.ExpandMore, - contentDescription = "Minimize player" - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ) - ) - }, - containerColor = MaterialTheme.colorScheme.surface + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + containerColor = Color.Transparent ) { paddingValues -> Box( modifier = Modifier .fillMaxSize() - .padding(paddingValues) + .background( + Brush.verticalGradient( + colors = listOf( + animatedColor.copy(alpha = 0.8f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.1f), + Color.Black.copy(alpha = 0.9f) + ) + ) + ) ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) + if (isLandscape) { + LandscapePlayerContent( + playbackState = playbackState, + viewModel = viewModel, + animatedColor = animatedColor, + onNavigateBack = onNavigateBack, + paddingValues = paddingValues ) } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Box( - modifier = Modifier - .fillMaxWidth(0.7f) - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - if (coverUrl != null) { - AsyncImage( - model = coverUrl, - contentDescription = playbackState.displayTitle, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = playbackState.displayTitle.ifEmpty { "Unknown Title" }, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - - Spacer(modifier = Modifier.height(4.dp)) - - if (playbackState.displayAuthor != null) { - Text( - text = playbackState.displayAuthor!!, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - if (playbackState.currentChapter != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = playbackState.currentChapter!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - PlayerControls( - currentTime = playbackState.currentTime, - duration = playbackState.duration, - isPlaying = playbackState.isPlaying, - isBuffering = playbackState.isBuffering, - onPlayPauseClick = viewModel::togglePlayPause, - onSkipForward = viewModel::skipForward, - onSkipBackward = viewModel::skipBackward, - onSeek = viewModel::seekTo, - onPreviousChapter = if (playbackState.chapters.isNotEmpty() && - playbackState.currentChapterIndex > 0 - ) { - { viewModel.seekToChapter(playbackState.currentChapterIndex - 1) } - } else null, - onNextChapter = if (playbackState.chapters.isNotEmpty() && - playbackState.currentChapterIndex < playbackState.chapters.lastIndex - ) { - { viewModel.seekToChapter(playbackState.currentChapterIndex + 1) } - } else null - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - if (playbackState.chapters.isNotEmpty()) { - IconButton(onClick = viewModel::showChapterSelector) { - Icon( - imageVector = Icons.Filled.List, - contentDescription = "Chapters" - ) - } - } - - TextButton(onClick = viewModel::showSpeedSelector) { - Icon( - imageVector = Icons.Filled.Speed, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = "${playbackState.playbackSpeed}x") - } - - IconButton(onClick = viewModel::showSleepTimerDialog) { - Icon( - imageVector = Icons.Filled.Bedtime, - contentDescription = "Sleep timer", - tint = if (playbackState.sleepTimerEndTime != null) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - } - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - } + PortraitPlayerContent( + playbackState = playbackState, + viewModel = viewModel, + animatedColor = animatedColor, + onNavigateBack = onNavigateBack, + paddingValues = paddingValues + ) } } - if (uiState.showChapterSelector) { ChapterSelector( chapters = playbackState.chapters, @@ -261,3 +147,363 @@ fun AudiobookshelfPlayerScreen( } } } + +@Composable +fun PortraitPlayerContent( + playbackState: AudiobookshelfPlaybackState, + viewModel: AudiobookshelfPlayerViewModel, + animatedColor: Color, + onNavigateBack: () -> Unit, + paddingValues: PaddingValues +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onNavigateBack) { + Icon( + painterResource(id = R.drawable.ic_keyboard_arrow_down), + contentDescription = "Minimize", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + + Text( + "NOW PLAYING", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.7f), + letterSpacing = 2.sp + ) + + IconButton(onClick = { /* Option Menu */ }) { + Icon( + painterResource(id = R.drawable.ic_options), + contentDescription = "Options", + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .shadow( + elevation = 24.dp, + shape = RoundedCornerShape(32.dp), + spotColor = Color.Black + ), + shape = RoundedCornerShape(32.dp), + color = Color.Transparent + ) { + if (playbackState.coverUrl != null) { + AsyncImage( + model = playbackState.coverUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = playbackState.displayTitle.ifEmpty { "Unknown Title" }, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.ExtraBold + ), + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = playbackState.displayAuthor ?: "Unknown Author", + style = MaterialTheme.typography.titleMedium, + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (playbackState.currentChapter != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playbackState.currentChapter!!.title, + style = MaterialTheme.typography.labelLarge, + color = animatedColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + PlayerControls( + currentTime = playbackState.currentTime, + duration = playbackState.duration, + isPlaying = playbackState.isPlaying, + isBuffering = playbackState.isBuffering, + onPlayPauseClick = viewModel::togglePlayPause, + onSkipForward = viewModel::skipForward, + onSkipBackward = viewModel::skipBackward, + onSeek = viewModel::seekTo, + onPreviousChapter = if (playbackState.chapters.isNotEmpty() && playbackState.currentChapterIndex > 0) { + { viewModel.seekToChapter(playbackState.currentChapterIndex - 1) } + } else null, + onNextChapter = if (playbackState.chapters.isNotEmpty() && playbackState.currentChapterIndex < playbackState.chapters.lastIndex) { + { viewModel.seekToChapter(playbackState.currentChapterIndex + 1) } + } else null, + accentColor = animatedColor + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier + .fillMaxWidth(0.5f) + .padding(bottom = 50.dp) + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.1f)) + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = viewModel::showSpeedSelector) { + Box(contentAlignment = Alignment.Center) { + Icon( + painterResource(id = R.drawable.ic_speed), + null, + tint = Color.White.copy(alpha = 0.8f) + ) + if (playbackState.playbackSpeed != 1.0f) { + Box( + modifier = Modifier + .size(4.dp) + .background( + Color.White, + androidx.compose.foundation.shape.CircleShape + ) + .align(Alignment.TopEnd) + ) + } + } + } + + IconButton(onClick = viewModel::showSleepTimerDialog) { + Icon( + painter = if (playbackState.sleepTimerEndTime != null) painterResource( + id = R.drawable.ic_moon_filled + ) else painterResource( + id = R.drawable.ic_moon + ), + contentDescription = null, + tint = if (playbackState.sleepTimerEndTime != null) animatedColor else Color.White.copy( + alpha = 0.8f + ) + ) + } + + IconButton(onClick = viewModel::showChapterSelector) { + Icon( + painterResource(id = R.drawable.ic_playlist_alt), + null, + tint = Color.White.copy(alpha = 0.8f) + ) + } + } + } +} + +@Composable +fun LandscapePlayerContent( + playbackState: AudiobookshelfPlaybackState, + viewModel: AudiobookshelfPlayerViewModel, + animatedColor: Color, + onNavigateBack: () -> Unit, + paddingValues: PaddingValues +) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 32.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(0.45f) + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .aspectRatio(1f) + .shadow( + elevation = 16.dp, + shape = RoundedCornerShape(24.dp), + spotColor = Color.Black + ), + shape = RoundedCornerShape(24.dp), + color = Color.Transparent + ) { + if (playbackState.coverUrl != null) { + AsyncImage( + model = playbackState.coverUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + Column( + modifier = Modifier + .weight(0.55f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onNavigateBack) { + Icon( + painterResource(id = R.drawable.ic_keyboard_arrow_down), + "Minimize", + tint = Color.White + ) + } + Text( + "NOW PLAYING", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.7f), + letterSpacing = 2.sp + ) + IconButton(onClick = { }) { + Icon( + painterResource(id = R.drawable.ic_options), + "Options", + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = playbackState.displayTitle.ifEmpty { "Unknown Title" }, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = playbackState.displayAuthor ?: "Unknown Author", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (playbackState.currentChapter != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playbackState.currentChapter!!.title, + style = MaterialTheme.typography.labelLarge, + color = animatedColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + PlayerControls( + currentTime = playbackState.currentTime, + duration = playbackState.duration, + isPlaying = playbackState.isPlaying, + isBuffering = playbackState.isBuffering, + onPlayPauseClick = viewModel::togglePlayPause, + onSkipForward = viewModel::skipForward, + onSkipBackward = viewModel::skipBackward, + onSeek = viewModel::seekTo, + onPreviousChapter = if (playbackState.chapters.isNotEmpty() && playbackState.currentChapterIndex > 0) { + { viewModel.seekToChapter(playbackState.currentChapterIndex - 1) } + } else null, + onNextChapter = if (playbackState.chapters.isNotEmpty() && playbackState.currentChapterIndex < playbackState.chapters.lastIndex) { + { viewModel.seekToChapter(playbackState.currentChapterIndex + 1) } + } else null, + accentColor = animatedColor + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier + .fillMaxWidth(0.5f) + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.1f)) + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + IconButton(onClick = viewModel::showSpeedSelector) { + Icon( + painterResource(id = R.drawable.ic_speed), + null, + tint = Color.White.copy(alpha = 0.8f) + ) + } + IconButton(onClick = viewModel::showSleepTimerDialog) { + Icon( + if (playbackState.sleepTimerEndTime != null) painterResource(id = R.drawable.ic_moon_filled) else painterResource( + id = R.drawable.ic_moon + ), + null, + tint = if (playbackState.sleepTimerEndTime != null) animatedColor else Color.White.copy( + alpha = 0.8f + ) + ) + } + IconButton(onClick = viewModel::showChapterSelector) { + Icon( + painterResource(id = R.drawable.ic_playlist_alt), + null, + tint = Color.White.copy(alpha = 0.8f) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt index 47203c3f..b0c1bcb5 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/AudiobookshelfPlayerViewModel.kt @@ -100,7 +100,6 @@ class AudiobookshelfPlayerViewModel @Inject constructor( fun setPlaybackSpeed(speed: Float) { audiobookshelfPlayer.setPlaybackSpeed(speed) - dismissSpeedSelector() } fun setSleepTimer(minutes: Int) { diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt index 120d4738..180288fb 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt @@ -1,20 +1,23 @@ package com.makd.afinity.ui.audiobookshelf.player.components +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -24,8 +27,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.makd.afinity.R import com.makd.afinity.data.models.audiobookshelf.BookChapter @OptIn(ExperimentalMaterial3Api::class) @@ -42,46 +51,47 @@ fun ChapterSelector( LaunchedEffect(currentChapterIndex) { if (currentChapterIndex >= 0 && currentChapterIndex < chapters.size) { - listState.animateScrollToItem(currentChapterIndex) + listState.scrollToItem((currentChapterIndex - 3).coerceAtLeast(0)) } } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - modifier = modifier + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + dragHandle = null ) { Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 32.dp) + .fillMaxHeight(0.85f) + .padding(top = 24.dp) ) { Text( - text = "Chapters", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + text = "CHAPTERS", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + letterSpacing = 2.sp ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(24.dp)) LazyColumn( state = listState, - modifier = Modifier.weight(1f, fill = false) + modifier = Modifier.weight(1f), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 32.dp) ) { itemsIndexed(chapters) { index, chapter -> - ChapterItem( + val isCurrent = index == currentChapterIndex + ChapterRow( chapter = chapter, - index = index, - isCurrentChapter = index == currentChapterIndex, + index = index + 1, + isCurrent = isCurrent, onClick = { onChapterSelected(index) } ) - - if (index < chapters.lastIndex) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - } } } } @@ -89,81 +99,100 @@ fun ChapterSelector( } @Composable -private fun ChapterItem( +private fun ChapterRow( chapter: BookChapter, index: Int, - isCurrentChapter: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier + isCurrent: Boolean, + onClick: () -> Unit ) { Row( - modifier = modifier + modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .background( + if (isCurrent) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.surface + ) + .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - if (isCurrentChapter) { - Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = "Currently playing", - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background( + if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainer + ), + contentAlignment = Alignment.Center + ) { + if (isCurrent) { + Icon( + painterResource(id = R.drawable.ic_speed), + contentDescription = "Playing", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp) + ) + } else { + Text( + text = index.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { Text( text = chapter.title, - style = MaterialTheme.typography.bodyLarge, - color = if (isCurrentChapter) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 2, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium + ), + color = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "${formatTime(chapter.start)} - ${formatTime(chapter.end)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { Text( - text = formatChapterDuration(chapter.start, chapter.end), - style = MaterialTheme.typography.bodySmall, + text = formatDuration(chapter.end - chapter.start), + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } -private fun formatChapterDuration(start: Double, end: Double): String { - val startFormatted = formatTime(start) - val duration = end - start - val durationFormatted = formatDuration(duration) - return "$startFormatted • $durationFormatted" -} - private fun formatTime(seconds: Double): String { val totalSeconds = seconds.toLong().coerceAtLeast(0) val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 val secs = totalSeconds % 60 - - return if (hours > 0) { - String.format("%d:%02d:%02d", hours, minutes, secs) - } else { - String.format("%d:%02d", minutes, secs) - } + return if (hours > 0) String.format("%d:%02d:%02d", hours, minutes, secs) else String.format("%d:%02d", minutes, secs) } private fun formatDuration(seconds: Double): String { val totalSeconds = seconds.toLong().coerceAtLeast(0) val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 - return when { hours > 0 -> "${hours}h ${minutes}m" minutes > 0 -> "${minutes}m" else -> "${totalSeconds}s" } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt index 6d8c2e74..5e85b8b0 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/MiniPlayer.kt @@ -12,10 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -28,9 +24,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.makd.afinity.R @Composable fun MiniPlayer( @@ -120,7 +118,9 @@ fun MiniPlayer( ) } else { Icon( - imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + painter = if (isPlaying) painterResource(id = R.drawable.ic_player_pause_filled) else painterResource( + id = R.drawable.ic_player_play_filled + ), contentDescription = if (isPlaying) "Pause" else "Play", modifier = Modifier.size(28.dp) ) @@ -129,7 +129,7 @@ fun MiniPlayer( IconButton(onClick = onCloseClick) { Icon( - imageVector = Icons.Filled.Close, + painter = painterResource(id = R.drawable.ic_close), contentDescription = "Close player", modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt index d470f76d..62eece0a 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlaybackSpeedSelector.kt @@ -1,28 +1,50 @@ package com.makd.afinity.ui.audiobookshelf.player.components +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.makd.afinity.R +import kotlin.math.roundToInt -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaybackSpeedSelector( currentSpeed: Float, @@ -32,81 +54,171 @@ fun PlaybackSpeedSelector( ) { val sheetState = rememberModalBottomSheetState() - val speeds = listOf( - 0.5f, 0.75f, 1.0f, 1.1f, 1.25f, 1.5f, 1.75f, 2.0f, 2.5f, 3.0f - ) + val presets = listOf(0.5f, 0.75f, 1.0f, 1.1f, 1.25f, 1.5f, 1.75f, 2.0f, 2.5f, 3.0f) + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + val index = presets.indexOfFirst { it == currentSpeed } + if (index != -1) { + listState.scrollToItem((index - 1).coerceAtLeast(0)) + } + } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - modifier = modifier + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + dragHandle = null ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) + .padding(24.dp) + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Playback Speed", - style = MaterialTheme.typography.titleLarge + text = "PLAYBACK SPEED", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + letterSpacing = 2.sp + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "${formatSpeed(currentSpeed)}x", + style = MaterialTheme.typography.displayMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(32.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + FilledIconButton( + onClick = { + val prev = presets.lastOrNull { it < currentSpeed - 0.01f } ?: presets.first() + onSpeedSelected(prev) + }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_minus), + contentDescription = "Previous preset" + ) + } + + Slider( + value = currentSpeed, + onValueChange = { value -> + val snapped = (value * 20).roundToInt() / 20f + onSpeedSelected(snapped) + }, + valueRange = presets.first()..presets.last(), + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.onSurface, + activeTrackColor = MaterialTheme.colorScheme.onSurface, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + FilledIconButton( + onClick = { + val next = presets.firstOrNull { it > currentSpeed + 0.01f } ?: presets.last() + onSpeedSelected(next) + }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = "Next preset" + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + LazyRow( + state = listState, + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() ) { - speeds.forEach { speed -> - SpeedChip( + items(presets) { speed -> + PresetSpeedItem( speed = speed, isSelected = speed == currentSpeed, onClick = { onSpeedSelected(speed) } ) } } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Current: ${formatSpeed(currentSpeed)}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } } @Composable -private fun SpeedChip( +private fun PresetSpeedItem( speed: Float, isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier + onClick: () -> Unit ) { - FilterChip( - selected = isSelected, - onClick = onClick, - label = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .width(64.dp) + .height(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (isSelected) MaterialTheme.colorScheme.onSurface + else Color.Transparent + ) + .border( + BorderStroke( + 1.dp, + if (isSelected) Color.Transparent + else MaterialTheme.colorScheme.outlineVariant + ), + RoundedCornerShape(8.dp) + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { Text( text = formatSpeed(speed), - style = MaterialTheme.typography.labelLarge + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + color = if (isSelected) MaterialTheme.colorScheme.inverseOnSurface + else MaterialTheme.colorScheme.onSurface ) - }, - modifier = modifier, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) + } + + Spacer(modifier = Modifier.height(8.dp)) + if (speed == 1.0f) { + Text( + text = "Normal", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text(text = " ", style = MaterialTheme.typography.labelSmall) + } + } } private fun formatSpeed(speed: Float): String { - return if (speed == speed.toLong().toFloat()) { - "${speed.toLong()}x" - } else { - "${speed}x" - } + return if (speed == speed.toLong().toFloat()) "${speed.toLong()}" else "$speed" } diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt index f185de2e..a2f7a8d3 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/PlayerControls.kt @@ -1,26 +1,26 @@ package com.makd.afinity.ui.audiobookshelf.player.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Forward30 -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Replay30 -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,8 +29,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.makd.afinity.R +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlayerControls( currentTime: Double, @@ -43,48 +49,72 @@ fun PlayerControls( onSeek: (Double) -> Unit, onPreviousChapter: (() -> Unit)? = null, onNextChapter: (() -> Unit)? = null, + accentColor: Color = MaterialTheme.colorScheme.primary, modifier: Modifier = Modifier ) { var sliderPosition by remember(currentTime) { mutableFloatStateOf(currentTime.toFloat()) } var isDragging by remember { mutableFloatStateOf(0f) } - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Slider( - value = if (isDragging > 0) sliderPosition else currentTime.toFloat(), - onValueChange = { value -> - sliderPosition = value - isDragging = 1f - }, - onValueChangeFinished = { - onSeek(sliderPosition.toDouble()) - isDragging = 0f - }, - valueRange = 0f..duration.toFloat().coerceAtLeast(1f), - modifier = Modifier.fillMaxWidth() - ) + Column(modifier = modifier.fillMaxWidth()) { + Box(contentAlignment = Alignment.CenterStart) { + Slider( + value = if (isDragging > 0) sliderPosition else currentTime.toFloat(), + onValueChange = { value -> + sliderPosition = value + isDragging = 1f + }, + onValueChangeFinished = { + onSeek(sliderPosition.toDouble()) + isDragging = 0f + }, + valueRange = 0f..duration.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color.White, + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ), + thumb = { + Box( + modifier = Modifier + .size(16.dp) + .background(Color.White, CircleShape) + ) + }, + track = { sliderState -> + SliderDefaults.Track( + sliderState = sliderState, + modifier = Modifier.height(2.dp), + thumbTrackGapSize = 0.dp, + colors = SliderDefaults.colors( + activeTrackColor = Color.White, + inactiveTrackColor = Color.White.copy(alpha = 0.2f) + ), + drawStopIndicator = null + ) + } + ) + } Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = formatTime(currentTime), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = Color.White.copy(alpha = 0.7f) ) Text( - text = "-${formatTime(duration - currentTime)}", + text = formatTime(duration), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = Color.White.copy(alpha = 0.7f) ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -96,44 +126,53 @@ fun PlayerControls( enabled = onPreviousChapter != null ) { Icon( - imageVector = Icons.Filled.SkipPrevious, - contentDescription = "Previous chapter", - modifier = Modifier.size(32.dp) + painterResource(id = R.drawable.ic_player_skip_back), + null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(28.dp) ) } IconButton(onClick = onSkipBackward) { Icon( - imageVector = Icons.Filled.Replay30, - contentDescription = "Skip backward 30 seconds", - modifier = Modifier.size(40.dp) + painterResource(id = R.drawable.ic_rewind_backward_30), + null, + tint = Color.White, + modifier = Modifier.size(34.dp) ) } - FilledIconButton( + Surface( onClick = onPlayPauseClick, - modifier = Modifier.size(72.dp) + shape = RoundedCornerShape(28.dp), + color = Color.White, + modifier = Modifier.size(76.dp), + shadowElevation = 8.dp ) { - if (isBuffering) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 3.dp - ) - } else { - Icon( - imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp) - ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = accentColor, + strokeWidth = 3.dp + ) + } else { + Icon( + painter = if (isPlaying) painterResource(id = R.drawable.ic_player_pause_filled) else painterResource(id = R.drawable.ic_player_play_filled), + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(38.dp) + ) + } } } IconButton(onClick = onSkipForward) { Icon( - imageVector = Icons.Filled.Forward30, - contentDescription = "Skip forward 30 seconds", - modifier = Modifier.size(40.dp) + painterResource(id = R.drawable.ic_rewind_forward_30), + null, + tint = Color.White, + modifier = Modifier.size(34.dp) ) } @@ -142,9 +181,10 @@ fun PlayerControls( enabled = onNextChapter != null ) { Icon( - imageVector = Icons.Filled.SkipNext, - contentDescription = "Next chapter", - modifier = Modifier.size(32.dp) + painterResource(id = R.drawable.ic_player_skip_forward), + null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(28.dp) ) } } @@ -156,10 +196,10 @@ private fun formatTime(seconds: Double): String { val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 val secs = totalSeconds % 60 - - return if (hours > 0) { - String.format("%d:%02d:%02d", hours, minutes, secs) - } else { - String.format("%d:%02d", minutes, secs) - } -} + return if (hours > 0) String.format( + "%d:%02d:%02d", + hours, + minutes, + secs + ) else String.format("%d:%02d", minutes, secs) +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt index 5293b51c..a720ac31 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/SleepTimerDialog.kt @@ -1,19 +1,25 @@ package com.makd.afinity.ui.audiobookshelf.player.components +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Timer -import androidx.compose.material.icons.filled.TimerOff +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -22,7 +28,14 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.makd.afinity.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -35,130 +48,128 @@ fun SleepTimerDialog( ) { val sheetState = rememberModalBottomSheetState() - val timerOptions = listOf( - 5 to "5 minutes", - 10 to "10 minutes", - 15 to "15 minutes", - 30 to "30 minutes", - 45 to "45 minutes", - 60 to "1 hour", - 90 to "1 hour 30 minutes", - 120 to "2 hours" - ) + val timerOptions = listOf(5, 10, 15, 30, 45, 60, 90, 120) val isTimerActive = currentTimerEndTime != null && currentTimerEndTime > System.currentTimeMillis() val remainingMinutes = if (isTimerActive && currentTimerEndTime != null) { ((currentTimerEndTime - System.currentTimeMillis()) / 60000).toInt().coerceAtLeast(1) - } else { - null - } + } else null ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - modifier = modifier + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + dragHandle = null ) { Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 32.dp) + .padding(24.dp) + .padding(bottom = 24.dp) ) { Text( - text = "Sleep Timer", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + text = "SLEEP TIMER", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + letterSpacing = 2.sp ) - if (isTimerActive && remainingMinutes != null) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.Timer, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "Timer active: $remainingMinutes min remaining", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - - Row( + if (isTimerActive && remainingMinutes != null) { + Box( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onCancelTimer) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(20.dp) ) { - Icon( - imageVector = Icons.Filled.TimerOff, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "Cancel timer", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(id = R.drawable.ic_timer), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "$remainingMinutes min remaining", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onCancelTimer, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon(painterResource(id = R.drawable.ic_timer_off), null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Stop Timer") + } + } } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant - ) - + Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Or set a new timer:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + "Set New Timer", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface ) + Spacer(modifier = Modifier.height(12.dp)) } - Spacer(modifier = Modifier.height(8.dp)) - - timerOptions.forEach { (minutes, label) -> - TimerOptionItem( - label = label, - onClick = { onTimerSelected(minutes) } - ) + LazyVerticalGrid( + columns = GridCells.Fixed(4), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(timerOptions) { minutes -> + TimerTile( + minutes = minutes, + onClick = { onTimerSelected(minutes) } + ) + } } } } } @Composable -private fun TimerOptionItem( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier +private fun TimerTile( + minutes: Int, + onClick: () -> Unit ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier + .aspectRatio(1.2f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Filled.Timer, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = minutes.toString(), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "min", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/util/DominantColorState.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/util/DominantColorState.kt new file mode 100644 index 00000000..dd4f8f7f --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/util/DominantColorState.kt @@ -0,0 +1,57 @@ +package com.makd.afinity.ui.audiobookshelf.player.util + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.palette.graphics.Palette +import androidx.core.graphics.drawable.toBitmap +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.allowHardware +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun rememberDominantColor(url: String?, defaultColor: Color): Color { + var dominantColor by remember { mutableStateOf(defaultColor) } + val context = LocalPlatformContext.current + + LaunchedEffect(url) { + if (url == null) return@LaunchedEffect + + withContext(Dispatchers.IO) { + val loader = SingletonImageLoader.get(context) + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .build() + + val result = loader.execute(request) + + if (result is SuccessResult) { + val image = result.image + val drawable = image.asDrawable(context.resources) + val bitmap = drawable.toBitmap() + + val palette = Palette.from(bitmap).generate() + val swatch = palette.vibrantSwatch + ?: palette.darkVibrantSwatch + ?: palette.mutedSwatch + ?: palette.dominantSwatch + + swatch?.let { + dominantColor = Color(it.rgb) + } + } + } + } + return dominantColor +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_apple_podcast.xml b/app/src/main/res/drawable/ic_apple_podcast.xml new file mode 100644 index 00000000..d5df5b67 --- /dev/null +++ b/app/src/main/res/drawable/ic_apple_podcast.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_book.xml b/app/src/main/res/drawable/ic_book.xml new file mode 100644 index 00000000..8dc7809d --- /dev/null +++ b/app/src/main/res/drawable/ic_book.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_clock_off.xml b/app/src/main/res/drawable/ic_clock_off.xml new file mode 100644 index 00000000..9297410c --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_off.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_clock_outline.xml b/app/src/main/res/drawable/ic_clock_outline.xml new file mode 100644 index 00000000..996f290d --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_outline.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml new file mode 100644 index 00000000..210ea9c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_minus_alt.xml b/app/src/main/res/drawable/ic_minus_alt.xml new file mode 100644 index 00000000..e2180c4f --- /dev/null +++ b/app/src/main/res/drawable/ic_minus_alt.xml @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_minus_filled.xml b/app/src/main/res/drawable/ic_minus_filled.xml new file mode 100644 index 00000000..3b93cad8 --- /dev/null +++ b/app/src/main/res/drawable/ic_minus_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_moon.xml b/app/src/main/res/drawable/ic_moon.xml new file mode 100644 index 00000000..6cff03dc --- /dev/null +++ b/app/src/main/res/drawable/ic_moon.xml @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_moon_filled.xml b/app/src/main/res/drawable/ic_moon_filled.xml new file mode 100644 index 00000000..2b75f8a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_moon_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_options.xml b/app/src/main/res/drawable/ic_options.xml new file mode 100644 index 00000000..8bb8dea9 --- /dev/null +++ b/app/src/main/res/drawable/ic_options.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_playlist_alt.xml b/app/src/main/res/drawable/ic_playlist_alt.xml new file mode 100644 index 00000000..4874cdb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_alt.xml @@ -0,0 +1,36 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_plus_alt.xml b/app/src/main/res/drawable/ic_plus_alt.xml new file mode 100644 index 00000000..faf558c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_alt.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_rewind_backward_30.xml b/app/src/main/res/drawable/ic_rewind_backward_30.xml new file mode 100644 index 00000000..81847b5b --- /dev/null +++ b/app/src/main/res/drawable/ic_rewind_backward_30.xml @@ -0,0 +1,30 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_timer.xml b/app/src/main/res/drawable/ic_timer.xml new file mode 100644 index 00000000..868d051b --- /dev/null +++ b/app/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_timer_off.xml b/app/src/main/res/drawable/ic_timer_off.xml new file mode 100644 index 00000000..377aba6a --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_off.xml @@ -0,0 +1,36 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 601289ca..cf85c35d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -275,6 +275,7 @@ None Track %1$d (%1$dch) + AFinity Audiobook Playback Logo Series Logo diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1a8c2f6..3b2708df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ coilCompose = "3.3.0" composePagerIndicator = "0.0.8" lottieCompose = "6.7.1" material3WindowSizeClass = "1.4.0" +paletteKtx = "1.0.0" richtext = "0.20.0" coreSplashscreen = "1.2.0" jellyfinCore = "1.7.1" @@ -56,6 +57,7 @@ androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Session" } androidx-media3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose", version.ref = "media3" } androidx-media3-ui-compose-material3 = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3" } +androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } blurhash = { module = "com.vanniktech:blurhash", version.ref = "blurhash" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilCompose" } @@ -95,7 +97,6 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } libmpv = { group = "dev.jdtech.mpv", name = "libmpv", version.ref = "libmpv" } From 18df8af9883d6ecb41c7b2fc6ad20237e2af4fda Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 11:12:36 +0530 Subject: [PATCH 10/19] refactor(icons): Update and add new icons for Audiobookshelf UI This commit introduces a set of new icons and updates existing ones to improve the user interface for the Audiobookshelf integration. The changes provide more specific and visually distinct icons for different media types and actions. ### Key Changes: * **New Icons Added**: * `ic_audiobookshelf_light`: A light version of the Audiobookshelf server icon. * `ic_book_audio`: Icon for audiobooks. * `ic_book_series`: Icon for book series. * `ic_collection`: Icon for collections. * **Icon Updates**: * **Audiobookshelf Libraries**: The navigation chips now use more specific icons. * "Home" is now `ic_book_series`. * "Series" is now `ic_collection`. * Library items now dynamically use `ic_book_audio` for books and `ic_apple_podcast` for podcasts. * **Chapter Selector**: The "currently playing" indicator icon has been changed from `ic_speed` to the more intuitive `ic_player_play_filled`. * **Output Icon**: The `ic_arrows_output.xml` drawable has been visually updated. --- .../AudiobookshelfLibrariesScreen.kt | 12 ++++++-- .../player/components/ChapterSelector.kt | 2 +- .../main/res/drawable/ic_arrows_output.xml | 12 ++++---- .../res/drawable/ic_audiobookshelf_light.xml | 9 ++++++ app/src/main/res/drawable/ic_book_audio.xml | 30 +++++++++++++++++++ app/src/main/res/drawable/ic_book_series.xml | 24 +++++++++++++++ app/src/main/res/drawable/ic_collection.xml | 10 +++++++ 7 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable/ic_audiobookshelf_light.xml create mode 100644 app/src/main/res/drawable/ic_book_audio.xml create mode 100644 app/src/main/res/drawable/ic_book_series.xml create mode 100644 app/src/main/res/drawable/ic_collection.xml diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt index 5cd4fb28..9fb22356 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesScreen.kt @@ -140,7 +140,7 @@ fun AudiobookshelfLibrariesScreen( NavigationChip( selected = pagerState.currentPage == 0, label = "Home", - iconResId = R.drawable.ic_home_filled, + iconResId = R.drawable.ic_book_series, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) @@ -152,7 +152,7 @@ fun AudiobookshelfLibrariesScreen( NavigationChip( selected = pagerState.currentPage == 1, label = "Series", - iconResId = R.drawable.ic_books_filled, + iconResId = R.drawable.ic_collection, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) @@ -161,10 +161,16 @@ fun AudiobookshelfLibrariesScreen( ) } itemsIndexed(libraries) { index, library -> + val iconRes = when (library.mediaType.lowercase()) { + "podcast" -> R.drawable.ic_apple_podcast + "book" -> R.drawable.ic_book_audio + else -> R.drawable.ic_book + } + NavigationChip( selected = pagerState.currentPage == index + 2, label = library.name, - iconResId = R.drawable.ic_headphones_filled, + iconResId = iconRes, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index + 2) diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt index 180288fb..4312957a 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/player/components/ChapterSelector.kt @@ -127,7 +127,7 @@ private fun ChapterRow( ) { if (isCurrent) { Icon( - painterResource(id = R.drawable.ic_speed), + painterResource(id = R.drawable.ic_player_play_filled), contentDescription = "Playing", tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(16.dp) diff --git a/app/src/main/res/drawable/ic_arrows_output.xml b/app/src/main/res/drawable/ic_arrows_output.xml index f621f2e5..b55cd786 100644 --- a/app/src/main/res/drawable/ic_arrows_output.xml +++ b/app/src/main/res/drawable/ic_arrows_output.xml @@ -1,9 +1,9 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/ic_audiobookshelf_light.xml b/app/src/main/res/drawable/ic_audiobookshelf_light.xml new file mode 100644 index 00000000..57e5625b --- /dev/null +++ b/app/src/main/res/drawable/ic_audiobookshelf_light.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_book_audio.xml b/app/src/main/res/drawable/ic_book_audio.xml new file mode 100644 index 00000000..c13af002 --- /dev/null +++ b/app/src/main/res/drawable/ic_book_audio.xml @@ -0,0 +1,30 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_book_series.xml b/app/src/main/res/drawable/ic_book_series.xml new file mode 100644 index 00000000..48862cd4 --- /dev/null +++ b/app/src/main/res/drawable/ic_book_series.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_collection.xml b/app/src/main/res/drawable/ic_collection.xml new file mode 100644 index 00000000..932452df --- /dev/null +++ b/app/src/main/res/drawable/ic_collection.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file From a6ddb52f2960c1dad20c10088f72a26857011508 Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 13:38:15 +0530 Subject: [PATCH 11/19] feat(player): Add extensive logging for Audiobookshelf playback This commit introduces detailed logging for Audiobookshelf playback sessions to aid in debugging and improve reliability. It captures information at various stages of the playback process, from session creation to server responses. Additionally, this change enhances support for podcasts by constructing a playlist from all episodes when a podcast series is played, rather than just a single episode. ### Key Changes: * **`AudiobookshelfRepositoryImpl.kt`**: Added detailed logging for playback session responses, including session details on success and error bodies on failure. * **`NetworkModule.kt`**: Implemented an OkHttp interceptor to log the full response body for playback-related network requests (`/play` endpoint). * **`AudiobookshelfPlayer.kt`**: * Enhanced logging when closing a playback session to distinguish between local and server-side actions. * Implemented logic to build a media playlist from all episodes when playing a podcast series from the library view. * **`AudiobookshelfApiService.kt`**: Corrected the return type for the `syncPlaybackSession` API call from `Response` to `Response`, as the endpoint does not return a body. --- .../data/network/AudiobookshelfApiService.kt | 2 +- .../AudiobookshelfRepositoryImpl.kt | 9 +- .../java/com/makd/afinity/di/NetworkModule.kt | 12 ++- .../audiobookshelf/AudiobookshelfPlayer.kt | 102 +++++++++++++++--- 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt b/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt index b2aa1048..cb87a234 100644 --- a/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt +++ b/app/src/main/java/com/makd/afinity/data/network/AudiobookshelfApiService.kt @@ -130,7 +130,7 @@ interface AudiobookshelfApiService { suspend fun syncPlaybackSession( @Path("sessionId") id: String, @Body syncData: MediaProgressSyncData - ): Response + ): Response @POST("api/session/{sessionId}/close") suspend fun closePlaybackSession( diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt index 4b7ab438..6d81a689 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt @@ -663,8 +663,15 @@ class AudiobookshelfRepositoryImpl @Inject constructor( } if (response.isSuccessful && response.body() != null) { - Result.success(response.body()!!) + val session = response.body()!! + Timber.d("Playback session received: id=${session.id}, mediaType=${session.mediaType}") + Timber.d("Session displayTitle=${session.displayTitle}, displayAuthor=${session.displayAuthor}") + Timber.d("Session audioTracks=${session.audioTracks?.size ?: 0}, chapters=${session.chapters?.size ?: 0}") + Timber.d("Session episodeId=${session.episodeId}, duration=${session.duration}") + Result.success(session) } else { + val errorBody = response.errorBody()?.string() + Timber.e("Failed to start session: ${response.code()} - ${response.message()}, body=$errorBody") Result.failure(Exception("Failed to start session: ${response.message()}")) } } catch (e: Exception) { diff --git a/app/src/main/java/com/makd/afinity/di/NetworkModule.kt b/app/src/main/java/com/makd/afinity/di/NetworkModule.kt index 46d41a90..97ae289b 100644 --- a/app/src/main/java/com/makd/afinity/di/NetworkModule.kt +++ b/app/src/main/java/com/makd/afinity/di/NetworkModule.kt @@ -358,7 +358,17 @@ object NetworkModule { } .build() - chain.proceed(newRequest) + val response = chain.proceed(newRequest) + if (newUrl.encodedPath.contains("/play")) { + val responseBody = response.body + val source = responseBody?.source() + source?.request(Long.MAX_VALUE) + val buffer = source?.buffer?.clone() + val responseString = buffer?.readUtf8() + timber.log.Timber.d("ABS Play Response [${response.code}]: $responseString") + } + + response } .build() } diff --git a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt index dd2dd59d..8b68db2e 100644 --- a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlayer.kt @@ -65,31 +65,87 @@ class AudiobookshelfPlayer @Inject constructor( val controller = getConnectedController() ?: return@launch val token = securePreferencesRepository.getCachedAudiobookshelfToken() - playbackManager.setSession(session, baseUrl, token) - - val audioTracks = session.audioTracks - if (audioTracks.isNullOrEmpty()) { - Timber.e("No audio tracks found in session") + val isPodcastWithoutEpisode = + session.audioTracks.isNullOrEmpty() && session.mediaType == "podcast" + val episodes = session.libraryItem?.media?.episodes ?: emptyList() + + val audioTracks = if (isPodcastWithoutEpisode) { + val allTracks = episodes.mapNotNull { episode -> + episode.audioTrack?.let { track -> + track.copy(title = episode.title) + } + } + if (allTracks.isNotEmpty()) { + Timber.d("Loading ${allTracks.size} episodes for podcast playlist") + allTracks + } else { + Timber.e("No audio tracks found in any episodes") + return@launch + } + } else if (session.audioTracks.isNullOrEmpty()) { + Timber.e("No audio tracks found in session. Session data: $session") return@launch + } else { + session.audioTracks } - val mediaItems = audioTracks.map { track -> + val enhancedSession = if (isPodcastWithoutEpisode && episodes.isNotEmpty()) { + var accumulatedTime = 0.0 + val episodeChapters = episodes.mapNotNull { episode -> + episode.audioTrack?.let { + val chapter = com.makd.afinity.data.models.audiobookshelf.BookChapter( + id = episodes.indexOf(episode), + start = accumulatedTime, + end = accumulatedTime + (episode.duration ?: 0.0), + title = episode.title + ) + accumulatedTime += episode.duration ?: 0.0 + chapter + } + } + val totalDuration = episodes.sumOf { it.duration ?: 0.0 } + + session.copy( + audioTracks = audioTracks, + displayTitle = session.mediaMetadata?.title ?: session.displayTitle + ?: "Podcast", + displayAuthor = session.mediaMetadata?.authorName ?: session.displayAuthor, + duration = totalDuration, + chapters = episodeChapters + ) + } else { + session + } + + playbackManager.setSession(enhancedSession, baseUrl, token) + + val mediaItems = audioTracks.mapIndexed { index, track -> val url = if (track.contentUrl?.startsWith("http") == true) track.contentUrl else "$baseUrl${track.contentUrl}" val artUrl = if (baseUrl.isNotEmpty()) { - "$baseUrl/api/items/${session.libraryItemId}/cover?token=$token" - } else session.coverPath + "$baseUrl/api/items/${enhancedSession.libraryItemId}/cover?token=$token" + } else enhancedSession.coverPath + + val itemTitle = if (isPodcastWithoutEpisode && track.title != null) { + track.title + } else { + enhancedSession.displayTitle ?: enhancedSession.mediaMetadata?.title + ?: "Unknown" + } val metadata = MediaMetadata.Builder() - .setTitle(session.displayTitle) - .setArtist(session.displayAuthor) + .setTitle(itemTitle) + .setArtist( + enhancedSession.displayAuthor ?: enhancedSession.mediaMetadata?.authorName + ?: "" + ) .setArtworkUri(Uri.parse(artUrl)) .build() MediaItem.Builder() .setUri(url) - .setMediaId(track.index.toString()) + .setMediaId(index.toString()) .setMediaMetadata(metadata) .build() } @@ -176,6 +232,28 @@ class AudiobookshelfPlayer @Inject constructor( fun closeSession() { cancelSleepTimer() + val state = playbackManager.playbackState.value + val sessionId = state.sessionId + if (sessionId != null) { + scope.launch { + try { + val result = audiobookshelfRepository.closePlaybackSession( + sessionId = sessionId, + currentTime = state.currentTime, + timeListened = state.currentTime, + duration = state.duration + ) + if (result.isSuccess) { + Timber.d("Session closed on server: $sessionId") + } else { + Timber.e("Failed to close session on server: ${result.exceptionOrNull()?.message}") + } + } catch (e: Exception) { + Timber.e(e, "Error closing session on server") + } + } + } + mediaController?.stop() mediaController?.clearMediaItems() @@ -184,7 +262,7 @@ class AudiobookshelfPlayer @Inject constructor( controllerFuture = null playbackManager.clearSession() - Timber.d("Session closed") + Timber.d("Session closed locally") } fun release() { From e720557496f53019078e069485acaf5ffe6e2dc3 Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 13:47:32 +0530 Subject: [PATCH 12/19] fix(audiobookshelf): Fetch all items from paginated endpoints This commit fixes an issue where only the first page of results was being fetched from Audiobookshelf libraries. The `getLibraryItems` and `getSeries` methods have been updated to loop through all available pages, ensuring that all items and series are retrieved and displayed. ### Key Changes: * **`AudiobookshelfRepositoryImpl.kt`**: * Modified `refreshLibraryItems` to repeatedly call the API until all paginated items for a library are fetched. * Updated `getSeriesForLibrary` to similarly fetch all pages of series results. * When refreshing library items, the local database is now cleared only once before the first page is fetched, preventing data loss during the pagination process. --- .../AudiobookshelfRepositoryImpl.kt | 109 ++++++++++++------ 1 file changed, 74 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt index 6d81a689..16ee8a5b 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt @@ -343,37 +343,58 @@ class AudiobookshelfRepositoryImpl @Inject constructor( return@withContext Result.failure(Exception("No network connection")) } - val response = apiService.get().getLibraryItems( - id = libraryId, - limit = limit, - page = page, - include = "progress" - ) + val allItems = mutableListOf() + var currentPage = 0 + var totalFetched = 0 + var total = Int.MAX_VALUE + var isFirstPage = true + + while (totalFetched < total) { + val response = apiService.get().getLibraryItems( + id = libraryId, + limit = limit, + page = currentPage, + include = "progress" + ) - if (response.isSuccessful && response.body() != null) { - val items = response.body()!!.results - val entities = items.map { item -> - item.toEntity(currentServerId, currentUserId.toString()) - } + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + total = body.total + val items = body.results - if (page == 0) { - audiobookshelfDao.deleteItemsByLibrary( - currentServerId, - currentUserId.toString(), - libraryId - ) - } - audiobookshelfDao.insertItems(entities) - items.forEach { item -> - item.userMediaProgress?.let { progress -> - cacheProgress(progress) + val entities = items.map { item -> + item.toEntity(currentServerId, currentUserId.toString()) } - } - Result.success(items) - } else { - Result.failure(Exception("Failed to fetch items: ${response.message()}")) + if (isFirstPage) { + audiobookshelfDao.deleteItemsByLibrary( + currentServerId, + currentUserId.toString(), + libraryId + ) + isFirstPage = false + } + audiobookshelfDao.insertItems(entities) + items.forEach { item -> + item.userMediaProgress?.let { progress -> + cacheProgress(progress) + } + } + + allItems.addAll(items) + totalFetched += items.size + currentPage++ + + Timber.d("Fetched items page $currentPage: ${items.size} items, total: $total") + + if (items.isEmpty()) break + } else { + return@withContext Result.failure(Exception("Failed to fetch items: ${response.message()}")) + } } + + Timber.d("Fetched all ${allItems.size} items for library $libraryId") + Result.success(allItems) } catch (e: Exception) { Timber.e(e, "Failed to refresh library items") Result.failure(e) @@ -464,17 +485,35 @@ class AudiobookshelfRepositoryImpl @Inject constructor( return@withContext Result.failure(Exception("No network connection")) } - val response = apiService.get().getSeries( - id = libraryId, - limit = limit, - page = page - ) + val allSeries = mutableListOf() + var currentPage = 0 + var totalFetched = 0 + var total = Int.MAX_VALUE - if (response.isSuccessful && response.body() != null) { - Result.success(response.body()!!.results) - } else { - Result.failure(Exception("Failed to fetch series: ${response.message()}")) + while (totalFetched < total) { + val response = apiService.get().getSeries( + id = libraryId, + limit = limit, + page = currentPage + ) + + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + total = body.total + allSeries.addAll(body.results) + totalFetched += body.results.size + currentPage++ + + Timber.d("Fetched series page $currentPage: ${body.results.size} items, total: $total") + + if (body.results.isEmpty()) break + } else { + return@withContext Result.failure(Exception("Failed to fetch series: ${response.message()}")) + } } + + Timber.d("Fetched all ${allSeries.size} series for library $libraryId") + Result.success(allSeries) } catch (e: Exception) { Timber.e(e, "Failed to get series for library $libraryId") Result.failure(e) From 56500e5ec7f0728487b3b8e8ad7ed57dea8e21b5 Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 13:57:16 +0530 Subject: [PATCH 13/19] refactor(Audiobookshelf): Sort library items by title and simplify map update This commit introduces two changes to the Audiobookshelf integration. First, it updates the API call for fetching library items to sort the results alphabetically by the media's title (`media.metadata.title`). This ensures a consistent and predictable order for items displayed within a library. Second, it simplifies the code in the `AudiobookshelfLibrariesViewModel` by using the `plusAssign` (`+=`) operator to update the `_libraryItems` map, which is a minor code cleanup. ### Key Changes: * **`AudiobookshelfRepositoryImpl.kt`**: * Added `sort = "media.metadata.title"` to the `getLibraryItems` API call parameters. * **`AudiobookshelfLibrariesViewModel.kt`**: * Changed `_libraryItems.value = _libraryItems.value + (libraryId to items)` to the more concise `_libraryItems.value += (libraryId to items)`. --- .../repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt | 3 ++- .../libraries/AudiobookshelfLibrariesViewModel.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt index 16ee8a5b..5ca4ee70 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AudiobookshelfRepositoryImpl.kt @@ -354,7 +354,8 @@ class AudiobookshelfRepositoryImpl @Inject constructor( id = libraryId, limit = limit, page = currentPage, - include = "progress" + include = "progress", + sort = "media.metadata.title" ) if (response.isSuccessful && response.body() != null) { diff --git a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt index 5644eff9..1ee5810c 100644 --- a/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/audiobookshelf/libraries/AudiobookshelfLibrariesViewModel.kt @@ -183,7 +183,7 @@ class AudiobookshelfLibrariesViewModel @Inject constructor( val result = audiobookshelfRepository.refreshLibraryItems(libraryId) result.fold( onSuccess = { items -> - _libraryItems.value = _libraryItems.value + (libraryId to items) + _libraryItems.value += (libraryId to items) }, onFailure = { error -> Timber.e(error, "Failed to load items for library $libraryId") From ec53e98e9181ae1727b132cc088fc6d87c524e76 Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 15:44:38 +0530 Subject: [PATCH 14/19] refactor(UI): Update icons for Overseerr, Audiobookshelf, and Playback Settings This commit updates several icons throughout the application to improve visual consistency and branding. The previous monochrome icon for Overseerr/Jellyseerr has been replaced with the official, full-color logo. The Audiobookshelf icon has also been updated. Additionally, the stroke width of the playback settings icon has been increased for better visibility. ### Key Changes: * **`ic_request_seerr_dark.xml`**: Deleted the old monochrome icon. * **`ic_seerr_logo.xml`**: Added the new, official color logo for Overseerr/Jellyseerr. * **`SettingsScreen.kt`**: Updated `SettingsSwitchItem` and dialogs for media requests (Jellyseerr) and Audiobookshelf to use the new icons. * **`ic_playback_settings.xml`**: Increased the `strokeWidth` from `1.5` to `1.7` for improved clarity. --- .../afinity/ui/settings/SettingsScreen.kt | 8 +- .../res/drawable/ic_playback_settings.xml | 2 +- .../res/drawable/ic_request_seerr_dark.xml | 9 -- app/src/main/res/drawable/ic_seerr_logo.xml | 137 ++++++++++++++++++ 4 files changed, 142 insertions(+), 14 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_request_seerr_dark.xml create mode 100644 app/src/main/res/drawable/ic_seerr_logo.xml diff --git a/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt b/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt index 4e61794b..ef34f30a 100644 --- a/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/settings/SettingsScreen.kt @@ -231,7 +231,7 @@ fun SettingsScreen( ) SettingsDivider() SettingsSwitchItem( - icon = painterResource(id = R.drawable.ic_request_seerr_dark), + icon = painterResource(id = R.drawable.ic_seerr_logo), title = stringResource(R.string.pref_discovery_requests), subtitle = if (isJellyseerrAuthenticated) stringResource(R.string.discovery_connected) else stringResource( R.string.discovery_connect @@ -244,7 +244,7 @@ fun SettingsScreen( ) SettingsDivider() SettingsSwitchItem( - icon = painterResource(id = R.drawable.ic_headphones), + icon = painterResource(id = R.drawable.ic_audiobookshelf_light), title = stringResource(R.string.pref_audiobookshelf), subtitle = if (isAudiobookshelfAuthenticated) stringResource(R.string.audiobookshelf_connected) else stringResource( R.string.audiobookshelf_connect @@ -644,7 +644,7 @@ private fun AudiobookshelfLogoutConfirmationDialog(onConfirm: () -> Unit, onDism onDismissRequest = onDismiss, icon = { Icon( - painter = painterResource(id = R.drawable.ic_headphones), + painter = painterResource(id = R.drawable.ic_audiobookshelf_light), contentDescription = null, tint = MaterialTheme.colorScheme.primary ) @@ -676,7 +676,7 @@ private fun JellyseerrLogoutConfirmationDialog(onConfirm: () -> Unit, onDismiss: onDismissRequest = onDismiss, icon = { Icon( - painter = painterResource(id = R.drawable.ic_request_seerr_dark), + painter = painterResource(id = R.drawable.ic_seerr_logo), contentDescription = null, tint = MaterialTheme.colorScheme.primary ) diff --git a/app/src/main/res/drawable/ic_playback_settings.xml b/app/src/main/res/drawable/ic_playback_settings.xml index 9fbb5f2b..8a8178ac 100644 --- a/app/src/main/res/drawable/ic_playback_settings.xml +++ b/app/src/main/res/drawable/ic_playback_settings.xml @@ -13,6 +13,6 @@ android:strokeColor="@color/white" android:strokeLineCap="round" android:strokeLineJoin="round" - android:strokeWidth="1.5" + android:strokeWidth="1.7" android:pathData="M10,9v6l5,-3Z" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_request_seerr_dark.xml b/app/src/main/res/drawable/ic_request_seerr_dark.xml deleted file mode 100644 index 5a678e6f..00000000 --- a/app/src/main/res/drawable/ic_request_seerr_dark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_seerr_logo.xml b/app/src/main/res/drawable/ic_seerr_logo.xml new file mode 100644 index 00000000..16a71e3c --- /dev/null +++ b/app/src/main/res/drawable/ic_seerr_logo.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 9d185631f9267cc2278b0688eac9dee6127ab3e2 Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 16:30:15 +0530 Subject: [PATCH 15/19] chore: Add and configure ktfmt for code formatting This commit introduces the `ktfmt` Gradle plugin to enforce a consistent code style across the project. The plugin is applied to all subprojects and configured to use the `kotlinLangStyle()`, which is a ktfmt-compatible code style used by official Kotlin libraries. This helps maintain code quality and readability. --- build.gradle.kts | 9 +++++++++ gradle/libs.versions.toml | 2 ++ 2 files changed, 11 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index a97d1568..1ecf866d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,16 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ktfmt) apply false alias(libs.plugins.aboutlibraries.android) apply false alias(libs.plugins.hilt.android) apply false alias(libs.plugins.ksp) apply false +} + +subprojects { + apply(plugin = "com.ncorti.ktfmt.gradle") + + configure { + kotlinLangStyle() + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b2708df..d9384de4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ composeBom = "2026.01.01" hilt = "2.59.1" hilt-navigation-compose = "1.3.0" ksp = "2.3.4" +ktfmt = "0.25.0" material3 = "1.4.0" material3AdaptiveNavigationSuite = "1.4.0" media3ExoplayerHls = "1.9.1" @@ -114,4 +115,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } From a10e85a334f4d91a3e949bd2b86c5e27986c602c Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 6 Feb 2026 16:53:19 +0530 Subject: [PATCH 16/19] style: Format code with trailing commas This commit applies code formatting changes across multiple files, primarily focused on adding trailing commas to parameter lists in Kotlin files. This improves code style consistency and makes future diffs cleaner. No functional changes are introduced in this commit. --- .idea/gradle.xml | 1 + .idea/misc.xml | 1 - app/build.gradle.kts | 10 +- .../com/makd/afinity/AfinityApplication.kt | 20 +- .../java/com/makd/afinity/MainActivity.kt | 85 +- .../java/com/makd/afinity/MainViewModel.kt | 20 +- .../com/makd/afinity/core/AppConstants.kt | 2 +- .../afinity/data/database/AfinityDatabase.kt | 162 +- .../data/database/AfinityTypeConverters.kt | 299 ++- .../data/database/DatabaseMigrations.kt | 1012 +++++---- .../afinity/data/database/DatabaseUtils.kt | 8 +- .../data/database/dao/AudiobookshelfDao.kt | 122 +- .../data/database/dao/BoxSetCacheDao.kt | 17 +- .../afinity/data/database/dao/EpisodeDao.kt | 49 +- .../data/database/dao/GenreCacheDao.kt | 52 +- .../data/database/dao/JellyseerrDao.kt | 50 +- .../data/database/dao/LibraryCacheDao.kt | 10 +- .../data/database/dao/MediaStreamDao.kt | 14 +- .../afinity/data/database/dao/MovieDao.kt | 29 +- .../data/database/dao/MovieSectionDao.kt | 11 +- .../data/database/dao/PersonSectionDao.kt | 11 +- .../afinity/data/database/dao/SeasonDao.kt | 32 +- .../data/database/dao/ServerAddressDao.kt | 8 +- .../afinity/data/database/dao/ServerDao.kt | 17 +- .../data/database/dao/ServerDatabaseDao.kt | 162 +- .../makd/afinity/data/database/dao/ShowDao.kt | 25 +- .../afinity/data/database/dao/SourceDao.kt | 11 +- .../data/database/dao/StudioCacheDao.kt | 9 +- .../afinity/data/database/dao/TopPeopleDao.kt | 11 +- .../makd/afinity/data/database/dao/UserDao.kt | 23 +- .../afinity/data/database/dao/UserDataDao.kt | 49 +- .../database/entities/AfinityEpisodeDto.kt | 39 +- .../entities/AfinityMediaStreamDto.kt | 17 +- .../data/database/entities/AfinityMovieDto.kt | 5 +- .../database/entities/AfinitySeasonDto.kt | 26 +- .../database/entities/AfinitySegmentDto.kt | 19 +- .../data/database/entities/AfinityShowDto.kt | 5 +- .../database/entities/AfinitySourceDto.kt | 9 +- .../entities/AfinityTrickplayInfoDto.kt | 22 +- .../entities/AudiobookshelfConfigEntity.kt | 9 +- .../entities/AudiobookshelfItemEntity.kt | 4 +- .../entities/AudiobookshelfLibraryEntity.kt | 4 +- .../entities/AudiobookshelfProgressEntity.kt | 4 +- .../database/entities/BoxSetCacheEntity.kt | 7 +- .../database/entities/BoxSetCacheMetadata.kt | 7 +- .../data/database/entities/DownloadDto.kt | 7 +- .../database/entities/GenreCacheEntity.kt | 7 +- .../entities/GenreMovieCacheEntity.kt | 9 +- .../database/entities/GenreShowCacheEntity.kt | 9 +- .../entities/JellyseerrConfigEntity.kt | 7 +- .../entities/JellyseerrRequestEntity.kt | 6 +- .../database/entities/LibraryCacheEntity.kt | 9 +- .../entities/MovieSectionCacheEntity.kt | 7 +- .../entities/PersonSectionCacheEntity.kt | 7 +- .../database/entities/ShowGenreCacheEntity.kt | 7 +- .../database/entities/StudioCacheEntity.kt | 7 +- .../database/entities/TopPeopleCacheEntity.kt | 7 +- .../data/manager/OfflineModeManager.kt | 29 +- .../data/manager/PlaybackStateManager.kt | 49 +- .../afinity/data/manager/SessionManager.kt | 263 ++- .../data/manager/SessionPreferences.kt | 33 +- .../com/makd/afinity/data/models/GenreItem.kt | 7 +- .../afinity/data/models/HomeScreenSections.kt | 49 +- .../audiobookshelf/AudiobookshelfItem.kt | 653 ++---- .../audiobookshelf/AudiobookshelfLibrary.kt | 239 +- .../audiobookshelf/AudiobookshelfPlayback.kt | 314 +-- .../audiobookshelf/AudiobookshelfUser.kt | 171 +- .../data/models/auth/QuickConnectState.kt | 4 +- .../data/models/common/CollectionType.kt | 19 +- .../data/models/common/EpisodeLayout.kt | 2 +- .../makd/afinity/data/models/common/SortBy.kt | 5 +- .../data/models/download/DownloadStatus.kt | 2 +- .../extensions/AfinityImagesExtensions.kt | 44 +- .../extensions/JellyfinModelExtensions.kt | 466 ++-- .../models/jellyseerr/CreateRequestBody.kt | 16 +- .../models/jellyseerr/DiscoverCategory.kt | 17 +- .../afinity/data/models/jellyseerr/Genre.kt | 41 +- .../models/jellyseerr/JellyfinLoginRequest.kt | 8 +- .../models/jellyseerr/JellyseerrRequest.kt | 46 +- .../jellyseerr/JellyseerrSearchResult.kt | 14 +- .../data/models/jellyseerr/JellyseerrUser.kt | 35 +- .../data/models/jellyseerr/LoginRequest.kt | 8 +- .../data/models/jellyseerr/LoginResponse.kt | 35 +- .../data/models/jellyseerr/MediaDetails.kt | 140 +- .../data/models/jellyseerr/MediaInfo.kt | 67 +- .../data/models/jellyseerr/MediaStatus.kt | 22 +- .../data/models/jellyseerr/MediaType.kt | 22 +- .../afinity/data/models/jellyseerr/Network.kt | 85 +- .../data/models/jellyseerr/Permissions.kt | 2 +- .../data/models/jellyseerr/RatingsCombined.kt | 38 +- .../data/models/jellyseerr/RequestStatus.kt | 20 +- .../data/models/jellyseerr/RequestUser.kt | 11 +- .../models/jellyseerr/RequestsResponse.kt | 20 +- .../models/jellyseerr/SearchResultItem.kt | 71 +- .../afinity/data/models/jellyseerr/Season.kt | 23 +- .../afinity/data/models/jellyseerr/Studio.kt | 91 +- .../data/models/livetv/AfinityChannel.kt | 4 +- .../data/models/livetv/AfinityProgram.kt | 4 +- .../afinity/data/models/livetv/ChannelType.kt | 4 +- .../data/models/media/AfinityBoxSet.kt | 14 +- .../data/models/media/AfinityChapter.kt | 10 +- .../data/models/media/AfinityCollection.kt | 14 +- .../data/models/media/AfinityEpisode.kt | 40 +- .../data/models/media/AfinityExternalUrl.kt | 12 +- .../data/models/media/AfinityFolder.kt | 14 +- .../data/models/media/AfinityImages.kt | 122 +- .../afinity/data/models/media/AfinityItem.kt | 12 +- .../data/models/media/AfinityMediaStream.kt | 13 +- .../afinity/data/models/media/AfinityMovie.kt | 15 +- .../data/models/media/AfinityPerson.kt | 31 +- .../data/models/media/AfinityPersonDetail.kt | 8 +- .../models/media/AfinityRecommendation.kt | 33 +- .../data/models/media/AfinitySeason.kt | 16 +- .../data/models/media/AfinitySegment.kt | 38 +- .../afinity/data/models/media/AfinityShow.kt | 16 +- .../data/models/media/AfinitySource.kt | 35 +- .../data/models/media/AfinitySources.kt | 2 +- .../data/models/media/AfinityStudio.kt | 4 +- .../data/models/media/AfinityTrickplayInfo.kt | 2 +- .../afinity/data/models/media/AfinityVideo.kt | 3 +- .../data/models/media/VideoMetadata.kt | 8 +- .../data/models/player/ExternalSubtitle.kt | 2 +- .../afinity/data/models/player/MpvOptions.kt | 3 + .../afinity/data/models/player/PlayerState.kt | 34 +- .../data/models/player/SubtitlePreferences.kt | 7 +- .../afinity/data/models/player/Trickplay.kt | 5 +- .../data/models/player/VideoZoomMode.kt | 3 +- .../makd/afinity/data/models/server/Server.kt | 8 +- .../data/models/server/ServerAddress.kt | 25 +- .../data/models/server/ServerWithAddresses.kt | 11 +- .../server/ServerWithAddressesAndUsers.kt | 17 +- .../data/models/server/ServerWithUsers.kt | 11 +- .../data/models/user/AfinityUserDataDto.kt | 7 +- .../com/makd/afinity/data/models/user/User.kt | 25 +- .../data/network/AudiobookshelfApiService.kt | 42 +- .../data/network/JellyseerrApiService.kt | 30 +- .../data/paging/EpisodesPagingSource.kt | 31 +- .../data/paging/JellyfinItemsPagingSource.kt | 245 +- .../data/repository/AppDataRepository.kt | 721 +++--- .../repository/AudiobookshelfRepository.kt | 29 +- .../data/repository/DatabaseRepository.kt | 99 +- .../makd/afinity/data/repository/FieldSets.kt | 193 +- .../data/repository/JellyfinRepository.kt | 83 +- .../data/repository/JellyseerrRepository.kt | 35 +- .../data/repository/PreferencesRepository.kt | 65 +- .../repository/SecurePreferencesRepository.kt | 55 +- .../AudiobookshelfRepositoryImpl.kt | 515 +++-- .../data/repository/auth/AuthRepository.kt | 14 +- .../repository/auth/JellyfinAuthRepository.kt | 245 +- .../repository/download/DownloadRepository.kt | 2 +- .../download/JellyfinDownloadRepository.kt | 373 +-- .../repository/impl/DatabaseRepositoryImpl.kt | 186 +- .../repository/impl/JellyfinRepositoryImpl.kt | 212 +- .../impl/PreferencesRepositoryImpl.kt | 310 +-- .../impl/SecurePreferencesRepositoryImpl.kt | 187 +- .../jellyseerr/JellyseerrRepositoryImpl.kt | 453 ++-- .../livetv/JellyfinLiveTvRepository.kt | 353 +-- .../repository/livetv/LiveTvRepository.kt | 8 +- .../data/repository/media/BoxSetCache.kt | 200 +- .../media/JellyfinMediaRepository.kt | 2021 +++++++++-------- .../data/repository/media/MediaRepository.kt | 79 +- .../playback/JellyfinPlaybackRepository.kt | 283 +-- .../repository/playback/PlaybackRepository.kt | 20 +- .../segments/JellyfinSegmentsRepository.kt | 104 +- .../repository/segments/SegmentsRepository.kt | 2 +- .../server/JellyfinServerRepository.kt | 106 +- .../repository/server/ServerRepository.kt | 16 +- .../userdata/JellyfinUserDataRepository.kt | 105 +- .../repository/userdata/UserDataRepository.kt | 14 +- .../watchlist/WatchlistRepository.kt | 4 +- .../watchlist/WatchlistRepositoryImpl.kt | 74 +- .../afinity/data/serialization/Serializers.kt | 8 +- .../data/sync/UserDataSyncScheduler.kt | 31 +- .../afinity/data/updater/GitHubApiService.kt | 68 +- .../afinity/data/updater/UpdateCheckWorker.kt | 8 +- .../afinity/data/updater/UpdateManager.kt | 262 ++- .../afinity/data/updater/UpdateScheduler.kt | 25 +- .../data/updater/models/GitHubRelease.kt | 12 +- .../notification/UpdateNotificationManager.kt | 82 +- .../websocket/JellyfinWebSocketManager.kt | 25 +- .../afinity/data/websocket/WebSocketState.kt | 4 +- .../data/workers/ImageDownloadWorker.kt | 356 +-- .../data/workers/MediaDownloadWorker.kt | 792 ++++--- .../data/workers/SubtitleDownloadWorker.kt | 249 +- .../data/workers/TrickplayDownloadWorker.kt | 253 ++- .../data/workers/UserDataSyncWorker.kt | 152 +- .../com/makd/afinity/di/DatabaseModule.kt | 15 +- .../com/makd/afinity/di/HiltQualifiers.kt | 36 +- .../com/makd/afinity/di/JellyseerrModule.kt | 4 +- .../java/com/makd/afinity/di/LiveTvModule.kt | 2 +- .../java/com/makd/afinity/di/NetworkModule.kt | 358 +-- .../java/com/makd/afinity/di/PlayerModule.kt | 4 +- .../makd/afinity/di/PreferencesEntryPoint.kt | 2 +- .../com/makd/afinity/di/PreferencesModule.kt | 23 +- .../com/makd/afinity/di/RepositoryModule.kt | 13 +- .../com/makd/afinity/di/SecurityModule.kt | 2 +- .../com/makd/afinity/di/SegmentsModule.kt | 2 +- .../com/makd/afinity/di/WatchlistModule.kt | 6 +- .../makd/afinity/navigation/Destination.kt | 22 +- .../makd/afinity/navigation/MainNavigation.kt | 354 ++- .../navigation/MainNavigationViewModel.kt | 46 +- .../AudiobookshelfPlaybackManager.kt | 58 +- .../audiobookshelf/AudiobookshelfPlayer.kt | 210 +- .../AudiobookshelfPlayerService.kt | 156 +- .../AudiobookshelfProgressSyncer.kt | 44 +- .../com/makd/afinity/player/mpv/MPVPlayer.kt | 406 ++-- .../makd/afinity/player/mpv/MPVTrackType.kt | 5 +- .../item/AudiobookshelfItemScreen.kt | 122 +- .../item/AudiobookshelfItemViewModel.kt | 31 +- .../item/components/ChapterList.kt | 96 +- .../item/components/EpisodeList.kt | 59 +- .../item/components/ItemHeader.kt | 237 +- .../libraries/AudiobookshelfHomeTab.kt | 37 +- .../AudiobookshelfLibrariesScreen.kt | 149 +- .../AudiobookshelfLibrariesViewModel.kt | 83 +- .../libraries/AudiobookshelfSeriesTab.kt | 37 +- .../libraries/components/LibraryCard.kt | 55 +- .../library/AudiobookshelfLibraryScreen.kt | 75 +- .../library/AudiobookshelfLibraryViewModel.kt | 61 +- .../library/components/AudiobookCard.kt | 36 +- .../library/components/PodcastCard.kt | 44 +- .../login/AudiobookshelfLoginScreen.kt | 157 +- .../login/AudiobookshelfLoginViewModel.kt | 96 +- .../player/AudiobookshelfPlayerScreen.kt | 283 ++- .../player/AudiobookshelfPlayerViewModel.kt | 26 +- .../player/components/ChapterSelector.kt | 91 +- .../player/components/MiniPlayer.kt | 49 +- .../components/PlaybackSpeedSelector.kt | 117 +- .../player/components/PlayerControls.kt | 82 +- .../player/components/SleepTimerDialog.kt | 86 +- .../player/util/DominantColorState.kt | 29 +- .../ui/components/AfinitySplashScreen.kt | 47 +- .../afinity/ui/components/AfinityTopAppBar.kt | 86 +- .../makd/afinity/ui/components/AsyncImage.kt | 118 +- .../ui/components/ContinueWatchingCard.kt | 143 +- .../afinity/ui/components/EpisodeListCard.kt | 107 +- .../afinity/ui/components/HeroCarousel.kt | 497 ++-- .../afinity/ui/components/MediaItemCard.kt | 146 +- .../components/RequestConfirmationDialog.kt | 417 ++-- .../afinity/ui/components/SeasonSelector.kt | 30 +- .../ui/downloads/DownloadsViewModel.kt | 104 +- .../afinity/ui/favorites/FavoritesScreen.kt | 298 +-- .../ui/favorites/FavoritesViewModel.kt | 125 +- .../com/makd/afinity/ui/home/HomeScreen.kt | 1232 +++++----- .../com/makd/afinity/ui/home/HomeViewModel.kt | 486 ++-- .../ui/home/components/GenreSection.kt | 106 +- .../ui/home/components/HomeSections.kt | 76 +- .../ui/home/components/NextUpSection.kt | 26 +- .../home/components/RecommendationsSection.kt | 110 +- .../ui/home/components/ShowGenreSection.kt | 56 +- .../makd/afinity/ui/item/ItemDetailScreen.kt | 1616 +++++++------ .../afinity/ui/item/ItemDetailViewModel.kt | 570 ++--- .../ui/item/components/BoxSetDetailContent.kt | 34 +- .../components/DownloadProgressIndicator.kt | 25 +- .../item/components/EpisodeDetailOverlay.kt | 330 +-- .../ui/item/components/MovieDetailContent.kt | 88 +- .../item/components/QualitySelectionDialog.kt | 97 +- .../ui/item/components/SeasonDetailContent.kt | 54 +- .../ui/item/components/SeriesDetailContent.kt | 213 +- .../ui/item/components/shared/CastSection.kt | 58 +- .../components/shared/ExternalLinksSection.kt | 74 +- .../ui/item/components/shared/HeroSection.kt | 75 +- .../components/shared/InCollectionsSection.kt | 18 +- .../ui/item/components/shared/MetadataRow.kt | 340 +-- .../item/components/shared/NextUpSection.kt | 27 +- .../shared/PlaybackSelectionButton.kt | 53 +- .../components/shared/SimilarItemsSection.kt | 34 +- .../shared/SpecialFeaturesSection.kt | 47 +- .../shared/VideoQualitySelection.kt | 48 +- .../ui/jellyseerr/JellyseerrLoginViewModel.kt | 149 +- .../afinity/ui/libraries/LibrariesScreen.kt | 152 +- .../ui/libraries/LibrariesViewModel.kt | 29 +- .../ui/library/LibraryContentScreen.kt | 576 +++-- .../ui/library/LibraryContentViewModel.kt | 130 +- .../makd/afinity/ui/livetv/LiveTvScreen.kt | 222 +- .../makd/afinity/ui/livetv/LiveTvUiState.kt | 10 +- .../makd/afinity/ui/livetv/LiveTvViewModel.kt | 197 +- .../ui/livetv/components/ChannelCard.kt | 148 +- .../ui/livetv/components/EpgChannelCell.kt | 44 +- .../ui/livetv/components/EpgProgramCell.kt | 73 +- .../ui/livetv/components/EpgProgramRow.kt | 28 +- .../ui/livetv/components/EpgTimeHeader.kt | 37 +- .../afinity/ui/livetv/components/LiveBadge.kt | 17 +- .../ui/livetv/components/ProgramCard.kt | 135 +- .../livetv/components/ProgramCategoryRow.kt | 24 +- .../livetv/components/ProgramProgressBar.kt | 14 +- .../ui/livetv/models/LiveTvCategory.kt | 4 +- .../ui/livetv/models/ProgramWithChannel.kt | 5 +- .../ui/livetv/tabs/LiveTvChannelsTab.kt | 130 +- .../afinity/ui/livetv/tabs/LiveTvGuideTab.kt | 113 +- .../afinity/ui/livetv/tabs/LiveTvHomeTab.kt | 59 +- .../com/makd/afinity/ui/login/LoginScreen.kt | 423 ++-- .../makd/afinity/ui/login/LoginViewModel.kt | 507 ++--- .../com/makd/afinity/ui/main/MainUiState.kt | 4 +- .../com/makd/afinity/ui/main/MainViewModel.kt | 24 +- .../makd/afinity/ui/person/PersonScreen.kt | 30 +- .../makd/afinity/ui/person/PersonViewModel.kt | 61 +- .../person/components/PersonDetailContent.kt | 243 +- .../com/makd/afinity/ui/player/Extensions.kt | 34 +- .../makd/afinity/ui/player/PlayerActivity.kt | 197 +- .../makd/afinity/ui/player/PlayerLauncher.kt | 56 +- .../makd/afinity/ui/player/PlayerScreen.kt | 120 +- .../afinity/ui/player/PlayerScreenWrapper.kt | 41 +- .../makd/afinity/ui/player/PlayerViewModel.kt | 866 +++---- .../ui/player/PlayerWrapperViewModel.kt | 82 +- .../makd/afinity/ui/player/PlaylistManager.kt | 110 +- .../player/components/BufferingIndicator.kt | 33 +- .../ui/player/components/EpisodeSwitcher.kt | 286 ++- .../ui/player/components/GestureHandler.kt | 220 +- .../ui/player/components/MpvSurface.kt | 60 +- .../player/components/PlaybackSpeedDialog.kt | 131 +- .../ui/player/components/PlayerControls.kt | 702 +++--- .../ui/player/components/PlayerIndicators.kt | 177 +- .../ui/player/components/SkipButton.kt | 46 +- .../ui/player/components/TrickplayPreview.kt | 57 +- .../afinity/ui/player/utils/KeepScreenOn.kt | 6 +- .../utils/PlayerSystemBarsController.kt | 6 +- .../ui/player/utils/ScreenBrightnessHelper.kt | 17 +- .../afinity/ui/player/utils/VolmeManager.kt | 8 +- .../afinity/ui/requests/DiscoverMediaCard.kt | 135 +- .../ui/requests/FilteredMediaScreen.kt | 120 +- .../ui/requests/FilteredMediaViewModel.kt | 83 +- .../makd/afinity/ui/requests/RequestCard.kt | 195 +- .../afinity/ui/requests/RequestsScreen.kt | 198 +- .../afinity/ui/requests/RequestsSections.kt | 168 +- .../afinity/ui/requests/RequestsViewModel.kt | 730 +++--- .../afinity/ui/requests/StudioNetworkCard.kt | 112 +- .../afinity/ui/search/GenreResultsScreen.kt | 147 +- .../ui/search/GenreResultsViewModel.kt | 64 +- .../makd/afinity/ui/search/SearchScreen.kt | 448 ++-- .../makd/afinity/ui/search/SearchViewModel.kt | 390 ++-- .../ui/settings/JellyseerrBottomSheet.kt | 197 +- .../afinity/ui/settings/LicensesScreen.kt | 95 +- .../ui/settings/SessionSwitcherBottomSheet.kt | 176 +- .../ui/settings/SessionSwitcherViewModel.kt | 182 +- .../afinity/ui/settings/SettingsScreen.kt | 326 ++- .../afinity/ui/settings/SettingsViewModel.kt | 207 +- .../appearance/AppearanceOptionsScreen.kt | 143 +- .../downloads/DownloadSettingsScreen.kt | 318 +-- .../ui/settings/player/PlayerOptionsScreen.kt | 800 +++---- .../settings/servers/AddEditServerScreen.kt | 212 +- .../servers/AddEditServerViewModel.kt | 154 +- .../servers/ServerManagementScreen.kt | 224 +- .../servers/ServerManagementViewModel.kt | 64 +- .../ui/settings/update/GlobalUpdateDialog.kt | 35 +- .../settings/update/UpdateAvailableDialog.kt | 131 +- .../ui/settings/update/UpdateSection.kt | 157 +- .../ui/settings/update/UpdateViewModel.kt | 37 +- .../makd/afinity/ui/theme/CardDimensions.kt | 41 +- .../java/com/makd/afinity/ui/theme/Color.kt | 2 +- .../java/com/makd/afinity/ui/theme/Theme.kt | 89 +- .../com/makd/afinity/ui/theme/ThemeMode.kt | 2 +- .../java/com/makd/afinity/ui/theme/Type.kt | 50 +- .../makd/afinity/ui/utils/HtmlTextUtils.kt | 22 +- .../com/makd/afinity/ui/utils/IntentUtils.kt | 12 +- .../afinity/ui/watchlist/WatchlistScreen.kt | 144 +- .../ui/watchlist/WatchlistViewModel.kt | 119 +- .../com/makd/afinity/util/BackdropTracker.kt | 29 +- .../com/makd/afinity/util/ComposeHiltExt.kt | 8 +- .../util/GenreDuotoneColorGenerator.kt | 35 +- .../afinity/util/JellyfinImageUrlBuilder.kt | 69 +- .../util/NetworkConnectivityMonitor.kt | 94 +- 362 files changed, 20767 insertions(+), 21209 deletions(-) diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 97f0a8e1..639c779c 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,5 +1,6 @@ +