From e3b13f2ea76dd070c651619309b8fe7e68ecacd2 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 7 Feb 2026 16:13:07 +0100 Subject: [PATCH] feat: implement watch progress tracking and home page content refresh on apperance. --- .../hu/bbara/purefin/app/home/HomePage.kt | 6 ++++ .../purefin/app/home/HomePageViewModel.kt | 14 +++++++- .../purefin/data/InMemoryMediaRepository.kt | 36 +++++++++++++++++++ .../hu/bbara/purefin/data/MediaRepository.kt | 2 ++ .../purefin/data/MediaRepositoryModule.kt | 14 ++++++++ .../local/room/RoomMediaLocalDataSource.kt | 35 ++++++++++++++++++ .../purefin/data/local/room/dao/EpisodeDao.kt | 9 +++++ .../purefin/data/local/room/dao/MovieDao.kt | 3 ++ .../purefin/data/local/room/dao/SeasonDao.kt | 3 ++ .../purefin/data/local/room/dao/SeriesDao.kt | 3 ++ .../domain/usecase/RefreshHomeDataUseCase.kt | 12 +++++++ .../usecase/UpdateWatchProgressUseCase.kt | 13 +++++++ .../purefin/player/manager/ProgressManager.kt | 22 ++++++++++-- 13 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt create mode 100644 app/src/main/java/hu/bbara/purefin/domain/usecase/RefreshHomeDataUseCase.kt create mode 100644 app/src/main/java/hu/bbara/purefin/domain/usecase/UpdateWatchProgressUseCase.kt diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt index 1b5c384..72f4559 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import hu.bbara.purefin.app.home.ui.HomeContent import hu.bbara.purefin.app.home.ui.HomeDrawerContent import hu.bbara.purefin.app.home.ui.HomeMockData @@ -50,6 +51,11 @@ fun HomePage( val continueWatching = viewModel.continueWatching.collectAsState() val latestLibraryContent = viewModel.latestLibraryContent.collectAsState() + LifecycleResumeEffect(Unit) { + viewModel.onResumed() + onPauseOrDispose { } + } + ModalNavigationDrawer( drawerState = drawerState, drawerContent = { diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index 37f0fe4..668c434 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -10,6 +10,7 @@ import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.data.InMemoryMediaRepository import hu.bbara.purefin.data.model.Media +import hu.bbara.purefin.domain.usecase.RefreshHomeDataUseCase import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.navigation.EpisodeDto import hu.bbara.purefin.navigation.LibraryDto @@ -39,7 +40,8 @@ class HomePageViewModel @Inject constructor( private val mediaRepository: InMemoryMediaRepository, private val userSessionRepository: UserSessionRepository, private val navigationManager: NavigationManager, - private val jellyfinApiClient: JellyfinApiClient + private val jellyfinApiClient: JellyfinApiClient, + private val refreshHomeDataUseCase: RefreshHomeDataUseCase ) : ViewModel() { private val _url = userSessionRepository.serverUrl.stateIn( @@ -219,6 +221,16 @@ class HomePageViewModel @Inject constructor( ) } + fun onResumed() { + viewModelScope.launch { + try { + refreshHomeDataUseCase() + } catch (e: Exception) { + // Refresh is best-effort; don't crash on failure + } + } + } + fun logout() { viewModelScope.launch { userSessionRepository.setLoggedIn(false) diff --git a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt index e44d415..fcc79a9 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -305,6 +305,42 @@ class InMemoryMediaRepository @Inject constructor( return series.seasons.flatMap { it.episodes } } + override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { + if (durationMs <= 0) return + val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0 + val watched = progressPercent >= 90.0 + + val result = localDataSource.updateWatchProgress(mediaId, progressPercent, watched) ?: return + + if (result.isMovie) { + _movies.value[mediaId]?.let { movie -> + _movies.value += (mediaId to movie.copy(progress = progressPercent, watched = watched)) + } + } else { + val seriesId = result.seriesId ?: return + _series.value[seriesId]?.let { currentSeries -> + val updatedSeasons = currentSeries.seasons.map { season -> + if (season.id == result.seasonId) { + val updatedEpisodes = season.episodes.map { ep -> + if (ep.id == mediaId) ep.copy(progress = progressPercent, watched = watched) else ep + } + season.copy(unwatchedEpisodeCount = result.seasonUnwatchedCount, episodes = updatedEpisodes) + } else season + } + _series.value += (seriesId to currentSeries.copy( + unwatchedEpisodeCount = result.seriesUnwatchedCount, + seasons = updatedSeasons + )) + } + } + } + + override suspend fun refreshHomeData() { + loadLibraries() + loadContinueWatching() + loadLatestLibraryContent() + } + private suspend fun serverUrl(): String { return userSessionRepository.serverUrl.first() } diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt index 53bb1d0..91e5dc6 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -25,4 +25,6 @@ interface MediaRepository { suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode suspend fun getEpisode(seriesId: UUID, episodeId: UUID) : Episode + suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) + suspend fun refreshHomeData() } diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt new file mode 100644 index 0000000..d920fbd --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt @@ -0,0 +1,14 @@ +package hu.bbara.purefin.data + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class MediaRepositoryModule { + + @Binds + abstract fun bindMediaRepository(impl: InMemoryMediaRepository): MediaRepository +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt index d50f340..f748609 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt @@ -131,6 +131,41 @@ class RoomMediaLocalDataSource @Inject constructor( return episodeEntity.toDomain(cast) } + + data class WatchProgressResult( + val isMovie: Boolean, + val seriesId: UUID? = null, + val seasonId: UUID? = null, + val seriesUnwatchedCount: Int = 0, + val seasonUnwatchedCount: Int = 0 + ) + + suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean): WatchProgressResult? { + movieDao.getById(mediaId)?.let { + movieDao.updateProgress(mediaId, progress, watched) + return WatchProgressResult(isMovie = true) + } + + episodeDao.getById(mediaId)?.let { episode -> + return database.withTransaction { + episodeDao.updateProgress(mediaId, progress, watched) + val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId!!) + seasonDao.updateUnwatchedCount(episode.seasonId, seasonUnwatched) + val seriesUnwatched = episodeDao.countUnwatchedBySeries(episode.seriesId) + seriesDao.updateUnwatchedCount(episode.seriesId, seriesUnwatched) + WatchProgressResult( + isMovie = false, + seriesId = episode.seriesId, + seasonId = episode.seasonId, + seriesUnwatchedCount = seriesUnwatched, + seasonUnwatchedCount = seasonUnwatched + ) + } + } + + return null + } + suspend fun getEpisodesBySeries(seriesId: UUID): List { return episodeDao.getBySeriesId(seriesId).map { episodeEntity -> val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() } diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt index 37fe87e..b5dde53 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt @@ -23,6 +23,15 @@ interface EpisodeDao { @Query("SELECT * FROM episodes WHERE id = :id") suspend fun getById(id: UUID): EpisodeEntity? + @Query("UPDATE episodes SET progress = :progress, watched = :watched WHERE id = :id") + suspend fun updateProgress(id: UUID, progress: Double?, watched: Boolean) + + @Query("SELECT COUNT(*) FROM episodes WHERE seriesId = :seriesId AND watched = 0") + suspend fun countUnwatchedBySeries(seriesId: UUID): Int + + @Query("SELECT COUNT(*) FROM episodes WHERE seasonId = :seasonId AND watched = 0") + suspend fun countUnwatchedBySeason(seasonId: UUID): Int + @Query("DELETE FROM episodes WHERE seriesId = :seriesId") suspend fun deleteBySeriesId(seriesId: UUID) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt index b54a08c..b1fa019 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt @@ -20,6 +20,9 @@ interface MovieDao { @Query("SELECT * FROM movies WHERE id = :id") suspend fun getById(id: UUID): MovieEntity? + @Query("UPDATE movies SET progress = :progress, watched = :watched WHERE id = :id") + suspend fun updateProgress(id: UUID, progress: Double?, watched: Boolean) + @Query("DELETE FROM movies") suspend fun clear() } diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt index 64fc41a..6eb6118 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt @@ -20,6 +20,9 @@ interface SeasonDao { @Query("SELECT * FROM seasons WHERE id = :id") suspend fun getById(id: UUID): SeasonEntity? + @Query("UPDATE seasons SET unwatchedEpisodeCount = :count WHERE id = :id") + suspend fun updateUnwatchedCount(id: UUID, count: Int) + @Query("DELETE FROM seasons WHERE seriesId = :seriesId") suspend fun deleteBySeriesId(seriesId: UUID) } diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt index 19f828b..ef8231c 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt @@ -20,6 +20,9 @@ interface SeriesDao { @Query("SELECT * FROM series WHERE id = :id") suspend fun getById(id: UUID): SeriesEntity? + @Query("UPDATE series SET unwatchedEpisodeCount = :count WHERE id = :id") + suspend fun updateUnwatchedCount(id: UUID, count: Int) + @Query("DELETE FROM series") suspend fun clear() } diff --git a/app/src/main/java/hu/bbara/purefin/domain/usecase/RefreshHomeDataUseCase.kt b/app/src/main/java/hu/bbara/purefin/domain/usecase/RefreshHomeDataUseCase.kt new file mode 100644 index 0000000..f85ebb1 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/domain/usecase/RefreshHomeDataUseCase.kt @@ -0,0 +1,12 @@ +package hu.bbara.purefin.domain.usecase + +import hu.bbara.purefin.data.MediaRepository +import javax.inject.Inject + +class RefreshHomeDataUseCase @Inject constructor( + private val repository: MediaRepository +) { + suspend operator fun invoke() { + repository.refreshHomeData() + } +} diff --git a/app/src/main/java/hu/bbara/purefin/domain/usecase/UpdateWatchProgressUseCase.kt b/app/src/main/java/hu/bbara/purefin/domain/usecase/UpdateWatchProgressUseCase.kt new file mode 100644 index 0000000..56397b0 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/domain/usecase/UpdateWatchProgressUseCase.kt @@ -0,0 +1,13 @@ +package hu.bbara.purefin.domain.usecase + +import hu.bbara.purefin.data.MediaRepository +import java.util.UUID +import javax.inject.Inject + +class UpdateWatchProgressUseCase @Inject constructor( + private val repository: MediaRepository +) { + suspend operator fun invoke(mediaId: UUID, positionMs: Long, durationMs: Long) { + repository.updateWatchProgress(mediaId, positionMs, durationMs) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt b/app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt index 6bd9f4f..5c2cb94 100644 --- a/app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt +++ b/app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt @@ -3,6 +3,7 @@ package hu.bbara.purefin.player.manager import android.util.Log import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.domain.usecase.UpdateWatchProgressUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -18,12 +19,14 @@ import javax.inject.Inject @ViewModelScoped class ProgressManager @Inject constructor( - private val jellyfinApiClient: JellyfinApiClient + private val jellyfinApiClient: JellyfinApiClient, + private val updateWatchProgressUseCase: UpdateWatchProgressUseCase ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var progressJob: Job? = null private var activeItemId: UUID? = null private var lastPositionMs: Long = 0L + private var lastDurationMs: Long = 0L private var isPaused: Boolean = false fun bind( @@ -36,6 +39,7 @@ class ProgressManager @Inject constructor( Triple(state, prog, meta) }.collect { (state, prog, meta) -> lastPositionMs = prog.positionMs + lastDurationMs = prog.durationMs isPaused = !state.isPlaying val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() } @@ -65,7 +69,16 @@ class ProgressManager @Inject constructor( private fun stopSession() { progressJob?.cancel() - activeItemId?.let { report(it, lastPositionMs, isStop = true) } + activeItemId?.let { itemId -> + report(itemId, lastPositionMs, isStop = true) + scope.launch(Dispatchers.IO) { + try { + updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs) + } catch (e: Exception) { + Log.e("ProgressManager", "Local cache update failed", e) + } + } + } activeItemId = null } @@ -89,10 +102,13 @@ class ProgressManager @Inject constructor( progressJob?.cancel() activeItemId?.let { itemId -> val ticks = lastPositionMs * 10_000 + val posMs = lastPositionMs + val durMs = lastDurationMs CoroutineScope(Dispatchers.IO).launch { try { jellyfinApiClient.reportPlaybackStopped(itemId, ticks) - Log.d("ProgressManager", "Stop: $itemId at ${lastPositionMs}ms") + updateWatchProgressUseCase(itemId, posMs, durMs) + Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms") } catch (e: Exception) { Log.e("ProgressManager", "Report failed", e) }