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 32acea2c..99aba590 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.AbsDownloadDao import com.makd.afinity.data.database.dao.AudiobookshelfDao import com.makd.afinity.data.database.dao.BoxSetCacheDao import com.makd.afinity.data.database.dao.EpisodeDao @@ -35,6 +36,7 @@ import com.makd.afinity.data.database.entities.AfinitySegmentDto import com.makd.afinity.data.database.entities.AfinityShowDto import com.makd.afinity.data.database.entities.AfinitySourceDto import com.makd.afinity.data.database.entities.AfinityTrickplayInfoDto +import com.makd.afinity.data.database.entities.AbsDownloadEntity import com.makd.afinity.data.database.entities.AudiobookshelfAddressEntity import com.makd.afinity.data.database.entities.AudiobookshelfConfigEntity import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity @@ -99,8 +101,9 @@ import com.makd.afinity.data.models.user.User JellyseerrAddressEntity::class, AudiobookshelfAddressEntity::class, JellyfinStatsCacheEntity::class, + AbsDownloadEntity::class, ], - version = 36, + version = 39, exportSchema = false, ) @TypeConverters(AfinityTypeConverters::class) @@ -150,6 +153,8 @@ abstract class AfinityDatabase : RoomDatabase() { abstract fun jellyfinStatsDao(): JellyfinStatsDao + abstract fun absDownloadDao(): AbsDownloadDao + companion object { @Volatile private var INSTANCE: AfinityDatabase? = null 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 a396655a..b902df39 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 @@ -716,6 +716,62 @@ object DatabaseMigrations { } } + val MIGRATION_36_37 = + object : Migration(36, 37) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS abs_downloads ( + id TEXT NOT NULL PRIMARY KEY, + libraryItemId TEXT NOT NULL, + episodeId TEXT, + jellyfinServerId TEXT NOT NULL, + jellyfinUserId TEXT NOT NULL, + title TEXT NOT NULL, + authorName TEXT, + mediaType TEXT NOT NULL, + coverUrl TEXT, + duration REAL NOT NULL, + status TEXT NOT NULL, + progress REAL NOT NULL, + bytesDownloaded INTEGER NOT NULL, + totalBytes INTEGER NOT NULL, + tracksTotal INTEGER NOT NULL, + tracksDownloaded INTEGER NOT NULL, + error TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + localDirPath TEXT, + serializedSession TEXT + ) + """ + .trimIndent() + ) + db.execSQL( + """ + CREATE UNIQUE INDEX IF NOT EXISTS index_abs_downloads_item + ON abs_downloads (libraryItemId, episodeId, jellyfinServerId, jellyfinUserId) + """ + .trimIndent() + ) + } + } + + val MIGRATION_37_38 = + object : Migration(37, 38) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE audiobookshelf_items ADD COLUMN serializedEpisodes TEXT") + } + } + + val MIGRATION_38_39 = + object : Migration(38, 39) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE abs_downloads ADD COLUMN episodeDescription TEXT") + db.execSQL("ALTER TABLE abs_downloads ADD COLUMN publishedAt INTEGER") + } + } + val ALL_MIGRATIONS = arrayOf( MIGRATION_1_2, @@ -749,5 +805,8 @@ object DatabaseMigrations { MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, + MIGRATION_36_37, + MIGRATION_37_38, + MIGRATION_38_39, ) } diff --git a/app/src/main/java/com/makd/afinity/data/database/dao/AbsDownloadDao.kt b/app/src/main/java/com/makd/afinity/data/database/dao/AbsDownloadDao.kt new file mode 100644 index 00000000..cb3cbbae --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/database/dao/AbsDownloadDao.kt @@ -0,0 +1,166 @@ +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.AbsDownloadEntity +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus +import kotlinx.coroutines.flow.Flow +import java.util.UUID + +@Dao +interface AbsDownloadDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: AbsDownloadEntity) + + @Query("SELECT * FROM abs_downloads WHERE id = :id") + suspend fun getById(id: UUID): AbsDownloadEntity? + + @Query( + """SELECT * FROM abs_downloads + WHERE libraryItemId = :libraryItemId + AND episodeId IS NULL + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId + LIMIT 1""" + ) + suspend fun getDownloadForBook( + libraryItemId: String, + serverId: String, + userId: String, + ): AbsDownloadEntity? + + @Query( + """SELECT * FROM abs_downloads + WHERE libraryItemId = :libraryItemId + AND episodeId = :episodeId + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId + LIMIT 1""" + ) + suspend fun getDownloadForEpisode( + libraryItemId: String, + episodeId: String, + serverId: String, + userId: String, + ): AbsDownloadEntity? + + @Query( + """SELECT * FROM abs_downloads + WHERE libraryItemId = :libraryItemId + AND episodeId IS NOT NULL + AND status = 'COMPLETED' + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId + ORDER BY updatedAt DESC + LIMIT 1""" + ) + suspend fun getFirstCompletedEpisodeForItem( + libraryItemId: String, + serverId: String, + userId: String, + ): AbsDownloadEntity? + + @Query( + """SELECT * FROM abs_downloads + WHERE libraryItemId = :libraryItemId + AND episodeId IS NOT NULL + AND status = 'COMPLETED' + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId + ORDER BY updatedAt DESC""" + ) + suspend fun getCompletedEpisodesForItem( + libraryItemId: String, + serverId: String, + userId: String, + ): List + + @Query( + """SELECT * FROM abs_downloads + WHERE status IN ('QUEUED', 'DOWNLOADING') + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId + ORDER BY createdAt DESC""" + ) + fun getActiveDownloadsFlow(serverId: String, userId: String): Flow> + + @Query( + """SELECT * FROM abs_downloads + WHERE status = 'COMPLETED' + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId + ORDER BY updatedAt DESC""" + ) + fun getCompletedDownloadsFlow(serverId: String, userId: String): Flow> + + @Query( + """SELECT * FROM abs_downloads + WHERE status = 'COMPLETED' + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId + ORDER BY updatedAt DESC""" + ) + suspend fun getCompletedDownloads(serverId: String, userId: String): List + + @Query( + """SELECT COUNT(*) FROM abs_downloads + WHERE libraryItemId = :libraryItemId + AND episodeId IS NULL + AND status = 'COMPLETED' + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId""" + ) + suspend fun isBookDownloaded(libraryItemId: String, serverId: String, userId: String): Int + + @Query( + """SELECT COUNT(*) FROM abs_downloads + WHERE libraryItemId = :libraryItemId + AND episodeId = :episodeId + AND status = 'COMPLETED' + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId""" + ) + suspend fun isEpisodeDownloaded( + libraryItemId: String, + episodeId: String, + serverId: String, + userId: String, + ): Int + + @Query( + """UPDATE abs_downloads + SET status = :status, + progress = :progress, + bytesDownloaded = :bytesDownloaded, + tracksDownloaded = :tracksDownloaded, + serializedSession = COALESCE(:serializedSession, serializedSession), + updatedAt = :updatedAt + WHERE id = :id""" + ) + suspend fun updateProgress( + id: UUID, + status: AbsDownloadStatus, + progress: Float, + bytesDownloaded: Long, + tracksDownloaded: Int, + serializedSession: String?, + updatedAt: Long, + ) + + @Query("UPDATE abs_downloads SET status = :status, error = :error, updatedAt = :updatedAt WHERE id = :id") + suspend fun updateStatus(id: UUID, status: AbsDownloadStatus, error: String?, updatedAt: Long) + + @Query("DELETE FROM abs_downloads WHERE id = :id") + suspend fun deleteById(id: UUID) + + @Query( + """SELECT COALESCE(SUM(bytesDownloaded), 0) FROM abs_downloads + WHERE status = 'COMPLETED' + AND jellyfinServerId = :serverId + AND jellyfinUserId = :userId""" + ) + suspend fun getTotalBytesForServer(serverId: String, userId: String): Long +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/database/entities/AbsDownloadEntity.kt b/app/src/main/java/com/makd/afinity/data/database/entities/AbsDownloadEntity.kt new file mode 100644 index 00000000..000f26e2 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/database/entities/AbsDownloadEntity.kt @@ -0,0 +1,42 @@ +package com.makd.afinity.data.database.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus +import java.util.UUID + +@Entity( + tableName = "abs_downloads", + indices = [ + Index( + value = ["libraryItemId", "episodeId", "jellyfinServerId", "jellyfinUserId"], + unique = true, + ) + ], +) +data class AbsDownloadEntity( + @PrimaryKey val id: UUID, + val libraryItemId: String, + val episodeId: String?, + val jellyfinServerId: String, + val jellyfinUserId: String, + val title: String, + val authorName: String?, + val mediaType: String, + val coverUrl: String?, + val duration: Double, + val status: AbsDownloadStatus, + val progress: Float, + val bytesDownloaded: Long, + val totalBytes: Long, + val tracksTotal: Int, + val tracksDownloaded: Int, + val error: String?, + val createdAt: Long, + val updatedAt: Long, + val localDirPath: String?, + val serializedSession: String?, + val episodeDescription: String? = null, + val publishedAt: Long? = null, +) \ 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 index d6d4931f..18875a4e 100644 --- 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 @@ -27,4 +27,5 @@ data class AudiobookshelfItemEntity( val addedAt: Long?, val updatedAt: Long?, val cachedAt: Long, + val serializedEpisodes: String? = null, ) diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AbsDownloadInfo.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AbsDownloadInfo.kt new file mode 100644 index 00000000..7425ec7d --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AbsDownloadInfo.kt @@ -0,0 +1,26 @@ +package com.makd.afinity.data.models.audiobookshelf + +import java.util.UUID + +data class AbsDownloadInfo( + val id: UUID, + val libraryItemId: String, + val episodeId: String?, + val title: String, + val authorName: String?, + val mediaType: String, + val coverUrl: String?, + val duration: Double, + val status: AbsDownloadStatus, + val progress: Float, + val bytesDownloaded: Long, + val totalBytes: Long, + val tracksTotal: Int, + val tracksDownloaded: Int, + val error: String?, + val createdAt: Long, + val updatedAt: Long, + val localDirPath: String?, + val episodeDescription: String? = null, + val publishedAt: Long? = null, +) \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AbsDownloadStatus.kt b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AbsDownloadStatus.kt new file mode 100644 index 00000000..8cb1dea0 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/audiobookshelf/AbsDownloadStatus.kt @@ -0,0 +1,9 @@ +package com.makd.afinity.data.models.audiobookshelf + +enum class AbsDownloadStatus { + QUEUED, + DOWNLOADING, + COMPLETED, + FAILED, + CANCELLED, +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt b/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt index 3a70d210..523afd81 100644 --- a/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt +++ b/app/src/main/java/com/makd/afinity/data/models/extensions/JellyfinModelExtensions.kt @@ -67,8 +67,7 @@ private fun BaseItemDto.toAfinitySources(baseUrl: String): List = isExternal = mediaStream.isExternal, path = if ( - mediaStream.isExternal && - !mediaStream.deliveryUrl.isNullOrBlank() + mediaStream.isExternal && !mediaStream.deliveryUrl.isNullOrBlank() ) { baseUrl + mediaStream.deliveryUrl } else { @@ -117,9 +116,7 @@ fun BaseItemDto.toAfinityMovie(baseUrl: String): AfinityMovie { trickplayInfo = trickplay ?.flatMap { (_, widthMap) -> - widthMap.map { (width, info) -> - width.toString() to info.toAfinityTrickplayInfo() - } + widthMap.map { (width, info) -> width to info.toAfinityTrickplayInfo() } } ?.toMap(), providerIds = providerIds?.mapNotNull { (key, value) -> value?.let { key to it } }?.toMap(), @@ -259,9 +256,7 @@ fun BaseItemDto.toAfinityEpisode(baseUrl: String): AfinityEpisode? { trickplayInfo = trickplay ?.flatMap { (_, widthMap) -> - widthMap.map { (width, info) -> - width.toString() to info.toAfinityTrickplayInfo() - } + widthMap.map { (width, info) -> width to info.toAfinityTrickplayInfo() } } ?.toMap(), providerIds = 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 bedc27fd..c351bb14 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 @@ -1,5 +1,6 @@ package com.makd.afinity.data.network +import okhttp3.ResponseBody import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfUser import com.makd.afinity.data.models.audiobookshelf.AuthorizeResponse import com.makd.afinity.data.models.audiobookshelf.BatchLocalSessionRequest @@ -89,6 +90,7 @@ interface AudiobookshelfApiService { @Path("itemId") id: String, @Query("expanded") expanded: Int = 1, @Query("include") include: String? = "progress", + @Query("episode") episode: String? = null, ): Response @GET("api/libraries/{libraryId}/stats") @@ -117,14 +119,14 @@ interface AudiobookshelfApiService { suspend fun updateProgress( @Path("itemId") id: String, @Body progress: ProgressUpdateRequest, - ): Response + ): Response @PATCH("api/me/progress/{itemId}/{episodeId}") suspend fun updateEpisodeProgress( @Path("itemId") itemId: String, @Path("episodeId") episodeId: String, @Body progress: ProgressUpdateRequest, - ): Response + ): Response @POST("api/items/{itemId}/play") suspend fun startPlaybackSession( diff --git a/app/src/main/java/com/makd/afinity/data/paging/JellyfinItemsPagingSource.kt b/app/src/main/java/com/makd/afinity/data/paging/JellyfinItemsPagingSource.kt index ccbc7bc9..00bc1d6e 100644 --- a/app/src/main/java/com/makd/afinity/data/paging/JellyfinItemsPagingSource.kt +++ b/app/src/main/java/com/makd/afinity/data/paging/JellyfinItemsPagingSource.kt @@ -8,8 +8,8 @@ import com.makd.afinity.data.models.extensions.toAfinityItem import com.makd.afinity.data.models.media.AfinityItem import com.makd.afinity.data.repository.media.MediaRepository import com.makd.afinity.ui.library.FilterType -import java.util.UUID import timber.log.Timber +import java.util.UUID class JellyfinItemsPagingSource( private val mediaRepository: MediaRepository, @@ -79,7 +79,7 @@ class JellyfinItemsPagingSource( studios = if (studioName != null) listOf(studioName) else emptyList(), ) - response.items?.mapNotNull { it.toAfinityItem(baseUrl) } ?: emptyList() + response.items.mapNotNull { it.toAfinityItem(baseUrl) } } else { when (libraryType) { CollectionType.TvShows -> { @@ -100,8 +100,7 @@ class JellyfinItemsPagingSource( isPlayed = filterIsPlayed, isLiked = filterIsLiked, ) - response.items?.mapNotNull { it.toAfinityItem(baseUrl) } - ?: emptyList() + response.items.mapNotNull { it.toAfinityItem(baseUrl) } } else { mediaRepository.getShows( parentId = parentId, @@ -132,8 +131,7 @@ class JellyfinItemsPagingSource( isPlayed = filterIsPlayed, isLiked = filterIsLiked, ) - response.items?.mapNotNull { it.toAfinityItem(baseUrl) } - ?: emptyList() + response.items.mapNotNull { it.toAfinityItem(baseUrl) } } else { mediaRepository.getMovies( parentId = parentId, @@ -171,7 +169,7 @@ class JellyfinItemsPagingSource( isPlayed = filterIsPlayed, isLiked = filterIsLiked, ) - response.items?.mapNotNull { it.toAfinityItem(baseUrl) } ?: emptyList() + response.items.mapNotNull { it.toAfinityItem(baseUrl) } } } } 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 1dfd0878..a80a129d 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 @@ -113,7 +113,9 @@ interface AudiobookshelfRepository { duration: Double, ): Result - suspend fun syncPendingProgress(): Result + val currentActiveContext: Pair? + + suspend fun syncPendingProgress(serverId: String, userId: UUID): Result suspend fun getGenres(libraryIds: List): Result> diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsDownloadRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsDownloadRepository.kt new file mode 100644 index 00000000..14242f0f --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsDownloadRepository.kt @@ -0,0 +1,23 @@ +package com.makd.afinity.data.repository.audiobookshelf + +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo +import kotlinx.coroutines.flow.Flow +import java.util.UUID + +interface AbsDownloadRepository { + fun getActiveDownloadsFlow(): Flow> + + fun getCompletedDownloadsFlow(): Flow> + + suspend fun isItemDownloaded(libraryItemId: String, episodeId: String? = null): Boolean + + suspend fun getDownload(libraryItemId: String, episodeId: String? = null): AbsDownloadInfo? + + suspend fun startDownload(libraryItemId: String, episodeId: String? = null): Result + + suspend fun cancelDownload(downloadId: UUID): Result + + suspend fun deleteDownload(downloadId: UUID): Result + + suspend fun getTotalStorageUsed(): Long +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsDownloadRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsDownloadRepositoryImpl.kt new file mode 100644 index 00000000..b7c4b2bc --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsDownloadRepositoryImpl.kt @@ -0,0 +1,241 @@ +package com.makd.afinity.data.repository.audiobookshelf + +import android.content.Context +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.makd.afinity.data.database.dao.AbsDownloadDao +import com.makd.afinity.data.database.entities.AbsDownloadEntity +import com.makd.afinity.data.manager.SessionManager +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus +import com.makd.afinity.data.repository.PreferencesRepository +import com.makd.afinity.data.workers.AbsMediaDownloadWorker +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import timber.log.Timber +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AbsDownloadRepositoryImpl +@Inject +constructor( + @param:ApplicationContext private val context: Context, + private val sessionManager: SessionManager, + private val absDownloadDao: AbsDownloadDao, + private val preferencesRepository: PreferencesRepository, + private val workManager: WorkManager, +) : AbsDownloadRepository { + + companion object { + const val KEY_DOWNLOAD_ID = "abs_download_id" + const val KEY_LIBRARY_ITEM_ID = "abs_library_item_id" + const val KEY_EPISODE_ID = "abs_episode_id" + } + + private val downloadBaseDir: File + get() = + File(context.getExternalFilesDir(null), "AFinity/Audiobookshelf").also { + if (!it.exists()) it.mkdirs() + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getActiveDownloadsFlow(): Flow> = + sessionManager.currentSession.flatMapLatest { session -> + if (session == null) return@flatMapLatest flowOf(emptyList()) + absDownloadDao.getActiveDownloadsFlow(session.serverId, session.userId.toString()) + .map { entities -> entities.map { it.toAbsDownloadInfo() } } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getCompletedDownloadsFlow(): Flow> = + sessionManager.currentSession.flatMapLatest { session -> + if (session == null) return@flatMapLatest flowOf(emptyList()) + absDownloadDao.getCompletedDownloadsFlow(session.serverId, session.userId.toString()) + .map { entities -> entities.map { it.toAbsDownloadInfo() } } + } + + override suspend fun isItemDownloaded(libraryItemId: String, episodeId: String?): Boolean { + val session = sessionManager.currentSession.value ?: return false + val serverId = session.serverId + val userId = session.userId.toString() + return if (episodeId != null) { + absDownloadDao.isEpisodeDownloaded(libraryItemId, episodeId, serverId, userId) > 0 + } else { + absDownloadDao.isBookDownloaded(libraryItemId, serverId, userId) > 0 + } + } + + override suspend fun getDownload(libraryItemId: String, episodeId: String?): AbsDownloadInfo? { + val session = sessionManager.currentSession.value ?: return null + val serverId = session.serverId + val userId = session.userId.toString() + val entity = if (episodeId != null) { + absDownloadDao.getDownloadForEpisode(libraryItemId, episodeId, serverId, userId) + } else { + absDownloadDao.getDownloadForBook(libraryItemId, serverId, userId) + } + return entity?.toAbsDownloadInfo() + } + + override suspend fun startDownload(libraryItemId: String, episodeId: String?): Result { + val session = + sessionManager.currentSession.value + ?: return Result.failure(Exception("No active session")) + + val serverId = session.serverId + val userId = session.userId.toString() + + val existing = if (episodeId != null) { + absDownloadDao.getDownloadForEpisode(libraryItemId, episodeId, serverId, userId) + } else { + absDownloadDao.getDownloadForBook(libraryItemId, serverId, userId) + } + if (existing != null && existing.status in listOf( + AbsDownloadStatus.QUEUED, + AbsDownloadStatus.DOWNLOADING + ) + ) { + Timber.d("AbsDownload already active for $libraryItemId / $episodeId") + return Result.success(existing.id) + } + + val downloadId = UUID.randomUUID() + val localDirPath = buildLocalDirPath(serverId, libraryItemId, episodeId) + + val entity = AbsDownloadEntity( + id = downloadId, + libraryItemId = libraryItemId, + episodeId = episodeId, + jellyfinServerId = serverId, + jellyfinUserId = userId, + title = "", + authorName = null, + mediaType = if (episodeId != null) "podcast" else "book", + coverUrl = null, + duration = 0.0, + status = AbsDownloadStatus.QUEUED, + progress = 0f, + bytesDownloaded = 0L, + totalBytes = 0L, + tracksTotal = 0, + tracksDownloaded = 0, + error = null, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + localDirPath = localDirPath, + serializedSession = null, + ) + absDownloadDao.upsert(entity) + + enqueueWorker(downloadId, libraryItemId, episodeId) + + Timber.d("AbsDownload enqueued: $downloadId for $libraryItemId / $episodeId") + return Result.success(downloadId) + } + + override suspend fun cancelDownload(downloadId: UUID): Result { + workManager.cancelUniqueWork("abs_download_$downloadId") + absDownloadDao.updateStatus( + id = downloadId, + status = AbsDownloadStatus.CANCELLED, + error = null, + updatedAt = System.currentTimeMillis(), + ) + return Result.success(Unit) + } + + override suspend fun deleteDownload(downloadId: UUID): Result { + workManager.cancelUniqueWork("abs_download_$downloadId") + val entity = absDownloadDao.getById(downloadId) + if (entity?.localDirPath != null) { + val dir = File(entity.localDirPath) + if (dir.exists()) { + dir.deleteRecursively() + Timber.d("AbsDownload: deleted local files at ${entity.localDirPath}") + } + } + absDownloadDao.deleteById(downloadId) + return Result.success(Unit) + } + + override suspend fun getTotalStorageUsed(): Long { + val session = sessionManager.currentSession.value ?: return 0L + return absDownloadDao.getTotalBytesForServer(session.serverId, session.userId.toString()) + } + + private fun buildLocalDirPath( + serverId: String, + libraryItemId: String, + episodeId: String? + ): String { + val base = File(downloadBaseDir, serverId) + return if (episodeId != null) { + File(base, "podcasts/$libraryItemId/episodes/$episodeId").absolutePath + } else { + File(base, "books/$libraryItemId").absolutePath + } + } + + private suspend fun enqueueWorker(downloadId: UUID, libraryItemId: String, episodeId: String?) { + val wifiOnly = preferencesRepository.getDownloadOverWifiOnly() + val networkType = if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED + val constraints = Constraints.Builder() + .setRequiredNetworkType(networkType) + .setRequiresStorageNotLow(true) + .build() + + val inputData = Data.Builder() + .putString(KEY_DOWNLOAD_ID, downloadId.toString()) + .putString(KEY_LIBRARY_ITEM_ID, libraryItemId) + .apply { if (episodeId != null) putString(KEY_EPISODE_ID, episodeId) } + .build() + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInputData(inputData) + .addTag("abs_download_active") + .addTag("abs_download_$downloadId") + .build() + + workManager.enqueueUniqueWork( + "abs_download_$downloadId", + ExistingWorkPolicy.KEEP, + request, + ) + } +} + +fun AbsDownloadEntity.toAbsDownloadInfo(): AbsDownloadInfo = + AbsDownloadInfo( + id = id, + libraryItemId = libraryItemId, + episodeId = episodeId, + title = title, + authorName = authorName, + mediaType = mediaType, + coverUrl = coverUrl, + duration = duration, + status = status, + progress = progress, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + tracksTotal = tracksTotal, + tracksDownloaded = tracksDownloaded, + error = error, + createdAt = createdAt, + updatedAt = updatedAt, + localDirPath = localDirPath, + episodeDescription = episodeDescription, + publishedAt = publishedAt, + ) \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsProgressSyncScheduler.kt b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsProgressSyncScheduler.kt new file mode 100644 index 00000000..153c4c01 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/audiobookshelf/AbsProgressSyncScheduler.kt @@ -0,0 +1,55 @@ +package com.makd.afinity.data.repository.audiobookshelf + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.makd.afinity.data.workers.AbsProgressSyncWorker +import dagger.hilt.android.qualifiers.ApplicationContext +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AbsProgressSyncScheduler +@Inject +constructor( + @ApplicationContext private val context: Context, +) { + fun scheduleSync(serverId: String, userId: UUID) { + try { + val constraints = + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + + val inputData = workDataOf( + KEY_SERVER_ID to serverId, + KEY_USER_ID to userId.toString(), + ) + + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInputData(inputData) + .addTag(SYNC_WORK_TAG) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork(SYNC_WORK_NAME, ExistingWorkPolicy.REPLACE, request) + + Timber.d("ABS progress sync scheduled for serverId=$serverId (runs when network available)") + } catch (e: Exception) { + Timber.e(e, "Failed to schedule ABS progress sync") + } + } + + companion object { + const val SYNC_WORK_NAME = "abs_progress_sync" + const val SYNC_WORK_TAG = "abs_sync" + const val KEY_SERVER_ID = "serverId" + const val KEY_USER_ID = "userId" + } +} \ 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 index 57c30b04..4d000c2d 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 @@ -8,6 +8,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.AbsDownloadStatus import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfSeries import com.makd.afinity.data.models.audiobookshelf.AudiobookshelfUser import com.makd.afinity.data.models.audiobookshelf.DeviceInfo @@ -22,6 +23,9 @@ 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.PodcastEpisode +import com.makd.afinity.data.models.audiobookshelf.BatchLocalSessionRequest +import com.makd.afinity.data.models.audiobookshelf.LocalSessionData import com.makd.afinity.data.models.audiobookshelf.ProgressUpdateRequest import com.makd.afinity.data.models.audiobookshelf.SearchResponse import com.makd.afinity.data.network.AudiobookshelfApiService @@ -62,6 +66,7 @@ constructor( private val database: AfinityDatabase, private val networkConnectivityMonitor: NetworkConnectivityMonitor, private val addressResolver: AudiobookshelfAddressResolver, + private val absSyncScheduler: AbsProgressSyncScheduler, ) : AudiobookshelfRepository { private val audiobookshelfDao = database.audiobookshelfDao() @@ -92,8 +97,8 @@ constructor( ) if ( result is AudiobookshelfAddressResult.Success && - result.address != - securePreferencesRepository.getCachedAudiobookshelfServerUrl() + result.address != + securePreferencesRepository.getCachedAudiobookshelfServerUrl() ) { Timber.d("Audiobookshelf: Network changed, switching to ${result.address}") securePreferencesRepository.updateCachedAudiobookshelfServerUrl( @@ -125,6 +130,9 @@ constructor( _activeContextFlow.value = value } + override val currentActiveContext: Pair? + get() = activeContext + private var pendingServerUrl: String? = null companion object { @@ -135,9 +143,9 @@ constructor( val currentContext = activeContext if ( currentContext != null && - currentContext.first == serverId && - currentContext.second == userId && - _isAuthenticated.value + currentContext.first == serverId && + currentContext.second == userId && + _isAuthenticated.value ) { Timber.d("Already in Audiobookshelf context for Server: $serverId, User: $userId") return @@ -164,7 +172,7 @@ constructor( ) if ( result is AudiobookshelfAddressResult.Success && - result.address != config.serverUrl + result.address != config.serverUrl ) { Timber.d( "Audiobookshelf: Resolved to ${result.address} (config: ${config.serverUrl})" @@ -186,6 +194,8 @@ constructor( username = config.username, ) _isAuthenticated.value = true + absSyncScheduler.scheduleSync(serverId, userId) + Timber.d("Audiobookshelf authenticated via context switch — pending progress sync scheduled") } Timber.d("Audiobookshelf Context Switched. Authenticated: ${_isAuthenticated.value}") @@ -251,8 +261,8 @@ constructor( audiobookshelfDao.getConfig(currentServerId, currentUserId.toString()) if ( existingConfig != null && - existingConfig.serverUrl != serverUrl && - existingConfig.serverUrl.isNotBlank() + existingConfig.serverUrl != serverUrl && + existingConfig.serverUrl.isNotBlank() ) { val oldExists = audiobookshelfDao.getAddressByUrl( @@ -303,6 +313,8 @@ constructor( ) _isAuthenticated.value = true + absSyncScheduler.scheduleSync(currentServerId, currentUserId) + Timber.d("Audiobookshelf authenticated via login — pending progress sync scheduled") _currentConfig.value = AudiobookshelfConfig( serverUrl = serverUrl, @@ -593,8 +605,35 @@ constructor( if (!networkConnectivityMonitor.isCurrentlyConnected()) { val cached = audiobookshelfDao.getItem(itemId, currentServerId, currentUserId.toString()) + Timber.d("getItemDetails offline: itemId=$itemId serverId=$currentServerId userId=$currentUserId cached=${cached != null} hasEpisodes=${cached?.serializedEpisodes != null}") if (cached != null) { - return@withContext Result.success(cached.toLibraryItem()) + var item = cached.toLibraryItem() + if (item.media.episodes == null && cached.mediaType == "podcast") { + val downloadedEpisodes = database.absDownloadDao() + .getCompletedEpisodesForItem( + itemId, + currentServerId, + currentUserId.toString() + ) + if (downloadedEpisodes.isNotEmpty()) { + val syntheticEpisodes = downloadedEpisodes.map { dl -> + PodcastEpisode( + id = dl.episodeId!!, + title = dl.title, + duration = dl.duration, + description = dl.episodeDescription, + publishedAt = dl.publishedAt, + addedAt = dl.createdAt, + updatedAt = dl.updatedAt, + ) + } + item = + item.copy(media = item.media.copy(episodes = syntheticEpisodes)) + Timber.d("getItemDetails offline: synthesized ${syntheticEpisodes.size} episodes from downloads") + } + } + Timber.d("getItemDetails offline: returning cached item episodes=${item.media.episodes?.size}") + return@withContext Result.success(item) } return@withContext Result.failure(Exception("No network connection")) } @@ -715,13 +754,13 @@ constructor( val encodedFilter = "series." + - java.net.URLEncoder.encode( - android.util.Base64.encodeToString( - seriesId.toByteArray(), - android.util.Base64.NO_WRAP, - ), - "UTF-8", - ) + java.net.URLEncoder.encode( + android.util.Base64.encodeToString( + seriesId.toByteArray(), + android.util.Base64.NO_WRAP, + ), + "UTF-8", + ) val response = apiService @@ -857,20 +896,16 @@ constructor( isFinished = isFinished, ) - if (networkConnectivityMonitor.isCurrentlyConnected()) { + val synced = if (networkConnectivityMonitor.isCurrentlyConnected()) { val response = if (episodeId != null) { apiService.get().updateEpisodeProgress(itemId, episodeId, request) } else { apiService.get().updateProgress(itemId, request) } + response.isSuccessful + } else false - if (response.isSuccessful && response.body() != null) { - val mediaProgress = response.body()!! - cacheProgress(mediaProgress) - return@withContext Result.success(mediaProgress) - } - } val localProgress = AudiobookshelfProgressEntity( id = "${itemId}_${episodeId ?: ""}", @@ -885,7 +920,7 @@ constructor( lastUpdate = System.currentTimeMillis(), startedAt = System.currentTimeMillis(), finishedAt = if (isFinished) System.currentTimeMillis() else null, - pendingSync = true, + pendingSync = !synced, ) audiobookshelfDao.insertProgress(localProgress) @@ -913,14 +948,14 @@ constructor( return _activeContextFlow.flatMapLatest { context -> if (context == null) return@flatMapLatest flowOf(emptyMap()) val (serverId, userId) = context - audiobookshelfDao.getEpisodeProgressFlow(itemId, serverId, userId.toString()).map { - progressList -> - progressList - .mapNotNull { entity -> - entity.episodeId?.let { epId -> epId to entity.toMediaProgress() } - } - .toMap() - } + audiobookshelfDao.getEpisodeProgressFlow(itemId, serverId, userId.toString()) + .map { progressList -> + progressList + .mapNotNull { entity -> + entity.episodeId?.let { epId -> epId to entity.toMediaProgress() } + } + .toMap() + } } } @@ -943,6 +978,48 @@ constructor( ): Result { return withContext(Dispatchers.IO) { try { + val (currentServerId, currentUserId) = activeContext + ?: return@withContext Result.failure(Exception("No active session")) + var downloadEntity = if (episodeId != null) { + database.absDownloadDao().getDownloadForEpisode( + itemId, + episodeId, + currentServerId, + currentUserId.toString() + ) + } else { + database.absDownloadDao() + .getDownloadForBook(itemId, currentServerId, currentUserId.toString()) + } + Timber.d("startPlaybackSession: itemId=$itemId episodeId=$episodeId serverId=$currentServerId userId=$currentUserId downloadEntity=${downloadEntity?.id} status=${downloadEntity?.status} hasSession=${downloadEntity?.serializedSession != null}") + if (downloadEntity == null && !networkConnectivityMonitor.isCurrentlyConnected()) { + downloadEntity = database.absDownloadDao() + .getFirstCompletedEpisodeForItem( + itemId, + currentServerId, + currentUserId.toString() + ) + if (downloadEntity != null) { + Timber.d("startPlaybackSession: offline episode fallback → using downloaded episodeId=${downloadEntity.episodeId}") + } + } + if (downloadEntity?.status == AbsDownloadStatus.COMPLETED && downloadEntity.serializedSession != null) { + Timber.d("startPlaybackSession: returning local session for $itemId / ${downloadEntity.episodeId}") + var session = + json.decodeFromString(downloadEntity.serializedSession) + val savedEpisodeId = downloadEntity.episodeId + val progressEntity = if (savedEpisodeId != null) { + audiobookshelfDao.getProgressForEpisode(itemId, savedEpisodeId, currentServerId, currentUserId.toString()) + } else { + audiobookshelfDao.getProgressForItem(itemId, currentServerId, currentUserId.toString()) + } + if (progressEntity != null && progressEntity.currentTime > 0) { + Timber.d("startPlaybackSession: overriding stale currentTime=${session.currentTime} with saved progress ${progressEntity.currentTime}") + session = session.copy(currentTime = progressEntity.currentTime) + } + return@withContext Result.success(session) + } + if (!networkConnectivityMonitor.isCurrentlyConnected()) { return@withContext Result.failure(Exception("No network connection")) } @@ -1014,6 +1091,10 @@ constructor( ): Result { return withContext(Dispatchers.IO) { try { + if (sessionId.startsWith("local_")) { + return@withContext Result.success(Unit) + } + if (!networkConnectivityMonitor.isCurrentlyConnected()) { return@withContext Result.failure(Exception("No network connection")) } @@ -1048,6 +1129,10 @@ constructor( ): Result { return withContext(Dispatchers.IO) { try { + if (sessionId.startsWith("local_")) { + return@withContext Result.success(Unit) + } + if (!networkConnectivityMonitor.isCurrentlyConnected()) { return@withContext Result.failure(Exception("No network connection")) } @@ -1122,13 +1207,13 @@ constructor( val encodedFilter = "genres." + - java.net.URLEncoder.encode( - android.util.Base64.encodeToString( - genre.toByteArray(), - android.util.Base64.NO_WRAP, - ), - "UTF-8", - ) + java.net.URLEncoder.encode( + android.util.Base64.encodeToString( + genre.toByteArray(), + android.util.Base64.NO_WRAP, + ), + "UTF-8", + ) val allItems = mutableListOf() var currentPage = 0 @@ -1185,13 +1270,13 @@ constructor( val encodedFilter = "genres." + - java.net.URLEncoder.encode( - android.util.Base64.encodeToString( - genre.toByteArray(), - android.util.Base64.NO_WRAP, - ), - "UTF-8", - ) + java.net.URLEncoder.encode( + android.util.Base64.encodeToString( + genre.toByteArray(), + android.util.Base64.NO_WRAP, + ), + "UTF-8", + ) val response = apiService @@ -1221,11 +1306,10 @@ constructor( } } - override suspend fun syncPendingProgress(): Result { + override suspend fun syncPendingProgress(serverId: String, userId: UUID): Result { + val currentServerId = serverId + val currentUserId = userId 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")) } @@ -1237,41 +1321,52 @@ constructor( currentUserId.toString(), ) - var syncedCount = 0 + Timber.d("syncPendingProgress: found ${pendingProgress.size} pending records for serverId=$currentServerId userId=$currentUserId") - pendingProgress.forEach { progress -> - try { - val request = - ProgressUpdateRequest( - currentTime = progress.currentTime, - duration = progress.duration, - progress = progress.progress, - isFinished = progress.isFinished, - ) + if (pendingProgress.isEmpty()) return@withContext Result.success(0) - val response = - if (progress.episodeId != null) { - apiService - .get() - .updateEpisodeProgress( - progress.libraryItemId, - progress.episodeId, - request, - ) - } else { - apiService.get().updateProgress(progress.libraryItemId, request) - } + pendingProgress.forEach { p -> + Timber.d("syncPendingProgress: pending → itemId=${p.libraryItemId} episodeId=${p.episodeId} currentTime=${p.currentTime} duration=${p.duration}") + } - 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}") + val sessions = pendingProgress.map { progress -> + LocalSessionData( + id = "local_${progress.libraryItemId}_${progress.episodeId ?: ""}", + libraryItemId = progress.libraryItemId, + episodeId = progress.episodeId, + currentTime = progress.currentTime, + timeListening = ((progress.lastUpdate - progress.startedAt) / 1000.0).coerceAtLeast(0.0), + duration = progress.duration, + progress = progress.progress, + startedAt = progress.startedAt, + updatedAt = progress.lastUpdate, + ) + } + + val response = apiService.get().syncAllLocalSessions(BatchLocalSessionRequest(sessions)) + Timber.d("syncPendingProgress: batch response ${response.code()}") + + if (!response.isSuccessful) { + Timber.w("syncPendingProgress: FAILED ${response.code()} ${response.message()} body=${response.errorBody()?.string()}") + return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) + } + + val batchResult = response.body() + val successfulIds = batchResult?.results + ?.filter { it.success } + ?.map { it.id } + ?.toSet() + ?: emptySet() + + var syncedCount = 0 + pendingProgress.forEach { progress -> + val sessionId = "local_${progress.libraryItemId}_${progress.episodeId ?: ""}" + if (successfulIds.isEmpty() || sessionId in successfulIds) { + audiobookshelfDao.markSynced(progress.id, currentServerId, currentUserId.toString()) + syncedCount++ + Timber.d("syncPendingProgress: synced itemId=${progress.libraryItemId} episodeId=${progress.episodeId}") + } else { + Timber.w("syncPendingProgress: server rejected itemId=${progress.libraryItemId} episodeId=${progress.episodeId}") } } @@ -1342,6 +1437,7 @@ constructor( addedAt = addedAt, updatedAt = updatedAt, cachedAt = System.currentTimeMillis(), + serializedEpisodes = media.episodes?.let { json.encodeToString(it) }, ) } @@ -1354,6 +1450,14 @@ constructor( null } } + val episodes = + serializedEpisodes?.let { + try { + json.decodeFromString>(it) + } catch (e: Exception) { + null + } + } return LibraryItem( id = id, @@ -1375,6 +1479,7 @@ constructor( coverPath = coverUrl, numTracks = numTracks, numChapters = numChapters, + episodes = episodes, ), addedAt = addedAt, updatedAt = updatedAt, diff --git a/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt index c6cb544d..5136a61c 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt @@ -5,6 +5,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.makd.afinity.data.manager.SessionManager +import com.makd.afinity.data.models.GenreType import com.makd.afinity.data.models.common.CollectionType import com.makd.afinity.data.models.common.SortBy import com.makd.afinity.data.models.extensions.toAfinityBoxSet @@ -774,6 +775,77 @@ constructor( } } + override suspend fun getTopRatedByGenre( + genre: String, + type: GenreType, + limit: Int, + ): List = + withContext(Dispatchers.IO) { + return@withContext try { + val apiClient = + sessionManager.getCurrentApiClient() ?: return@withContext emptyList() + val userId = getCurrentUserId() ?: return@withContext emptyList() + val itemsApi = ItemsApi(apiClient) + val includeTypes = + when (type) { + GenreType.MOVIE -> listOf(BaseItemKind.MOVIE) + GenreType.SHOW -> listOf(BaseItemKind.SERIES) + } + val response = + itemsApi.getItems( + userId = userId, + includeItemTypes = includeTypes, + recursive = true, + genres = listOf(genre), + limit = limit, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + imageTypes = listOf(ImageType.BACKDROP), + fields = FieldSets.MEDIA_ITEM_CARDS, + enableImages = true, + enableUserData = true, + ) + response.content.items.mapNotNull { it.toAfinityItem(getBaseUrl()) } + } catch (e: ApiClientException) { + Timber.e(e, "Failed to get top-rated items for genre: $genre") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error getting top-rated items for genre: $genre") + emptyList() + } + } + + override suspend fun getTopRatedByStudio(studioName: String, limit: Int): List = + withContext(Dispatchers.IO) { + return@withContext try { + val apiClient = + sessionManager.getCurrentApiClient() ?: return@withContext emptyList() + val userId = getCurrentUserId() ?: return@withContext emptyList() + val itemsApi = ItemsApi(apiClient) + val response = + itemsApi.getItems( + userId = userId, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + recursive = true, + studios = listOf(studioName), + limit = limit, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + imageTypes = listOf(ImageType.BACKDROP), + fields = FieldSets.MEDIA_ITEM_CARDS, + enableImages = true, + enableUserData = true, + ) + response.content.items.mapNotNull { it.toAfinityItem(getBaseUrl()) } + } catch (e: ApiClientException) { + Timber.e(e, "Failed to get top-rated items for studio: $studioName") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error getting top-rated items for studio: $studioName") + emptyList() + } + } + override suspend fun getShows( parentId: UUID?, sortBy: SortBy, diff --git a/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt index d03d4cd0..59dc235f 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt @@ -1,6 +1,7 @@ package com.makd.afinity.data.repository.media import androidx.paging.PagingData +import com.makd.afinity.data.models.GenreType import com.makd.afinity.data.models.common.CollectionType import com.makd.afinity.data.models.common.SortBy import com.makd.afinity.data.models.mdblist.MdbListRating @@ -229,4 +230,8 @@ interface MediaRepository { suspend fun getEpisodeToPlayForSeason(seasonId: UUID, seriesId: UUID): AfinityEpisode? suspend fun getSeriesNextEpisode(seriesId: UUID): AfinityEpisode? + + suspend fun getTopRatedByGenre(genre: String, type: GenreType, limit: Int = 10): List + + suspend fun getTopRatedByStudio(studioName: String, limit: Int = 10): List } diff --git a/app/src/main/java/com/makd/afinity/data/workers/AbsMediaDownloadWorker.kt b/app/src/main/java/com/makd/afinity/data/workers/AbsMediaDownloadWorker.kt new file mode 100644 index 00000000..c43f1713 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/workers/AbsMediaDownloadWorker.kt @@ -0,0 +1,477 @@ +package com.makd.afinity.data.workers + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationCompat +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.makd.afinity.R +import com.makd.afinity.data.database.dao.AbsDownloadDao +import com.makd.afinity.data.database.dao.AudiobookshelfDao +import com.makd.afinity.data.database.entities.AudiobookshelfItemEntity +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus +import com.makd.afinity.data.models.audiobookshelf.AudioFile +import com.makd.afinity.data.models.audiobookshelf.AudioTrack +import com.makd.afinity.data.models.audiobookshelf.PlaybackSession +import com.makd.afinity.data.network.AudiobookshelfApiService +import com.makd.afinity.data.repository.SecurePreferencesRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsDownloadRepositoryImpl +import com.makd.afinity.di.DownloadClient +import dagger.Lazy +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.util.UUID + +@HiltWorker +class AbsMediaDownloadWorker +@AssistedInject +constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val absDownloadDao: AbsDownloadDao, + private val audiobookshelfDao: AudiobookshelfDao, + private val apiService: Lazy, + private val securePreferencesRepository: SecurePreferencesRepository, + @DownloadClient private val okHttpClient: OkHttpClient, +) : CoroutineWorker(appContext, workerParams) { + + companion object { + const val BUFFER_SIZE = 8192 + private const val NOTIFICATION_CHANNEL_ID = "abs_downloads" + private const val NOTIFICATION_CHANNEL_NAME = "Audiobook Downloads" + } + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val downloadIdStr = inputData.getString(AbsDownloadRepositoryImpl.KEY_DOWNLOAD_ID) + ?: return@withContext Result.failure(workDataOf("error" to "Missing download ID")) + val downloadId = runCatching { UUID.fromString(downloadIdStr) }.getOrElse { + return@withContext Result.failure(workDataOf("error" to "Invalid download ID")) + } + + val libraryItemId = inputData.getString(AbsDownloadRepositoryImpl.KEY_LIBRARY_ITEM_ID) + ?: return@withContext Result.failure(workDataOf("error" to "Missing libraryItemId")) + val episodeId = inputData.getString(AbsDownloadRepositoryImpl.KEY_EPISODE_ID) + + val entity = absDownloadDao.getById(downloadId) + ?: return@withContext Result.failure(workDataOf("error" to "Download record not found")) + + Timber.d("AbsDownload: entity loaded — serverId=${entity.jellyfinServerId} userId=${entity.jellyfinUserId} libraryItemId=${entity.libraryItemId} episodeId=${entity.episodeId}") + + try { + setForeground( + createForegroundInfo( + downloadId.hashCode(), + entity.title.ifEmpty { "Audiobook" }, + 0f + ) + ) + } catch (e: Exception) { + Timber.w(e, "AbsDownload: could not set foreground") + } + + absDownloadDao.updateProgress( + id = downloadId, + status = AbsDownloadStatus.DOWNLOADING, + progress = 0f, + bytesDownloaded = 0L, + tracksDownloaded = 0, + serializedSession = null, + updatedAt = System.currentTimeMillis(), + ) + + val token = securePreferencesRepository.getCachedAudiobookshelfToken() + val baseUrl = securePreferencesRepository.getCachedAudiobookshelfServerUrl()?.trimEnd('/') + ?: run { + markFailed(downloadId, "No ABS server URL available") + return@withContext Result.failure(workDataOf("error" to "No ABS server URL")) + } + + val itemResult = runCatching { + val response = apiService.get().getItem(libraryItemId, expanded = 1, include = null) + if (!response.isSuccessful || response.body() == null) { + throw Exception("Item API returned ${response.code()}: ${response.message()}") + } + response.body()!! + } + + val item = itemResult.getOrElse { e -> + Timber.e(e, "AbsDownload: failed to fetch item metadata") + markFailed(downloadId, e.message ?: "Item fetch failed") + return@withContext Result.failure(workDataOf("error" to e.message)) + } + + if (item.media != null) { + runCatching { + audiobookshelfDao.insertItem( + AudiobookshelfItemEntity( + id = item.id ?: libraryItemId, + jellyfinServerId = entity.jellyfinServerId, + jellyfinUserId = entity.jellyfinUserId, + libraryId = item.libraryId ?: "", + title = item.media.metadata.title ?: "", + authorName = item.media.metadata.authorName, + narratorName = item.media.metadata.narratorName, + seriesName = item.media.metadata.seriesName, + seriesSequence = null, + mediaType = item.mediaType ?: if (episodeId != null) "podcast" else "book", + duration = item.media.duration, + coverUrl = item.media.coverPath, + description = item.media.metadata.description, + publishedYear = item.media.metadata.publishedYear, + genres = item.media.metadata.genres?.let { json.encodeToString(it) }, + numTracks = item.media.numTracks, + numChapters = item.media.numChapters, + addedAt = item.addedAt, + updatedAt = item.updatedAt, + cachedAt = System.currentTimeMillis(), + serializedEpisodes = item.media.episodes?.let { json.encodeToString(it) }, + ) + ) + Timber.d("AbsDownload: cached item $libraryItemId for offline browsing (${item.media.episodes?.size ?: 0} episodes)") + }.onFailure { Timber.w(it, "AbsDownload: failed to cache item $libraryItemId") } + } + + val audioFilesToDownload: List + val episodeDuration: Double + val displayTitle: String + val displayAuthor: String? + + if (episodeId != null) { + val episode = item.media?.episodes?.find { it.id == episodeId } + if (episode == null) { + Timber.e("AbsDownload: episode $episodeId not found. Episodes in response: ${item.media?.episodes?.map { it.id }}") + markFailed(downloadId, "Episode $episodeId not found in item response") + return@withContext Result.failure(workDataOf("error" to "Episode not found")) + } + val audioFile = episode.audioFile + if (audioFile == null) { + Timber.e("AbsDownload: episode $episodeId has no audioFile. audioTrack=${episode.audioTrack?.contentUrl}") + markFailed(downloadId, "Episode $episodeId has no audio file (ino unavailable)") + return@withContext Result.failure(workDataOf("error" to "No audio file")) + } + Timber.d("AbsDownload: episode audioFile ino=${audioFile.ino}, filename=${audioFile.metadata.filename}") + audioFilesToDownload = listOf(audioFile) + episodeDuration = episode.duration ?: audioFile.duration ?: 0.0 + displayTitle = episode.title + displayAuthor = item.media?.metadata?.title + if (episode.description != null || episode.publishedAt != null) { + absDownloadDao.upsert( + entity.copy( + episodeDescription = episode.description, + publishedAt = episode.publishedAt, + ) + ) + } + } else { + val files = item.media?.audioFiles + ?.filter { it.invalid != true && it.exclude != true } + ?.sortedBy { it.index ?: Int.MAX_VALUE } + ?: emptyList() + if (files.isEmpty()) { + markFailed(downloadId, "No audio files in item") + return@withContext Result.failure(workDataOf("error" to "No audio files")) + } + audioFilesToDownload = files + episodeDuration = item.media?.duration ?: files.sumOf { it.duration ?: 0.0 } + displayTitle = item.media?.metadata?.title ?: entity.title + displayAuthor = item.media?.metadata?.authorName + } + + val localDirPath = entity.localDirPath ?: run { + markFailed(downloadId, "No local dir path set") + return@withContext Result.failure(workDataOf("error" to "No local dir")) + } + val localDir = File(localDirPath).also { it.mkdirs() } + + absDownloadDao.upsert( + absDownloadDao.getById(downloadId)!!.copy( + title = displayTitle, + authorName = displayAuthor, + duration = episodeDuration, + tracksTotal = audioFilesToDownload.size, + coverUrl = "$baseUrl/api/items/$libraryItemId/cover", + updatedAt = System.currentTimeMillis(), + ) + ) + + var downloadedBytesAllTracks = 0L + val localTrackPaths = mutableListOf() + + for ((index, audioFile) in audioFilesToDownload.withIndex()) { + if (isStopped) { + markFailed(downloadId, "Cancelled") + return@withContext Result.failure(workDataOf("error" to "Cancelled")) + } + + val ext = audioFileExtension(audioFile) + val outputFile = File(localDir, "track_$index.$ext.download") + val finalFile = File(localDir, "track_$index.$ext") + + if (finalFile.exists() && finalFile.length() > 0) { + Timber.d("AbsDownload: track $index already downloaded, skipping") + localTrackPaths.add(finalFile.absolutePath) + downloadedBytesAllTracks += finalFile.length() + absDownloadDao.updateProgress( + id = downloadId, + status = AbsDownloadStatus.DOWNLOADING, + progress = (index + 1).toFloat() / audioFilesToDownload.size, + bytesDownloaded = downloadedBytesAllTracks, + tracksDownloaded = index + 1, + serializedSession = null, + updatedAt = System.currentTimeMillis(), + ) + continue + } + + val downloadUrl = "$baseUrl/api/items/$libraryItemId/file/${audioFile.ino}/download" + val resumeFrom = if (outputFile.exists()) outputFile.length() else 0L + val requestBuilder = Request.Builder() + .url(downloadUrl) + .apply { + if (token != null) header("Authorization", "Bearer $token") + if (resumeFrom > 0) header("Range", "bytes=$resumeFrom-") + } + val request = requestBuilder.build() + + var trackBytesDownloaded = resumeFrom + var trackTotalBytes = 0L + + val downloadSuccess = runCatching { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful && response.code != 416) { + throw Exception("HTTP ${response.code}: ${response.message}") + } + if (response.code == 416) return@runCatching + + val contentLength = response.body?.contentLength() ?: -1L + trackTotalBytes = if (contentLength != -1L) resumeFrom + contentLength else -1L + + var lastDbUpdate = 0L + + response.body?.byteStream()?.use { input -> + FileOutputStream(outputFile, resumeFrom > 0).use { output -> + val buffer = ByteArray(BUFFER_SIZE) + var bytes: Int + while (input.read(buffer).also { bytes = it } != -1) { + if (isStopped) throw Exception("Cancelled") + output.write(buffer, 0, bytes) + trackBytesDownloaded += bytes + downloadedBytesAllTracks += bytes + + val now = System.currentTimeMillis() + if (now - lastDbUpdate > 500) { + lastDbUpdate = now + val overallProgress = + (index.toFloat() + (trackBytesDownloaded.toFloat() / trackTotalBytes.coerceAtLeast( + 1L + ))) / audioFilesToDownload.size + absDownloadDao.updateProgress( + id = downloadId, + status = AbsDownloadStatus.DOWNLOADING, + progress = overallProgress, + bytesDownloaded = downloadedBytesAllTracks, + tracksDownloaded = index, + serializedSession = null, + updatedAt = now, + ) + } + } + } + } + } + } + + if (downloadSuccess.isFailure) { + val err = downloadSuccess.exceptionOrNull()?.message ?: "Download failed" + if (err == "Cancelled") { + markFailed(downloadId, "Cancelled") + return@withContext Result.failure(workDataOf("error" to "Cancelled")) + } + markFailed(downloadId, "Track $index failed: $err") + return@withContext Result.failure(workDataOf("error" to err)) + } + + if (outputFile.exists()) outputFile.renameTo(finalFile) + localTrackPaths.add(finalFile.absolutePath) + + absDownloadDao.updateProgress( + id = downloadId, + status = AbsDownloadStatus.DOWNLOADING, + progress = (index + 1).toFloat() / audioFilesToDownload.size, + bytesDownloaded = downloadedBytesAllTracks, + tracksDownloaded = index + 1, + serializedSession = null, + updatedAt = System.currentTimeMillis(), + ) + + Timber.d("AbsDownload: track $index downloaded to ${finalFile.absolutePath}") + } + + val localCoverPath = downloadCover(libraryItemId, baseUrl, token, localDir) + + var cumulativeStart = 0.0 + val audioTracks = audioFilesToDownload.mapIndexed { index, audioFile -> + val ext = audioFileExtension(audioFile) + val localPath = File(localDir, "track_$index.$ext").absolutePath + val startOffset = cumulativeStart + cumulativeStart += audioFile.duration ?: 0.0 + AudioTrack( + index = index + 1, + startOffset = startOffset, + duration = audioFile.duration ?: 0.0, + title = audioFile.metadata.filename, + contentUrl = "file://$localPath", + mimeType = audioFile.mimeType, + codec = audioFile.codec, + metadata = audioFile.metadata, + ) + } + + val now = System.currentTimeMillis() + val localSession = PlaybackSession( + id = "local_${libraryItemId}_${episodeId ?: ""}", + userId = "", + libraryId = item.libraryId ?: "", + libraryItemId = libraryItemId, + episodeId = episodeId, + mediaType = item.mediaType ?: if (episodeId != null) "podcast" else "book", + mediaMetadata = item.media?.metadata, + chapters = if (episodeId != null) { + item.media?.episodes?.find { it.id == episodeId }?.chapters + } else { + item.media?.chapters + }, + displayTitle = displayTitle, + displayAuthor = displayAuthor, + coverPath = localCoverPath, + duration = episodeDuration, + playMethod = 0, + startTime = 0.0, + currentTime = 0.0, + startedAt = now, + updatedAt = now, + audioTracks = audioTracks, + ) + + val serializedSession = json.encodeToString(localSession) + + absDownloadDao.updateProgress( + id = downloadId, + status = AbsDownloadStatus.COMPLETED, + progress = 1f, + bytesDownloaded = downloadedBytesAllTracks, + tracksDownloaded = audioFilesToDownload.size, + serializedSession = serializedSession, + updatedAt = System.currentTimeMillis(), + ) + + Timber.d("AbsDownload: completed $libraryItemId / $episodeId — ${audioFilesToDownload.size} tracks") + Result.success() + } + + private fun downloadCover( + libraryItemId: String, + baseUrl: String, + token: String?, + localDir: File, + ): String? { + val coverFile = File(localDir, "cover.jpg") + if (coverFile.exists() && coverFile.length() > 0) { + return "file://${coverFile.absolutePath}" + } + return try { + val request = Request.Builder() + .url("$baseUrl/api/items/$libraryItemId/cover?format=jpeg") + .apply { if (token != null) header("Authorization", "Bearer $token") } + .build() + okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body?.byteStream()?.use { input -> + FileOutputStream(coverFile).use { output -> input.copyTo(output) } + } + "file://${coverFile.absolutePath}" + } else { + Timber.w("Cover download failed: ${response.code}") + null + } + } + } catch (e: Exception) { + Timber.w(e, "Failed to download cover") + null + } + } + + private fun audioFileExtension(audioFile: AudioFile): String { + val metaExt = audioFile.metadata.ext?.trimStart('.') + if (!metaExt.isNullOrBlank()) return metaExt + + val filename = audioFile.metadata.filename + if (filename.contains('.')) return filename.substringAfterLast('.') + + val subtype = audioFile.mimeType?.substringAfter("/")?.substringBefore(";") ?: return "mp3" + return when (subtype) { + "mpeg" -> "mp3" + "mp4", "x-m4a" -> "m4a" + else -> subtype + } + } + + private suspend fun markFailed(downloadId: UUID, reason: String) { + Timber.e("AbsDownload failed ($downloadId): $reason") + absDownloadDao.updateStatus( + id = downloadId, + status = AbsDownloadStatus.FAILED, + error = reason, + updatedAt = System.currentTimeMillis(), + ) + } + + private fun createForegroundInfo( + notificationId: Int, + title: String, + progress: Float + ): ForegroundInfo { + val notificationManager = + appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null) { + notificationManager.createNotificationChannel( + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + ) + ) + } + val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle("Downloading: $title") + .setProgress(100, (progress * 100).toInt(), progress == 0f) + .setOngoing(true) + .setSilent(true) + .build() + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + ForegroundInfo( + notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo(notificationId, notification) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/workers/AbsProgressSyncWorker.kt b/app/src/main/java/com/makd/afinity/data/workers/AbsProgressSyncWorker.kt new file mode 100644 index 00000000..d616759f --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/workers/AbsProgressSyncWorker.kt @@ -0,0 +1,54 @@ +package com.makd.afinity.data.workers + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsProgressSyncScheduler +import dagger.Lazy +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.UUID + +@HiltWorker +class AbsProgressSyncWorker +@AssistedInject +constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val audiobookshelfRepository: Lazy, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val serverId = inputData.getString(AbsProgressSyncScheduler.KEY_SERVER_ID) + val userIdStr = inputData.getString(AbsProgressSyncScheduler.KEY_USER_ID) + + if (serverId == null || userIdStr == null) { + Timber.w("AbsProgressSync: missing serverId/userId in input data, skipping") + return@withContext Result.failure(workDataOf("error" to "missing context")) + } + + val userId = runCatching { UUID.fromString(userIdStr) }.getOrElse { + Timber.w("AbsProgressSync: invalid userId '$userIdStr'") + return@withContext Result.failure(workDataOf("error" to "invalid userId")) + } + + Timber.d("AbsProgressSync: syncing pending progress for serverId=$serverId") + val result = audiobookshelfRepository.get().syncPendingProgress(serverId, userId) + return@withContext when { + result.isSuccess -> { + Timber.d("AbsProgressSync: synced ${result.getOrDefault(0)} items") + Result.success(workDataOf("synced" to result.getOrDefault(0))) + } + else -> { + Timber.w("AbsProgressSync: failed — ${result.exceptionOrNull()?.message}") + Result.failure(workDataOf("error" to result.exceptionOrNull()?.message)) + } + } + } +} \ 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 index 1bb7fe69..07896670 100644 --- a/app/src/main/java/com/makd/afinity/di/AudiobookshelfModule.kt +++ b/app/src/main/java/com/makd/afinity/di/AudiobookshelfModule.kt @@ -1,8 +1,11 @@ package com.makd.afinity.di import com.makd.afinity.data.database.AfinityDatabase +import com.makd.afinity.data.database.dao.AbsDownloadDao import com.makd.afinity.data.database.dao.AudiobookshelfDao import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsDownloadRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsDownloadRepositoryImpl import com.makd.afinity.data.repository.audiobookshelf.AudiobookshelfRepositoryImpl import dagger.Binds import dagger.Module @@ -21,11 +24,23 @@ abstract class AudiobookshelfModule { impl: AudiobookshelfRepositoryImpl ): AudiobookshelfRepository + @Binds + @Singleton + abstract fun bindAbsDownloadRepository( + impl: AbsDownloadRepositoryImpl + ): AbsDownloadRepository + companion object { @Provides @Singleton fun provideAudiobookshelfDao(database: AfinityDatabase): AudiobookshelfDao { return database.audiobookshelfDao() } + + @Provides + @Singleton + fun provideAbsDownloadDao(database: AfinityDatabase): AbsDownloadDao { + return database.absDownloadDao() + } } } 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 5476f009..3e43860a 100644 --- a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt +++ b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt @@ -316,6 +316,11 @@ fun MainNavigation( val route = Destination.createSettingsRoute() navController.navigate(route) }, + onAbsItemClick = { itemId -> + navController.navigate( + Destination.createAudiobookshelfItemRoute(itemId) + ) + }, navController = navController, mainUiState = mainUiState, modifier = Modifier.fillMaxSize(), @@ -746,6 +751,11 @@ fun MainNavigation( composable(Destination.DOWNLOAD_SETTINGS_ROUTE) { DownloadSettingsScreen( onBackClick = { navController.popBackStack() }, + onNavigateToAbsItem = { itemId -> + navController.navigate( + Destination.createAudiobookshelfItemRoute(itemId) + ) + }, offlineModeManager = offlineModeManager, modifier = Modifier.fillMaxSize(), ) 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 index e36b7e6d..2b884cec 100644 --- a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlaybackManager.kt +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfPlaybackManager.kt @@ -3,12 +3,12 @@ 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 javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton @Singleton class AudiobookshelfPlaybackManager @Inject constructor() { @@ -23,7 +23,9 @@ class AudiobookshelfPlaybackManager @Inject constructor() { _currentSession.value = session val coverUrl = - if (serverUrl != null) { + if (session.id.startsWith("local_") && session.coverPath != null) { + session.coverPath + } else if (serverUrl != null) { val base = "$serverUrl/api/items/${session.libraryItemId}/cover" if (token != null) "$base?token=$token" else base } else { 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 c8034a05..5aa9bea2 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 @@ -3,9 +3,9 @@ package com.makd.afinity.player.audiobookshelf import android.content.ComponentName import android.content.Context import androidx.core.net.toUri -import androidx.media3.common.Player import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken @@ -17,6 +17,7 @@ import com.makd.afinity.data.models.audiobookshelf.PlaybackSession import com.makd.afinity.data.models.audiobookshelf.PodcastEpisode import com.makd.afinity.data.repository.AudiobookshelfRepository import com.makd.afinity.data.repository.SecurePreferencesRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsProgressSyncScheduler import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,6 +41,7 @@ constructor( private val securePreferencesRepository: SecurePreferencesRepository, private val audiobookshelfRepository: AudiobookshelfRepository, private val sessionManager: SessionManager, + private val absSyncScheduler: AbsProgressSyncScheduler, ) { private var mediaController: MediaController? = null private var controllerFuture: ListenableFuture? = null @@ -108,7 +110,9 @@ constructor( val controller = getConnectedController() ?: return@launch val token = securePreferencesRepository.getCachedAudiobookshelfToken() - val isPodcastPlaylist = episodeSort != null && session.mediaType == "podcast" + val isLocalSession = session.id.startsWith("local_") + val isPodcastPlaylist = + !isLocalSession && episodeSort != null && session.mediaType == "podcast" val unsortedEpisodes = session.libraryItem?.media?.episodes ?: emptyList() val episodes = if (isPodcastPlaylist) { @@ -178,13 +182,19 @@ constructor( val mediaItems = audioTracks.mapIndexed { index, track -> val url = - if (track.contentUrl?.startsWith("http") == true) track.contentUrl + if (track.contentUrl?.startsWith("http") == true || + track.contentUrl?.startsWith("file") == true + ) track.contentUrl else "$baseUrl${track.contentUrl}" val artUrl = - if (baseUrl.isNotEmpty()) { + if (enhancedSession.id?.startsWith("local_") == true) { + enhancedSession.coverPath + } else if (baseUrl.isNotEmpty()) { "$baseUrl/api/items/${enhancedSession.libraryItemId}/cover?token=$token" - } else enhancedSession.coverPath + } else { + enhancedSession.coverPath + } val itemTitle = if (isPodcastPlaylist && track.title != null) { @@ -274,14 +284,17 @@ constructor( compareBy(cmp) { it.season ?: "" } .thenBy(cmp) { it.episode ?: "" } ) + "episode" -> episodes.sortedWith(compareBy(cmp) { it.episode ?: "" }) + "filename" -> episodes.sortedWith( compareBy(cmp) { it.audioFile?.metadata?.filename ?: "" } ) + else -> episodes } @@ -365,6 +378,7 @@ constructor( cancelSleepTimer() val state = playbackManager.playbackState.value val sessionId = state.sessionId + Timber.d("closeSession: sessionId=$sessionId itemId=${state.itemId} episodeId=${state.episodeId} currentTime=${state.currentTime} isLocal=${sessionId?.startsWith("local_")}") if (sessionId != null) { scope.launch { try { @@ -383,6 +397,28 @@ constructor( duration = state.duration } + if (sessionId.startsWith("local_")) { + val itemId = state.itemId + val episodeId = state.episodeId + val activeContext = audiobookshelfRepository.currentActiveContext + Timber.d("closeSession[local]: saving final position itemId=$itemId episodeId=$episodeId currentTime=$currentTime duration=$duration") + if (itemId != null && activeContext != null) { + val (serverId, userId) = activeContext + audiobookshelfRepository.updateProgress( + itemId = itemId, + episodeId = episodeId, + currentTime = currentTime, + duration = duration, + isFinished = duration > 0 && currentTime / duration >= 0.99, + ) + absSyncScheduler.scheduleSync(serverId, userId) + Timber.d("closeSession[local]: final position saved and sync scheduled") + } else { + Timber.w("closeSession[local]: itemId or activeContext is null, skipping final position save") + } + return@launch + } + val result = audiobookshelfRepository.closePlaybackSession( sessionId = sessionId, 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 92f4d129..4cb8cbbf 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 @@ -8,6 +8,7 @@ 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.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -61,12 +62,13 @@ class AudiobookshelfPlayerService : MediaSessionService() { super.onCreate() val token = securePreferencesRepository.getCachedAudiobookshelfToken() - val dataSourceFactory = + val httpDataSourceFactory = DefaultHttpDataSource.Factory() .setAllowCrossProtocolRedirects(true) .setDefaultRequestProperties( buildMap { if (token != null) put("Authorization", "Bearer $token") } ) + val dataSourceFactory = DefaultDataSource.Factory(this, httpDataSourceFactory) exoPlayer = ExoPlayer.Builder(this) .setAudioAttributes(AudioAttributes.DEFAULT, true) @@ -107,6 +109,7 @@ class AudiobookshelfPlayerService : MediaSessionService() { stopPositionUpdates() serviceScope.launch { progressSyncer.syncNow() } } + Player.STATE_IDLE -> { playbackManager.updateBufferingState(false) } 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 index 4464755e..efbbc8b1 100644 --- a/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfProgressSyncer.kt +++ b/app/src/main/java/com/makd/afinity/player/audiobookshelf/AudiobookshelfProgressSyncer.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsProgressSyncScheduler import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,6 +23,7 @@ constructor( @param:ApplicationContext private val context: Context, private val audiobookshelfRepository: AudiobookshelfRepository, private val playbackManager: AudiobookshelfPlaybackManager, + private val absSyncScheduler: AbsProgressSyncScheduler, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var syncJob: Job? = null @@ -86,6 +88,22 @@ constructor( totalTimeListened += timeListenedSinceLastSync lastSyncTime = currentTime + if (sessionId.startsWith("local_")) { + val itemId = state.itemId ?: return + val (serverId, userId) = audiobookshelfRepository.currentActiveContext ?: return + Timber.d("ProgressSync[audiobook]: saving offline progress itemId=$itemId episodeId=${state.episodeId} currentTime=$currentTime duration=${state.duration}") + audiobookshelfRepository.updateProgress( + itemId = itemId, + episodeId = state.episodeId, + currentTime = currentTime, + duration = state.duration, + isFinished = state.duration > 0 && currentTime / state.duration >= 0.99, + ) + absSyncScheduler.scheduleSync(serverId, userId) + Timber.d("ProgressSync[audiobook]: offline progress saved and sync scheduled") + return + } + try { val result = audiobookshelfRepository.syncPlaybackSession( @@ -123,6 +141,22 @@ constructor( val timeListenedSinceLastSync = (state.currentTime - lastSyncTime).coerceAtLeast(0.0) lastSyncTime = state.currentTime + if (sessionId.startsWith("local_")) { + val itemId = state.itemId ?: return + val (serverId, userId) = audiobookshelfRepository.currentActiveContext ?: return + Timber.d("ProgressSync[podcast]: saving offline progress itemId=$itemId episodeId=$episodeId currentTime=$episodeCurrentTime duration=$episodeDuration") + audiobookshelfRepository.updateProgress( + itemId = itemId, + episodeId = episodeId, + currentTime = episodeCurrentTime, + duration = episodeDuration, + isFinished = episodeDuration > 0 && episodeCurrentTime / episodeDuration >= 0.99, + ) + absSyncScheduler.scheduleSync(serverId, userId) + Timber.d("ProgressSync[podcast]: offline progress saved and sync scheduled") + return + } + try { val result = audiobookshelfRepository.syncPlaybackSession( @@ -136,7 +170,7 @@ constructor( onSuccess = { Timber.d( "Playlist progress synced: episode=$episodeId, " + - "${episodeCurrentTime}s / ${episodeDuration}s" + "${episodeCurrentTime}s / ${episodeDuration}s" ) }, onFailure = { error -> Timber.w(error, "Failed to sync playlist progress") }, 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 0ad4e21d..bbeb8760 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 @@ -60,14 +60,15 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.makd.afinity.R +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus +import com.makd.afinity.ui.audiobookshelf.item.components.ChapterListDialog +import com.makd.afinity.ui.audiobookshelf.item.components.EpisodeListDialog import com.makd.afinity.ui.audiobookshelf.item.components.ExpandableSynopsis import com.makd.afinity.ui.audiobookshelf.item.components.IncludedInSeriesSection import com.makd.afinity.ui.audiobookshelf.item.components.ItemDetailsSection 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 -import com.makd.afinity.ui.audiobookshelf.item.components.ChapterListDialog -import com.makd.afinity.ui.audiobookshelf.item.components.EpisodeListDialog import com.makd.afinity.ui.audiobookshelf.item.components.chapterListItems import com.makd.afinity.ui.audiobookshelf.item.components.episodeListItems @@ -110,6 +111,9 @@ fun AudiobookshelfItemScreen( val progress by viewModel.progress.collectAsStateWithLifecycle() val config by viewModel.currentConfig.collectAsStateWithLifecycle() val episodeProgressMap by viewModel.episodeProgressMap.collectAsStateWithLifecycle() + val downloadInfo by viewModel.downloadInfo.collectAsStateWithLifecycle() + val episodeDownloadMap by viewModel.episodeDownloadMap.collectAsStateWithLifecycle() + val isOffline by viewModel.isOffline.collectAsStateWithLifecycle() val isPodcast = item?.mediaType?.lowercase() == "podcast" val configuration = LocalConfiguration.current @@ -124,77 +128,84 @@ fun AudiobookshelfItemScreen( var expandedEpisodeId by remember { mutableStateOf(null) } val sortedEpisodes by - remember(uiState.episodes, sortOption, sortAscending) { - derivedStateOf { - val episodes = uiState.episodes - val cmp = naturalOrderComparator - val sorted = - when (sortOption) { - EpisodeSortOption.PUB_DATE -> episodes.sortedBy { it.publishedAt ?: 0L } - EpisodeSortOption.TITLE -> - episodes.sortedWith( - compareBy< + remember(uiState.episodes, sortOption, sortAscending, isOffline, episodeDownloadMap) { + derivedStateOf { + val episodes = if (isOffline) { + uiState.episodes.filter { episodeDownloadMap[it.id]?.status == AbsDownloadStatus.COMPLETED } + } else { + uiState.episodes + } + val cmp = naturalOrderComparator + val sorted = + when (sortOption) { + EpisodeSortOption.PUB_DATE -> episodes.sortedBy { it.publishedAt ?: 0L } + EpisodeSortOption.TITLE -> + episodes.sortedWith( + compareBy< com.makd.afinity.data.models.audiobookshelf.PodcastEpisode, String, - >( - cmp - ) { - it.title - } - ) - EpisodeSortOption.SEASON -> - episodes.sortedWith( - compareBy< - com.makd.afinity.data.models.audiobookshelf.PodcastEpisode, - String, >( - cmp - ) { - it.season ?: "" - } - .thenBy(cmp) { it.episode ?: "" } - ) - EpisodeSortOption.EPISODE -> - episodes.sortedWith( - compareBy< + cmp + ) { + it.title + } + ) + + EpisodeSortOption.SEASON -> + episodes.sortedWith( + compareBy< com.makd.afinity.data.models.audiobookshelf.PodcastEpisode, String, - >( - cmp - ) { - it.episode ?: "" - } - ) - EpisodeSortOption.FILENAME -> - episodes.sortedWith( - compareBy< + >( + cmp + ) { + it.season ?: "" + } + .thenBy(cmp) { it.episode ?: "" } + ) + + EpisodeSortOption.EPISODE -> + episodes.sortedWith( + compareBy< com.makd.afinity.data.models.audiobookshelf.PodcastEpisode, String, - >( - cmp - ) { - it.audioFile?.metadata?.filename ?: "" - } - ) - } - if (sortAscending) sorted else sorted.reversed() - } + >( + cmp + ) { + it.episode ?: "" + } + ) + + EpisodeSortOption.FILENAME -> + episodes.sortedWith( + compareBy< + com.makd.afinity.data.models.audiobookshelf.PodcastEpisode, + String, + >( + cmp + ) { + it.audioFile?.metadata?.filename ?: "" + } + ) + } + if (sortAscending) sorted else sorted.reversed() } + } val currentSortParam by - remember(sortOption, sortAscending) { - derivedStateOf { - val key = - when (sortOption) { - EpisodeSortOption.PUB_DATE -> "pub_date" - EpisodeSortOption.TITLE -> "title" - EpisodeSortOption.SEASON -> "season" - EpisodeSortOption.EPISODE -> "episode" - EpisodeSortOption.FILENAME -> "filename" - } - "${key}_${if (sortAscending) "asc" else "desc"}" - } + remember(sortOption, sortAscending) { + derivedStateOf { + val key = + when (sortOption) { + EpisodeSortOption.PUB_DATE -> "pub_date" + EpisodeSortOption.TITLE -> "title" + EpisodeSortOption.SEASON -> "season" + EpisodeSortOption.EPISODE -> "episode" + EpisodeSortOption.FILENAME -> "filename" + } + "${key}_${if (sortAscending) "asc" else "desc"}" } + } Box(modifier = Modifier.fillMaxSize()) { when { @@ -213,11 +224,14 @@ fun AudiobookshelfItemScreen( Row( modifier = - Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.displayCutout) + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.displayCutout) ) { Column( modifier = - Modifier.weight(1f) + Modifier + .weight(1f) .fillMaxHeight() .verticalScroll(rememberScrollState()) .padding(bottom = 24.dp) @@ -228,8 +242,9 @@ fun AudiobookshelfItemScreen( coverUrl = coverUrl, onPlay = { val resumeEpisodeId = if (isPodcast) { + val downloadedIds = sortedEpisodes.map { it.id }.toSet() episodeProgressMap.values - .filter { !it.isFinished && it.currentTime > 0 } + .filter { !it.isFinished && it.currentTime > 0 && (!isOffline || it.episodeId in downloadedIds) } .maxByOrNull { it.lastUpdate } ?.episodeId ?: sortedEpisodes.firstOrNull()?.id @@ -241,12 +256,17 @@ fun AudiobookshelfItemScreen( if (isPodcast) currentSortParam else null, ) }, + downloadInfo = if (!isPodcast) downloadInfo else null, + onDownload = if (!isPodcast) ({ viewModel.startDownload() }) else null, + onCancelDownload = if (!isPodcast) ({ viewModel.cancelDownload() }) else null, + onDeleteDownload = if (!isPodcast) ({ viewModel.deleteDownload() }) else null, ) } LazyColumn( modifier = - Modifier.weight(1f) + Modifier + .weight(1f) .fillMaxHeight() .background( MaterialTheme.colorScheme.surface.copy(alpha = 0.5f) @@ -329,6 +349,10 @@ fun AudiobookshelfItemScreen( expandedEpisodeId = expandedEpisodeId, onExpandEpisode = { expandedEpisodeId = it }, episodeProgressMap = episodeProgressMap, + episodeDownloadMap = episodeDownloadMap, + onEpisodeDownload = { viewModel.startDownload(it) }, + onEpisodeCancelDownload = { viewModel.cancelDownload(it) }, + onEpisodeDeleteDownload = { viewModel.deleteDownload(it) }, ) item { @@ -369,8 +393,9 @@ fun AudiobookshelfItemScreen( serverUrl = config?.serverUrl, onPlay = { val resumeEpisodeId = if (isPodcast) { + val downloadedIds = sortedEpisodes.map { it.id }.toSet() episodeProgressMap.values - .filter { !it.isFinished && it.currentTime > 0 } + .filter { !it.isFinished && it.currentTime > 0 && (!isOffline || it.episodeId in downloadedIds) } .maxByOrNull { it.lastUpdate } ?.episodeId ?: sortedEpisodes.firstOrNull()?.id @@ -382,6 +407,10 @@ fun AudiobookshelfItemScreen( if (isPodcast) currentSortParam else null, ) }, + downloadInfo = if (!isPodcast) downloadInfo else null, + onDownload = if (!isPodcast) ({ viewModel.startDownload() }) else null, + onCancelDownload = if (!isPodcast) ({ viewModel.cancelDownload() }) else null, + onDeleteDownload = if (!isPodcast) ({ viewModel.deleteDownload() }) else null, ) } @@ -424,7 +453,11 @@ fun AudiobookshelfItemScreen( item { ItemDetailsSection( item = item!!, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp + ), ) } @@ -468,7 +501,8 @@ fun AudiobookshelfItemScreen( ) { Card( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(16.dp) .padding(WindowInsets.navigationBars.asPaddingValues()), colors = @@ -516,6 +550,10 @@ fun AudiobookshelfItemScreen( episodeProgressMap = episodeProgressMap, onDismiss = { showListDialog = false }, onSortClick = { showSortDialog = true }, + episodeDownloadMap = episodeDownloadMap, + onEpisodeDownload = { viewModel.startDownload(it) }, + onEpisodeCancelDownload = { viewModel.cancelDownload(it) }, + onEpisodeDeleteDownload = { viewModel.deleteDownload(it) }, ) } else if (uiState.chapters.isNotEmpty()) { ChapterListDialog( @@ -714,7 +752,10 @@ private fun EpisodeSortDialog( @Composable private fun EpisodeSortOptionRow(label: String, selected: Boolean, onClick: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { RadioButton(selected = selected, onClick = onClick) 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 index 4658bd5c..45cf1061 100644 --- 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 @@ -3,21 +3,25 @@ package com.makd.afinity.ui.audiobookshelf.item import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.manager.OfflineModeManager +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo 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.models.audiobookshelf.SeriesItem import com.makd.afinity.data.repository.AudiobookshelfRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsDownloadRepository import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject @HiltViewModel class AudiobookshelfItemViewModel @@ -25,6 +29,8 @@ class AudiobookshelfItemViewModel constructor( savedStateHandle: SavedStateHandle, private val audiobookshelfRepository: AudiobookshelfRepository, + private val absDownloadRepository: AbsDownloadRepository, + private val offlineModeManager: OfflineModeManager, ) : ViewModel() { val itemId: String = savedStateHandle.get("itemId") ?: "" @@ -47,6 +53,54 @@ constructor( val currentConfig = audiobookshelfRepository.currentConfig + val isOffline: StateFlow = + offlineModeManager.isOffline + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val downloadInfo: StateFlow = + absDownloadRepository.getActiveDownloadsFlow() + .combine(absDownloadRepository.getCompletedDownloadsFlow()) { active, completed -> + (active + completed).find { it.libraryItemId == itemId && it.episodeId == null } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val episodeDownloadMap: StateFlow> = + absDownloadRepository.getActiveDownloadsFlow() + .combine(absDownloadRepository.getCompletedDownloadsFlow()) { active, completed -> + (active + completed) + .filter { it.libraryItemId == itemId && it.episodeId != null } + .associateBy { it.episodeId!! } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + fun startDownload(episodeId: String? = null) { + viewModelScope.launch { + absDownloadRepository.startDownload(itemId, episodeId) + .onFailure { Timber.e(it, "Failed to start download") } + } + } + + fun cancelDownload(episodeId: String? = null) { + viewModelScope.launch { + val info = if (episodeId == null) downloadInfo.value + else episodeDownloadMap.value[episodeId] + info?.let { + absDownloadRepository.cancelDownload(it.id) + .onFailure { e -> Timber.e(e, "Failed to cancel download") } + } + } + } + + fun deleteDownload(episodeId: String? = null) { + viewModelScope.launch { + val info = if (episodeId == null) downloadInfo.value + else episodeDownloadMap.value[episodeId] + info?.let { + absDownloadRepository.deleteDownload(it.id) + .onFailure { e -> Timber.e(e, "Failed to delete download") } + } + } + } + init { loadItem() } 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 cf7bf1ca..dfa38fff 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 @@ -46,6 +46,8 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.core.text.HtmlCompat import com.makd.afinity.R +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus import com.makd.afinity.data.models.audiobookshelf.MediaProgress import com.makd.afinity.data.models.audiobookshelf.PodcastEpisode import java.text.SimpleDateFormat @@ -60,17 +62,27 @@ fun LazyListScope.episodeListItems( expandedEpisodeId: String?, onExpandEpisode: (String?) -> Unit, episodeProgressMap: Map = emptyMap(), + episodeDownloadMap: Map = emptyMap(), + onEpisodeDownload: ((String) -> Unit)? = null, + onEpisodeCancelDownload: ((String) -> Unit)? = null, + onEpisodeDeleteDownload: ((String) -> Unit)? = null, ) { items(items = episodes, key = { it.id }) { episode -> val isExpanded = expandedEpisodeId == episode.id - Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + Box(modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp)) { ExpandableEpisodeItem( episode = episode, isExpanded = isExpanded, onPlay = { onEpisodePlay(episode) }, onExpandToggle = { onExpandEpisode(if (isExpanded) null else episode.id) }, progress = episodeProgressMap[episode.id], + downloadInfo = episodeDownloadMap[episode.id], + onDownload = onEpisodeDownload?.let { { it(episode.id) } }, + onCancelDownload = onEpisodeCancelDownload?.let { { it(episode.id) } }, + onDeleteDownload = onEpisodeDeleteDownload?.let { { it(episode.id) } }, ) } } @@ -83,10 +95,15 @@ private fun ExpandableEpisodeItem( onPlay: () -> Unit, onExpandToggle: () -> Unit, progress: MediaProgress? = null, + downloadInfo: AbsDownloadInfo? = null, + onDownload: (() -> Unit)? = null, + onCancelDownload: (() -> Unit)? = null, + onDeleteDownload: (() -> Unit)? = null, ) { Row( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .clickable { onExpandToggle() } .padding(12.dp) @@ -173,35 +190,93 @@ private fun ExpandableEpisodeItem( Spacer(modifier = Modifier.width(12.dp)) - if (progress != null && progress.isFinished) { - Box( - modifier = - Modifier.size(40.dp) - .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_check), - contentDescription = "Finished", - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(24.dp), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (progress != null && progress.isFinished) { + Box( + modifier = + Modifier + .size(40.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = "Finished", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(24.dp), + ) + } + } else { + FilledIconButton( + onClick = onPlay, + modifier = Modifier.size(40.dp), + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_player_play_filled), + contentDescription = "Play episode", + modifier = Modifier.size(24.dp), + ) + } } - } else { - FilledIconButton( - onClick = onPlay, - modifier = Modifier.size(40.dp), - colors = - IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.primary, - ), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_player_play_filled), - contentDescription = "Play episode", - modifier = Modifier.size(24.dp), - ) + + if (onDownload != null || downloadInfo != null) { + when { + downloadInfo?.status == AbsDownloadStatus.COMPLETED -> + IconButton( + onClick = { onDeleteDownload?.invoke() }, + modifier = Modifier.size(32.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = "Downloaded — tap to delete", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + + downloadInfo?.status == AbsDownloadStatus.DOWNLOADING || + downloadInfo?.status == AbsDownloadStatus.QUEUED -> { + Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = { downloadInfo.progress }, + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + IconButton( + onClick = { onCancelDownload?.invoke() }, + modifier = Modifier.size(32.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_cancel), + contentDescription = "Cancel download", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + } + } + + else -> + IconButton( + onClick = { onDownload?.invoke() }, + modifier = Modifier.size(32.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_download), + contentDescription = "Download episode", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } } } } @@ -269,10 +344,16 @@ fun EpisodeListDialog( episodeProgressMap: Map, onDismiss: () -> Unit, onSortClick: () -> Unit, + episodeDownloadMap: Map = emptyMap(), + onEpisodeDownload: ((String) -> Unit)? = null, + onEpisodeCancelDownload: ((String) -> Unit)? = null, + onEpisodeDeleteDownload: ((String) -> Unit)? = null, ) { Dialog(onDismissRequest = onDismiss) { Surface( - modifier = Modifier.fillMaxWidth().height(600.dp), + modifier = Modifier + .fillMaxWidth() + .height(600.dp), shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 6.dp, @@ -280,7 +361,8 @@ fun EpisodeListDialog( Column(modifier = Modifier.fillMaxSize()) { Row( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -308,7 +390,9 @@ fun EpisodeListDialog( } } } - LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn(modifier = Modifier + .fillMaxWidth() + .weight(1f)) { episodeListItems( episodes = episodes, onEpisodePlay = { episode -> @@ -318,6 +402,10 @@ fun EpisodeListDialog( expandedEpisodeId = expandedEpisodeId, onExpandEpisode = onExpandEpisode, episodeProgressMap = episodeProgressMap, + episodeDownloadMap = episodeDownloadMap, + onEpisodeDownload = onEpisodeDownload, + onEpisodeCancelDownload = onEpisodeCancelDownload, + onEpisodeDeleteDownload = onEpisodeDeleteDownload, ) } } 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 6f1187e6..22270c68 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 @@ -9,9 +9,11 @@ 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.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 @@ -29,6 +31,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -68,6 +72,10 @@ fun ItemHeader( progress: MediaProgress?, serverUrl: String?, onPlay: () -> Unit, + downloadInfo: AbsDownloadInfo? = null, + onDownload: (() -> Unit)? = null, + onCancelDownload: (() -> Unit)? = null, + onDeleteDownload: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val coverUrl = @@ -80,7 +88,16 @@ fun ItemHeader( ItemHeroBackground(coverUrl = coverUrl) } - ItemHeaderContent(item = item, progress = progress, coverUrl = coverUrl, onPlay = onPlay) + ItemHeaderContent( + item = item, + progress = progress, + coverUrl = coverUrl, + onPlay = onPlay, + downloadInfo = downloadInfo, + onDownload = onDownload, + onCancelDownload = onCancelDownload, + onDeleteDownload = onDeleteDownload, + ) } } @@ -127,6 +144,10 @@ fun ItemHeaderContent( progress: MediaProgress?, coverUrl: String?, onPlay: () -> Unit, + downloadInfo: AbsDownloadInfo? = null, + onDownload: (() -> Unit)? = null, + onCancelDownload: (() -> Unit)? = null, + onDeleteDownload: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { Column( @@ -354,37 +375,113 @@ fun ItemHeaderContent( Spacer(modifier = Modifier.height(24.dp)) - Button( - onClick = onPlay, + val showSplit = onDownload != null + val primaryColors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) + + Row( modifier = Modifier.fillMaxWidth().height(56.dp), - shape = RoundedCornerShape(28.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + Button( + onClick = onPlay, + modifier = Modifier.weight(1f).fillMaxHeight(), + shape = if (showSplit) + RoundedCornerShape(topStart = 28.dp, bottomStart = 28.dp) + else + RoundedCornerShape(28.dp), + colors = primaryColors, + elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp), ) { - Icon( - painter = painterResource(id = R.drawable.ic_player_play_filled), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = - when { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_player_play_filled), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = when { progress != null && progress.isFinished -> "Listen Again" progress != null && progress.progress > 0 -> "Continue Listening" else -> "Play" }, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + } + + if (showSplit) { + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.25f)) ) + + Button( + onClick = { + when (downloadInfo?.status) { + AbsDownloadStatus.QUEUED, + AbsDownloadStatus.DOWNLOADING -> onCancelDownload?.invoke() + AbsDownloadStatus.COMPLETED -> onDeleteDownload?.invoke() + else -> onDownload?.invoke() + } + }, + modifier = Modifier.width(64.dp).fillMaxHeight(), + shape = RoundedCornerShape(topEnd = 28.dp, bottomEnd = 28.dp), + colors = primaryColors, + elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp), + contentPadding = PaddingValues(0.dp), + ) { + when (downloadInfo?.status) { + AbsDownloadStatus.DOWNLOADING -> { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = { downloadInfo.progress }, + modifier = Modifier.size(30.dp), + strokeWidth = 2.5.dp, + color = MaterialTheme.colorScheme.onPrimary, + trackColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.3f), + ) + Icon( + painter = painterResource(id = R.drawable.ic_cancel), + contentDescription = "Cancel download", + modifier = Modifier.size(14.dp), + ) + } + } + AbsDownloadStatus.QUEUED -> { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + trackColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.3f), + ) + } + AbsDownloadStatus.COMPLETED -> { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = "Delete download", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.errorContainer, + ) + } + else -> { + Icon( + painter = painterResource(id = R.drawable.ic_download), + contentDescription = "Download", + modifier = Modifier.size(22.dp), + ) + } + } + } } } } diff --git a/app/src/main/java/com/makd/afinity/ui/downloads/DownloadsViewModel.kt b/app/src/main/java/com/makd/afinity/ui/downloads/DownloadsViewModel.kt index 66c71ee3..e3a8173f 100644 --- a/app/src/main/java/com/makd/afinity/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/downloads/DownloadsViewModel.kt @@ -4,9 +4,11 @@ import android.os.Environment import android.os.StatFs import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo import com.makd.afinity.data.models.download.DownloadInfo import com.makd.afinity.data.models.download.DownloadStatus import com.makd.afinity.data.repository.PreferencesRepository +import com.makd.afinity.data.repository.audiobookshelf.AbsDownloadRepository import com.makd.afinity.data.repository.download.DownloadRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +27,7 @@ class DownloadsViewModel @Inject constructor( private val downloadRepository: DownloadRepository, + private val absDownloadRepository: AbsDownloadRepository, private val preferencesRepository: PreferencesRepository, ) : ViewModel() { @@ -115,6 +118,32 @@ constructor( Timber.e(e, "Failed to observe completed downloads") } } + + viewModelScope.launch { + try { + absDownloadRepository + .getActiveDownloadsFlow() + .catch { e -> Timber.e(e, "Error observing ABS active downloads") } + .collect { absActive -> + _uiState.value = _uiState.value.copy(absActiveDownloads = absActive) + } + } catch (e: Exception) { + Timber.e(e, "Failed to observe ABS active downloads") + } + } + + viewModelScope.launch { + try { + absDownloadRepository + .getCompletedDownloadsFlow() + .catch { e -> Timber.e(e, "Error observing ABS completed downloads") } + .collect { absCompleted -> + _uiState.value = _uiState.value.copy(absCompletedDownloads = absCompleted) + } + } catch (e: Exception) { + Timber.e(e, "Failed to observe ABS completed downloads") + } + } } private fun loadStorageInfo() { @@ -209,6 +238,38 @@ constructor( } } + fun cancelAbsDownload(downloadId: UUID) { + viewModelScope.launch { + try { + absDownloadRepository.cancelDownload(downloadId) + .onFailure { Timber.e(it, "Failed to cancel ABS download") } + } catch (e: Exception) { + Timber.e(e, "Error cancelling ABS download") + } + } + } + + fun deleteAbsDownload(downloadId: UUID) { + viewModelScope.launch { + try { + absDownloadRepository.deleteDownload(downloadId) + .onSuccess { loadStorageInfo() } + .onFailure { Timber.e(it, "Failed to delete ABS download") } + } catch (e: Exception) { + Timber.e(e, "Error deleting ABS download") + } + } + } + + fun deleteAbsPodcast(libraryItemId: String) { + viewModelScope.launch { + uiState.value.absCompletedDownloads + .filter { it.libraryItemId == libraryItemId } + .forEach { absDownloadRepository.deleteDownload(it.id) } + loadStorageInfo() + } + } + fun clearError() { _uiState.value = _uiState.value.copy(error = null) } @@ -273,6 +334,8 @@ constructor( data class DownloadsUiState( val activeDownloads: List = emptyList(), val completedDownloads: List = emptyList(), + val absActiveDownloads: List = emptyList(), + val absCompletedDownloads: List = emptyList(), val totalStorageUsed: Long = 0L, val totalStorageUsedAllServers: Long = 0L, val downloadOverWifiOnly: Boolean = true, diff --git a/app/src/main/java/com/makd/afinity/ui/home/HomeScreen.kt b/app/src/main/java/com/makd/afinity/ui/home/HomeScreen.kt index a06b84f6..177398cf 100644 --- a/app/src/main/java/com/makd/afinity/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/home/HomeScreen.kt @@ -60,6 +60,8 @@ import com.makd.afinity.navigation.Destination import com.makd.afinity.ui.components.AfinityTopAppBar import com.makd.afinity.ui.components.HeroCarousel import com.makd.afinity.ui.home.components.ContinueWatchingSkeleton +import com.makd.afinity.ui.home.components.SpotlightCarousel +import com.makd.afinity.ui.home.components.DownloadedAudiobooksSection import com.makd.afinity.ui.home.components.GenreSection import com.makd.afinity.ui.home.components.HighestRatedSection import com.makd.afinity.ui.home.components.LibrariesSection @@ -91,6 +93,7 @@ fun HomeScreen( navController: NavController, viewModel: HomeViewModel = hiltViewModel(), widthSizeClass: WindowWidthSizeClass, + onAbsItemClick: (String) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current @@ -168,8 +171,10 @@ fun HomeScreen( !uiState.isOffline && uiState.isLoading && uiState.latestMedia.isNotEmpty() -> "cw_skeleton" - uiState.downloadedMovies.isNotEmpty() -> "downloaded_movies" - uiState.downloadedShows.isNotEmpty() -> "downloaded_shows" + uiState.isOffline && uiState.downloadedMovies.isNotEmpty() -> "downloaded_movies" + uiState.isOffline && uiState.downloadedShows.isNotEmpty() -> "downloaded_shows" + uiState.isOffline && uiState.downloadedAudiobooks.isNotEmpty() -> "downloaded_audiobooks" + uiState.isOffline && uiState.downloadedPodcastEpisodes.isNotEmpty() -> "downloaded_podcasts" !uiState.isOffline && uiState.nextUp.isNotEmpty() -> "next_up" else -> null } @@ -265,7 +270,7 @@ fun HomeScreen( } } - if (uiState.downloadedMovies.isNotEmpty()) { + if (uiState.isOffline && uiState.downloadedMovies.isNotEmpty()) { item(key = "downloaded_movies") { Box(modifier = getItemModifier("downloaded_movies")) { Column { @@ -281,7 +286,7 @@ fun HomeScreen( } } - if (uiState.downloadedShows.isNotEmpty()) { + if (uiState.isOffline && uiState.downloadedShows.isNotEmpty()) { item(key = "downloaded_shows") { Box(modifier = getItemModifier("downloaded_shows")) { Column { @@ -297,6 +302,36 @@ fun HomeScreen( } } + if (uiState.isOffline && uiState.downloadedAudiobooks.isNotEmpty()) { + item(key = "downloaded_audiobooks") { + Box(modifier = getItemModifier("downloaded_audiobooks")) { + Column { + Spacer(modifier = Modifier.height(24.dp)) + DownloadedAudiobooksSection( + title = "Downloaded Audiobooks", + items = uiState.downloadedAudiobooks, + onItemClick = { onAbsItemClick(it.libraryItemId) }, + ) + } + } + } + } + + if (uiState.isOffline && uiState.downloadedPodcastEpisodes.isNotEmpty()) { + item(key = "downloaded_podcasts") { + Box(modifier = getItemModifier("downloaded_podcasts")) { + Column { + Spacer(modifier = Modifier.height(24.dp)) + DownloadedAudiobooksSection( + title = "Downloaded Episodes", + items = uiState.downloadedPodcastEpisodes, + onItemClick = { onAbsItemClick(it.libraryItemId) }, + ) + } + } + } + } + if (!uiState.isOffline && uiState.nextUp.isNotEmpty()) { item(key = "next_up") { Box(modifier = getItemModifier("next_up")) { @@ -433,6 +468,8 @@ fun HomeScreen( "person_movie_${section.section.hashCode()}" is HomeSection.Genre -> "genre_${section.genreItem.name}_${section.genreItem.type}" + is HomeSection.Spotlight -> + "spotlight_${section.title}" } }, ) { section -> @@ -463,6 +500,15 @@ fun HomeScreen( ) } + is HomeSection.Spotlight -> { + SpotlightCarousel( + title = section.title, + items = section.items, + onItemClick = onItemClick, + onPlayClick = onPlayClick, + ) + } + is HomeSection.Genre -> { when (section.genreItem.type) { GenreType.MOVIE -> { diff --git a/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt b/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt index 4c6cea5d..461cd98d 100644 --- a/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt @@ -14,9 +14,11 @@ import com.makd.afinity.data.manager.OfflineModeManager import com.makd.afinity.data.manager.PlaybackEvent import com.makd.afinity.data.manager.PlaybackStateManager import com.makd.afinity.data.models.GenreItem +import com.makd.afinity.data.models.GenreType import com.makd.afinity.data.models.MovieSection import com.makd.afinity.data.models.PersonFromMovieSection import com.makd.afinity.data.models.PersonSection +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo import com.makd.afinity.data.models.download.DownloadInfo import com.makd.afinity.data.models.extensions.toAfinityMovie import com.makd.afinity.data.models.media.AfinityCollection @@ -31,6 +33,7 @@ import com.makd.afinity.data.models.media.toAfinityEpisode import com.makd.afinity.data.repository.AppDataRepository import com.makd.afinity.data.repository.DatabaseRepository import com.makd.afinity.data.repository.FieldSets +import com.makd.afinity.data.repository.audiobookshelf.AbsDownloadRepository import com.makd.afinity.data.repository.auth.AuthRepository import com.makd.afinity.data.repository.download.DownloadRepository import com.makd.afinity.data.repository.media.MediaRepository @@ -73,6 +76,7 @@ constructor( private val watchlistRepository: WatchlistRepository, private val databaseRepository: DatabaseRepository, private val downloadRepository: DownloadRepository, + private val absDownloadRepository: AbsDownloadRepository, private val offlineModeManager: OfflineModeManager, private val authRepository: AuthRepository, private val mediaRepository: MediaRepository, @@ -85,6 +89,7 @@ constructor( val uiState: StateFlow = _uiState.asStateFlow() private val loadedRecommendationSections = mutableListOf() + private val loadedSpotlightSections = mutableListOf() private var cachedShuffledGenres: List = emptyList() @@ -106,6 +111,7 @@ constructor( "Data cleared detected (Session Switch/Clear), resetting HomeViewModel UI state" ) loadedRecommendationSections.clear() + loadedSpotlightSections.clear() cachedShuffledGenres = emptyList() renderedPeopleNames.clear() renderedItemIds.clear() @@ -118,10 +124,14 @@ constructor( Timber.d( "Initial Data Loaded: Triggering secondary content load (Studios, Genres, Recs)" ) - launch { loadStudios() } - launch { loadCombinedGenres() } - loadNewHomescreenSections() - launch { loadDownloadedContent() } + launch { + coroutineScope { + launch { loadStudios() } + launch { loadCombinedGenres() } + } + loadNewHomescreenSections() + loadDownloadedContent() + } } } } @@ -278,6 +288,7 @@ constructor( played = isPlayed, ) } + is AfinityMovie -> { val runtime = cwItem.runtimeTicks ?: 0L val isPlayed = @@ -288,24 +299,26 @@ constructor( played = isPlayed, ) } + else -> cwItem } appDataRepository.updatePlaybackProgressLocally(optimisticItem) } } } + is PlaybackEvent.Synced -> { Timber.d("HomeViewModel received sync for ${event.itemId}") - val syncedItem = - mediaRepository.getItemById(event.itemId) ?: return@collect + val syncedItem = mediaRepository.getItemById(event.itemId) ?: return@collect appDataRepository.updatePlaybackProgressLocally(syncedItem) val targetItem = when (syncedItem) { is AfinityEpisode -> mediaRepository.getItemById(syncedItem.seriesId) - is AfinitySeason -> - mediaRepository.getItemById(syncedItem.seriesId) + + is AfinitySeason -> mediaRepository.getItemById(syncedItem.seriesId) + else -> syncedItem } ?: return@collect @@ -345,8 +358,21 @@ constructor( finalLayout.add(recIterator.next()) } + if (loadedSpotlightSections.isNotEmpty()) { + val positions = + computeSpotlightPositions(finalLayout.size, loadedSpotlightSections.size) + positions.sorted().forEachIndexed { offset, pos -> + finalLayout.add( + (pos + offset).coerceAtMost(finalLayout.size), + loadedSpotlightSections[offset], + ) + } + } + _uiState.update { it.copy(combinedSections = finalLayout) } - Timber.d("Updated home layout with ${finalLayout.size} sections (Stable Interleave)") + Timber.d( + "Updated home layout with ${finalLayout.size} sections (${loadedSpotlightSections.size} spotlights)" + ) } private fun loadNewHomescreenSections() { @@ -372,6 +398,7 @@ constructor( val writerTask = async { loadAllWriterSections() } val becauseYouWatchedTask = async { loadAllBecauseYouWatchedSections() } val actorFromRecentTask = async { loadAllActorFromRecentSections() } + val spotlightTask = async { loadSpotlightSections() } awaitAll( actorTask, @@ -379,6 +406,7 @@ constructor( writerTask, becauseYouWatchedTask, actorFromRecentTask, + spotlightTask, ) } @@ -452,8 +480,9 @@ constructor( val topDirectors = appDataRepository.getTopPeople(type = DIRECTOR, limit = 75, minAppearances = 5) - val availableDirectors = - topDirectors.filterNot { it.person.name in renderedPeopleNames } + val availableDirectors = topDirectors.filterNot { + it.person.name in renderedPeopleNames + } val maxDirectorSections = 8 val selectedDirectors = availableDirectors.shuffled().take(maxDirectorSections) @@ -651,6 +680,7 @@ constructor( person.id == selectedActor.id && person.type == ACTOR } } + else -> false } } @@ -683,6 +713,136 @@ constructor( } } + private suspend fun loadSpotlightSections() { + try { + loadedSpotlightSections.clear() + val genres = appDataRepository.combinedGenres.value + val movieGenres = genres.filter { it.type == GenreType.MOVIE }.shuffled().take(4) + val showGenres = genres.filter { it.type == GenreType.SHOW }.shuffled().take(4) + val studios = + try { + mediaRepository.getStudios(limit = 50) + } catch (e: Exception) { + emptyList() + } + val selectedStudios = studios.shuffled().take(4) + + coroutineScope { + val tasks = buildList { + movieGenres.forEach { genre -> + add( + async { + try { + val items = + mediaRepository.getTopRatedByGenre( + genre.name, + GenreType.MOVIE, + limit = 20, + ) + if (items.size >= 3) { + recommendationMutex.withLock { + loadedSpotlightSections.add( + HomeSection.Spotlight( + title = "Top ${genre.name} Movies", + type = SpotlightType.GENRE_MOVIE, + items = items, + ) + ) + } + Timber.d( + "Loaded genre spotlight: ${genre.name} Movies (${items.size} items)" + ) + } + } catch (e: Exception) { + Timber.w(e, "Failed spotlight for movie genre: ${genre.name}") + } + } + ) + } + showGenres.forEach { genre -> + add( + async { + try { + val items = + mediaRepository.getTopRatedByGenre( + genre.name, + GenreType.SHOW, + limit = 20, + ) + if (items.size >= 3) { + recommendationMutex.withLock { + loadedSpotlightSections.add( + HomeSection.Spotlight( + title = "Top ${genre.name} Series", + type = SpotlightType.GENRE_SHOW, + items = items, + ) + ) + } + Timber.d( + "Loaded genre spotlight: ${genre.name} Series (${items.size} items)" + ) + } + } catch (e: Exception) { + Timber.w(e, "Failed spotlight for show genre: ${genre.name}") + } + } + ) + } + selectedStudios.forEach { studio -> + add( + async { + try { + val items = + mediaRepository.getTopRatedByStudio(studio.name, limit = 20) + if (items.size >= 3) { + recommendationMutex.withLock { + loadedSpotlightSections.add( + HomeSection.Spotlight( + title = "Best of ${studio.name}", + type = SpotlightType.STUDIO, + items = items, + ) + ) + } + Timber.d( + "Loaded studio spotlight: ${studio.name} (${items.size} items)" + ) + } + } catch (e: Exception) { + Timber.w(e, "Failed studio spotlight") + } + } + ) + } + } + tasks.awaitAll() + } + val seenIds = mutableSetOf() + val deduplicated = loadedSpotlightSections.mapNotNull { section -> + val uniqueItems = section.items.filter { seenIds.add(it.id) }.take(10) + if (uniqueItems.size < 3) null else section.copy(items = uniqueItems) + } + loadedSpotlightSections.clear() + loadedSpotlightSections.addAll(deduplicated) + + if (loadedSpotlightSections.size > 12) { + loadedSpotlightSections.subList(12, loadedSpotlightSections.size).clear() + } + Timber.d("Loaded ${loadedSpotlightSections.size} spotlight sections total") + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to load spotlight sections") + } + } + + private fun computeSpotlightPositions(listSize: Int, count: Int): List { + if (listSize == 0 || count == 0) return emptyList() + val chunkSize = (listSize / (count + 1)).coerceAtLeast(1) + return (1..count).map { i -> (i * chunkSize + (-2..2).random()).coerceIn(1, listSize) } + } + private suspend fun loadDownloadedContent() { try { val userId = authRepository.currentUser.value?.id ?: return @@ -698,12 +858,11 @@ constructor( } val allShows = databaseRepository.getAllShows(userId) - val downloadedShows = - allShows.filter { show -> - show.seasons.any { season -> - season.episodes.any { episode -> episode.id in downloadedItemIds } - } + val downloadedShows = allShows.filter { show -> + show.seasons.any { season -> + season.episodes.any { episode -> episode.id in downloadedItemIds } } + } Timber.d( "Found ${downloadedMovies.size} movies and ${downloadedShows.size} shows with downloads" @@ -731,24 +890,40 @@ constructor( } } - val sortedOfflineContinueWatching = - offlineContinueWatching.sortedByDescending { item -> - when (item) { - is AfinityMovie -> item.playbackPositionTicks - is AfinityEpisode -> item.playbackPositionTicks - else -> 0L - } + val sortedOfflineContinueWatching = offlineContinueWatching.sortedByDescending { item -> + when (item) { + is AfinityMovie -> item.playbackPositionTicks + is AfinityEpisode -> item.playbackPositionTicks + else -> 0L } + } Timber.d( "Found ${sortedOfflineContinueWatching.size} items to continue watching offline" ) + val absCompleted = absDownloadRepository.getCompletedDownloadsFlow().first() + val downloadedAudiobooks = absCompleted.filter { it.mediaType == "book" } + val downloadedPodcastEpisodes = + absCompleted + .filter { it.mediaType == "podcast" } + .groupBy { it.libraryItemId } + .map { (_, episodes) -> + val rep = episodes.maxByOrNull { it.updatedAt }!! + val count = episodes.size + rep.copy( + title = rep.authorName?.takeIf { it.isNotBlank() } ?: rep.title, + authorName = "$count episode${if (count > 1) "s" else ""} downloaded", + ) + } + _uiState.update { it.copy( downloadedMovies = downloadedMovies, downloadedShows = downloadedShows, offlineContinueWatching = sortedOfflineContinueWatching, + downloadedAudiobooks = downloadedAudiobooks, + downloadedPodcastEpisodes = downloadedPodcastEpisodes, ) } } catch (e: Exception) { @@ -937,14 +1112,12 @@ constructor( IntentUtils.openYouTubeUrl(context, trailerUrl) } - private fun loadCombinedGenres() { - viewModelScope.launch { - if (offlineModeManager.isOffline.first()) return@launch - try { - appDataRepository.loadCombinedGenres() - } catch (e: Exception) { - Timber.e(e, "Failed to load combined genres") - } + private suspend fun loadCombinedGenres() { + if (offlineModeManager.isOffline.first()) return + try { + appDataRepository.loadCombinedGenres() + } catch (e: Exception) { + Timber.e(e, "Failed to load combined genres") } } @@ -972,14 +1145,12 @@ constructor( } } - private fun loadStudios() { - viewModelScope.launch { - if (offlineModeManager.isOffline.first()) return@launch - try { - appDataRepository.loadStudios() - } catch (e: Exception) { - Timber.e(e, "Failed to load studios") - } + private suspend fun loadStudios() { + if (offlineModeManager.isOffline.first()) return + try { + appDataRepository.loadStudios() + } catch (e: Exception) { + Timber.e(e, "Failed to load studios") } } @@ -992,8 +1163,12 @@ constructor( fun refresh() { viewModelScope.launch { appDataRepository.reloadHomeData() - loadStudios() - loadCombinedGenres() + + coroutineScope { + launch { loadStudios() } + launch { loadCombinedGenres() } + } + loadNewHomescreenSections() } } @@ -1028,48 +1203,47 @@ constructor( } var sectionsChanged = false - val updatedSections = - loadedRecommendationSections.map { section -> - when (section) { - is HomeSection.Movie -> { - val items = section.section.recommendedItems - val index = items.indexOfFirst { it.id == targetId } - if (index != -1 && updatedItem is AfinityMovie) { - sectionsChanged = true - val newItems = items.toMutableList().apply { this[index] = updatedItem } - HomeSection.Movie(section.section.copy(recommendedItems = newItems)) - } else section - } - - is HomeSection.Person -> { - val items = section.section.items - val index = items.indexOfFirst { it.id == targetId } - if ( - index != -1 && - (updatedItem is AfinityMovie || updatedItem is AfinityShow) - ) { - sectionsChanged = true - val newItems = items.toMutableList().apply { this[index] = updatedItem } - HomeSection.Person(section.section.copy(items = newItems)) - } else section - } + val updatedSections = loadedRecommendationSections.map { section -> + when (section) { + is HomeSection.Movie -> { + val items = section.section.recommendedItems + val index = items.indexOfFirst { it.id == targetId } + if (index != -1 && updatedItem is AfinityMovie) { + sectionsChanged = true + val newItems = items.toMutableList().apply { this[index] = updatedItem } + HomeSection.Movie(section.section.copy(recommendedItems = newItems)) + } else section + } - is HomeSection.PersonFromMovie -> { - val items = section.section.items - val index = items.indexOfFirst { it.id == targetId } - if ( - index != -1 && - (updatedItem is AfinityMovie || updatedItem is AfinityShow) - ) { - sectionsChanged = true - val newItems = items.toMutableList().apply { this[index] = updatedItem } - HomeSection.PersonFromMovie(section.section.copy(items = newItems)) - } else section - } + is HomeSection.Person -> { + val items = section.section.items + val index = items.indexOfFirst { it.id == targetId } + if ( + index != -1 && (updatedItem is AfinityMovie || updatedItem is AfinityShow) + ) { + sectionsChanged = true + val newItems = items.toMutableList().apply { this[index] = updatedItem } + HomeSection.Person(section.section.copy(items = newItems)) + } else section + } - is HomeSection.Genre -> section + is HomeSection.PersonFromMovie -> { + val items = section.section.items + val index = items.indexOfFirst { it.id == targetId } + if ( + index != -1 && (updatedItem is AfinityMovie || updatedItem is AfinityShow) + ) { + sectionsChanged = true + val newItems = items.toMutableList().apply { this[index] = updatedItem } + HomeSection.PersonFromMovie(section.section.copy(items = newItems)) + } else section } + + is HomeSection.Genre -> section + + is HomeSection.Spotlight -> section } + } if (sectionsChanged) { loadedRecommendationSections.clear() @@ -1093,6 +1267,15 @@ sealed interface HomeSection { data class PersonFromMovie(val section: PersonFromMovieSection) : HomeSection data class Genre(val genreItem: GenreItem) : HomeSection + + data class Spotlight(val title: String, val type: SpotlightType, val items: List) : + HomeSection +} + +enum class SpotlightType { + GENRE_MOVIE, + GENRE_SHOW, + STUDIO, } data class HomeUiState( @@ -1111,6 +1294,8 @@ data class HomeUiState( val genreLoadingStates: Map = emptyMap(), val downloadedMovies: List = emptyList(), val downloadedShows: List = emptyList(), + val downloadedAudiobooks: List = emptyList(), + val downloadedPodcastEpisodes: List = emptyList(), val isLoading: Boolean = false, val error: String? = null, val combineLibrarySections: Boolean = false, diff --git a/app/src/main/java/com/makd/afinity/ui/home/components/HomeSections.kt b/app/src/main/java/com/makd/afinity/ui/home/components/HomeSections.kt index 906d655e..fb394875 100644 --- a/app/src/main/java/com/makd/afinity/ui/home/components/HomeSections.kt +++ b/app/src/main/java/com/makd/afinity/ui/home/components/HomeSections.kt @@ -11,6 +11,7 @@ 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.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow @@ -34,7 +35,10 @@ 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 androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.sp import com.makd.afinity.R +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo import com.makd.afinity.data.models.common.CollectionType import com.makd.afinity.data.models.extensions.backdropBlurHash import com.makd.afinity.data.models.extensions.backdropImageUrl @@ -272,3 +276,57 @@ fun OptimizedLatestTvSeriesSection( } } } + +@Composable +fun DownloadedAudiobooksSection( + title: String, + items: List, + onItemClick: (AbsDownloadInfo) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(horizontal = 14.dp)) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 16.dp), + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 0.dp), + ) { + items(items = items, key = { "abs_${it.id}" }) { download -> + Column( + modifier = Modifier.width(100.dp).clickable { onItemClick(download) }, + ) { + Card( + modifier = Modifier.width(100.dp).aspectRatio(1f), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + AsyncImage( + imageUrl = download.coverUrl, + contentDescription = download.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = download.title, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(100.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/home/components/SpotlightCarousel.kt b/app/src/main/java/com/makd/afinity/ui/home/components/SpotlightCarousel.kt new file mode 100644 index 00000000..cc2d6203 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/home/components/SpotlightCarousel.kt @@ -0,0 +1,219 @@ +package com.makd.afinity.ui.home.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.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.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +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 com.makd.afinity.R +import com.makd.afinity.data.models.extensions.backdropBlurHash +import com.makd.afinity.data.models.extensions.backdropImageUrl +import com.makd.afinity.data.models.extensions.logoImageUrlWithTransparency +import com.makd.afinity.data.models.extensions.primaryBlurHash +import com.makd.afinity.data.models.extensions.primaryImageUrl +import com.makd.afinity.data.models.extensions.showLogoImageUrl +import com.makd.afinity.data.models.media.AfinityItem +import com.makd.afinity.data.models.media.AfinityMovie +import com.makd.afinity.data.models.media.AfinityShow +import com.makd.afinity.ui.components.AsyncImage +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SpotlightCarousel( + title: String, + items: List, + onItemClick: (AfinityItem) -> Unit, + onPlayClick: (AfinityItem) -> Unit, + modifier: Modifier = Modifier, +) { + if (items.isEmpty()) return + + val configuration = LocalConfiguration.current + val isLandscape = + configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + val preferredItemWidth = (configuration.screenWidthDp * if (isLandscape) 0.58f else 0.82f).dp + val carouselHeight = if (isLandscape) (configuration.screenHeightDp * 0.62f).dp else 220.dp + val state = rememberCarouselState { items.size } + + Column(modifier = modifier) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 14.dp, bottom = 12.dp), + ) + HorizontalMultiBrowseCarousel( + state = state, + preferredItemWidth = preferredItemWidth, + modifier = Modifier.height(carouselHeight).padding(horizontal = 14.dp), + itemSpacing = 8.dp, + ) { index -> + val item = items[index] + Box( + modifier = + Modifier.height(carouselHeight) + .maskClip(MaterialTheme.shapes.extraLarge) + .clickable { onItemClick(item) } + ) { + AsyncImage( + imageUrl = item.images.backdropImageUrl ?: item.images.primaryImageUrl, + contentDescription = item.name, + blurHash = item.images.backdropBlurHash ?: item.images.primaryBlurHash, + targetWidth = preferredItemWidth, + targetHeight = carouselHeight, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Box( + modifier = + Modifier.fillMaxSize().drawWithCache { + val gradient = + Brush.verticalGradient( + colors = + listOf(Color.Transparent, Color.Black.copy(alpha = 0.72f)), + startY = size.height * 0.45f, + endY = size.height, + ) + onDrawWithContent { + drawRect(Color.Black.copy(alpha = 0.4f)) + drawRect(gradient) + drawContent() + } + } + ) { + Row( + modifier = + Modifier.align(Alignment.BottomStart) + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val logoUrl = + item.images.logoImageUrlWithTransparency ?: item.images.showLogoImageUrl + val imdbRating = + when (item) { + is AfinityMovie -> item.communityRating + is AfinityShow -> item.communityRating + else -> null + } + val rtRating = (item as? AfinityMovie)?.criticRating + + Column( + modifier = Modifier.weight(1f).padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (logoUrl != null) { + AsyncImage( + imageUrl = logoUrl, + contentDescription = item.name, + blurHash = null, + modifier = Modifier.fillMaxWidth().height(56.dp), + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + ) + } else { + Text( + text = item.name, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (imdbRating != null || rtRating != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + imdbRating?.let { rating -> + Icon( + painter = painterResource(R.drawable.ic_imdb_logo), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(18.dp), + ) + Text( + text = String.format(Locale.US, "%.1f", rating), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + } + if (imdbRating != null && rtRating != null) { + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.6f), + ) + } + rtRating?.let { rt -> + Icon( + painter = + painterResource( + if (rt > 60) R.drawable.ic_rotten_tomato_fresh + else R.drawable.ic_rotten_tomato_rotten + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(14.dp), + ) + Text( + text = "${rt.toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + } + } + } + } + + Box( + modifier = + Modifier.size(40.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .clickable { onPlayClick(item) }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_player_play_filled), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } +} 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 8fd2356a..cede22b1 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 @@ -273,6 +273,7 @@ fun SettingsScreen( if (enabled) showJellyseerrBottomSheet = true else showJellyseerrLogoutDialog = true }, + enabled = !effectiveOfflineMode, ) SettingsDivider() SettingsSwitchItem( @@ -287,6 +288,7 @@ fun SettingsScreen( if (enabled) showAudiobookshelfBottomSheet = true else showAudiobookshelfLogoutDialog = true }, + enabled = !effectiveOfflineMode, ) SettingsDivider() SettingsItem( @@ -300,7 +302,9 @@ fun SettingsScreen( icon = painterResource(id = R.drawable.ic_user), title = stringResource(R.string.pref_switch_session), subtitle = stringResource(R.string.pref_switch_session_summary), - onClick = { showSessionSwitcherSheet = true }, + onClick = if (!effectiveOfflineMode) { + { showSessionSwitcherSheet = true } + } else null, ) } } diff --git a/app/src/main/java/com/makd/afinity/ui/settings/downloads/DownloadSettingsScreen.kt b/app/src/main/java/com/makd/afinity/ui/settings/downloads/DownloadSettingsScreen.kt index 5f69d284..88dcc2e2 100644 --- a/app/src/main/java/com/makd/afinity/ui/settings/downloads/DownloadSettingsScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/settings/downloads/DownloadSettingsScreen.kt @@ -3,6 +3,7 @@ package com.makd.afinity.ui.settings.downloads import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -63,6 +64,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.makd.afinity.R import com.makd.afinity.data.manager.OfflineModeManager +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadInfo +import com.makd.afinity.data.models.audiobookshelf.AbsDownloadStatus import com.makd.afinity.data.models.download.DownloadInfo import com.makd.afinity.data.models.download.DownloadStatus import com.makd.afinity.ui.components.AsyncImage @@ -74,6 +77,7 @@ import java.util.UUID @Composable fun DownloadSettingsScreen( onBackClick: () -> Unit, + onNavigateToAbsItem: (libraryItemId: String) -> Unit = {}, modifier: Modifier = Modifier, viewModel: DownloadsViewModel = hiltViewModel(), offlineModeManager: OfflineModeManager, @@ -119,6 +123,15 @@ fun DownloadSettingsScreen( containerColor = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxSize(), ) { innerPadding -> + val absBooks = remember(uiState.absCompletedDownloads) { + uiState.absCompletedDownloads.filter { it.episodeId == null } + } + val absPodcastGroups = remember(uiState.absCompletedDownloads) { + uiState.absCompletedDownloads + .filter { it.episodeId != null } + .groupBy { it.libraryItemId } + } + LazyColumn( modifier = Modifier.fillMaxSize().padding(innerPadding), contentPadding = PaddingValues(bottom = 32.dp), @@ -156,19 +169,20 @@ fun DownloadSettingsScreen( ) } - if (uiState.activeDownloads.isNotEmpty()) { + val allActiveCount = uiState.activeDownloads.size + uiState.absActiveDownloads.size + if (allActiveCount > 0) { item { SectionHeader( title = stringResource( R.string.active_downloads_header_fmt, - uiState.activeDownloads.size, + allActiveCount, ), modifier = Modifier.padding(horizontal = 24.dp), ) } - items(uiState.activeDownloads.reversed(), key = { it.id }) { download -> + items(uiState.activeDownloads.reversed(), key = { "jf_${it.id}" }) { download -> ActiveDownloadCard( download = download, onPause = viewModel::pauseDownload, @@ -178,30 +192,88 @@ fun DownloadSettingsScreen( modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } + + items(uiState.absActiveDownloads.reversed(), key = { "abs_${it.id}" }) { download -> + AbsActiveDownloadCard( + download = download, + onCancel = viewModel::cancelAbsDownload, + formatSize = viewModel::formatStorageSize, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } } - if (uiState.completedDownloads.isNotEmpty()) { + val absUniqueItemCount = absBooks.size + absPodcastGroups.size + val allCompletedCount = uiState.completedDownloads.size + absUniqueItemCount + if (allCompletedCount > 0) { item { SectionHeader( title = stringResource( R.string.completed_downloads_header_fmt, - uiState.completedDownloads.size, + allCompletedCount, ), modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) } - items(uiState.completedDownloads, key = { it.id }) { download -> - CompletedDownloadRow( - download = download, - onDelete = viewModel::deleteDownload, - formatSize = viewModel::formatStorageSize, - ) + if (uiState.completedDownloads.isNotEmpty()) { + if (absUniqueItemCount > 0) { + item { + SectionHeader( + title = "VIDEOS", + modifier = Modifier.padding(horizontal = 32.dp, vertical = 4.dp), + ) + } + } + items(uiState.completedDownloads, key = { "jf_${it.id}" }) { download -> + CompletedDownloadRow( + download = download, + onDelete = viewModel::deleteDownload, + formatSize = viewModel::formatStorageSize, + ) + } + } + + if (absBooks.isNotEmpty()) { + item { + SectionHeader( + title = "AUDIOBOOKS", + modifier = Modifier.padding(horizontal = 32.dp, vertical = 4.dp), + ) + } + items(absBooks, key = { "abs_book_${it.id}" }) { download -> + AbsCompletedDownloadRow( + download = download, + onClick = { onNavigateToAbsItem(download.libraryItemId) }, + onDelete = viewModel::deleteAbsDownload, + formatSize = viewModel::formatStorageSize, + ) + } + } + + if (absPodcastGroups.isNotEmpty()) { + item { + SectionHeader( + title = "PODCASTS", + modifier = Modifier.padding(horizontal = 32.dp, vertical = 4.dp), + ) + } + absPodcastGroups.forEach { (libraryItemId, episodes) -> + item(key = "abs_podcast_$libraryItemId") { + AbsPodcastGroupRow( + libraryItemId = libraryItemId, + episodes = episodes, + onClick = { onNavigateToAbsItem(libraryItemId) }, + onDelete = { viewModel.deleteAbsPodcast(libraryItemId) }, + formatSize = viewModel::formatStorageSize, + ) + } + } } } - if (uiState.activeDownloads.isEmpty() && uiState.completedDownloads.isEmpty()) { + if (allActiveCount == 0 && allCompletedCount == 0) { item { EmptyDownloadsState() } } } @@ -670,6 +742,223 @@ fun EmptyDownloadsState() { } } +@Composable +fun AbsActiveDownloadCard( + download: AbsDownloadInfo, + onCancel: (UUID) -> Unit, + formatSize: (Long) -> String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + imageUrl = download.coverUrl, + contentDescription = download.title, + modifier = Modifier.width(64.dp).aspectRatio(1f).clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = download.title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!download.authorName.isNullOrBlank()) { + Text( + text = download.authorName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + LinearProgressIndicator( + progress = { download.progress }, + modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)), + color = + if (download.status == AbsDownloadStatus.FAILED) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeCap = StrokeCap.Round, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + val statusText = when (download.status) { + AbsDownloadStatus.QUEUED -> "QUEUED" + AbsDownloadStatus.DOWNLOADING -> + "TRACK ${download.tracksDownloaded}/${download.tracksTotal}" + AbsDownloadStatus.FAILED -> "FAILED" + else -> "" + } + Text( + text = statusText, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = + if (download.status == AbsDownloadStatus.FAILED) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + ) + Text( + text = formatSize(download.bytesDownloaded), + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + IconButton( + onClick = { onCancel(download.id) }, + modifier = Modifier.size(36.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_cancel), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +fun AbsCompletedDownloadRow( + download: AbsDownloadInfo, + onClick: () -> Unit = {}, + onDelete: (UUID) -> Unit, + formatSize: (Long) -> String, +) { + val durationMinutes = (download.duration / 60).toInt() + val durationStr = when { + durationMinutes >= 60 -> "${durationMinutes / 60}h ${durationMinutes % 60}m" + durationMinutes > 0 -> "${durationMinutes}m" + else -> "" + } + val subtitleText = buildString { + if (!download.authorName.isNullOrBlank()) append("${download.authorName} • ") + if (durationStr.isNotEmpty()) append("$durationStr • ") + append(formatSize(download.bytesDownloaded)) + } + + ListItem( + modifier = Modifier.clickable { onClick() }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + AsyncImage( + imageUrl = download.coverUrl, + contentDescription = null, + modifier = Modifier.width(56.dp).aspectRatio(1f).clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop, + ) + }, + headlineContent = { + Text( + text = download.title, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text( + text = subtitleText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingContent = { + IconButton(onClick = { onDelete(download.id) }) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = stringResource(R.string.cd_delete_download), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + }, + ) +} + +@Composable +fun AbsPodcastGroupRow( + libraryItemId: String, + episodes: List, + onClick: () -> Unit, + onDelete: () -> Unit, + formatSize: (Long) -> String, +) { + val first = episodes.first() + val podcastName = first.authorName?.takeIf { it.isNotBlank() } ?: first.title + val totalBytes = episodes.sumOf { it.bytesDownloaded } + val count = episodes.size + val subtitleText = "$count episode${if (count > 1) "s" else ""} \u00B7 ${formatSize(totalBytes)}" + + ListItem( + modifier = Modifier.clickable { onClick() }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + AsyncImage( + imageUrl = first.coverUrl, + contentDescription = null, + modifier = Modifier.width(56.dp).aspectRatio(1f).clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop, + ) + }, + headlineContent = { + Text( + text = podcastName, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text( + text = subtitleText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = stringResource(R.string.cd_delete_download), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + }, + ) +} + @Composable fun ImageCacheSettingsCard( isCacheEnabled: Boolean, diff --git a/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementScreen.kt b/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementScreen.kt index b929ccff..81615027 100644 --- a/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementScreen.kt @@ -113,6 +113,7 @@ fun ServerManagementScreen( viewModel: ServerManagementViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val isOffline by viewModel.isOffline.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { viewModel.loadServers() } @@ -149,9 +150,13 @@ fun ServerManagementScreen( }, floatingActionButton = { FloatingActionButton( - onClick = onAddServerClick, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = { if (!isOffline) onAddServerClick() }, + containerColor = + if (isOffline) MaterialTheme.colorScheme.surfaceContainerHighest + else MaterialTheme.colorScheme.primary, + contentColor = + if (isOffline) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else MaterialTheme.colorScheme.onPrimary, shape = RoundedCornerShape(16.dp), ) { Icon( @@ -322,17 +327,21 @@ fun ServerCard( AddressType.REMOTE -> Triple(R.drawable.ic_link, RemoteColor, "Remote") } + val mutedColor = + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) Icon( painter = painterResource(id = connIcon), contentDescription = null, - tint = connColor, + tint = if (serverWithCount.isActiveServer) connColor else mutedColor, modifier = Modifier.size(12.dp), ) Spacer(modifier = Modifier.width(4.dp)) Text( text = connText, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = + if (serverWithCount.isActiveServer) MaterialTheme.colorScheme.onSurfaceVariant + else mutedColor, ) } } diff --git a/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementViewModel.kt b/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementViewModel.kt index 1aa4250a..31b1c433 100644 --- a/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/settings/servers/ServerManagementViewModel.kt @@ -8,6 +8,7 @@ import com.makd.afinity.data.database.dao.AudiobookshelfDao import com.makd.afinity.data.database.dao.JellyseerrDao import com.makd.afinity.data.database.entities.AudiobookshelfAddressEntity import com.makd.afinity.data.database.entities.JellyseerrAddressEntity +import com.makd.afinity.data.manager.OfflineModeManager import com.makd.afinity.data.manager.SessionManager import com.makd.afinity.data.models.audiobookshelf.Library import com.makd.afinity.data.models.audiobookshelf.ListeningStats @@ -25,9 +26,11 @@ import com.makd.afinity.util.isTailscaleAddress import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -126,6 +129,7 @@ class ServerManagementViewModel constructor( @param:ApplicationContext private val context: Context, private val sessionManager: SessionManager, + private val offlineModeManager: OfflineModeManager, private val databaseRepository: DatabaseRepository, private val jellyseerrDao: JellyseerrDao, private val audiobookshelfDao: AudiobookshelfDao, @@ -139,6 +143,9 @@ constructor( private val _state = MutableStateFlow(ServerManagementState()) val state: StateFlow = _state.asStateFlow() + val isOffline: StateFlow = offlineModeManager.isOffline + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + init { loadServers() } diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d04ce4f5..e79d535e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,10 +1,9 @@ -