mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: implement watch progress tracking and home page content refresh on apperance.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user