feat: implement watch progress tracking and home page content refresh on apperance.

This commit is contained in:
2026-02-07 16:13:07 +01:00
parent 7951315048
commit e3b13f2ea7
13 changed files with 168 additions and 4 deletions

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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<Episode> {
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}