mirror of
https://github.com/bbara04/Purefin.git
synced 2026-04-01 01:30: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.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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.HomeContent
|
||||||
import hu.bbara.purefin.app.home.ui.HomeDrawerContent
|
import hu.bbara.purefin.app.home.ui.HomeDrawerContent
|
||||||
import hu.bbara.purefin.app.home.ui.HomeMockData
|
import hu.bbara.purefin.app.home.ui.HomeMockData
|
||||||
@@ -50,6 +51,11 @@ fun HomePage(
|
|||||||
val continueWatching = viewModel.continueWatching.collectAsState()
|
val continueWatching = viewModel.continueWatching.collectAsState()
|
||||||
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
|
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
|
||||||
|
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
viewModel.onResumed()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import hu.bbara.purefin.app.home.ui.PosterItem
|
|||||||
import hu.bbara.purefin.client.JellyfinApiClient
|
import hu.bbara.purefin.client.JellyfinApiClient
|
||||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||||
import hu.bbara.purefin.data.model.Media
|
import hu.bbara.purefin.data.model.Media
|
||||||
|
import hu.bbara.purefin.domain.usecase.RefreshHomeDataUseCase
|
||||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||||
import hu.bbara.purefin.navigation.EpisodeDto
|
import hu.bbara.purefin.navigation.EpisodeDto
|
||||||
import hu.bbara.purefin.navigation.LibraryDto
|
import hu.bbara.purefin.navigation.LibraryDto
|
||||||
@@ -39,7 +40,8 @@ class HomePageViewModel @Inject constructor(
|
|||||||
private val mediaRepository: InMemoryMediaRepository,
|
private val mediaRepository: InMemoryMediaRepository,
|
||||||
private val userSessionRepository: UserSessionRepository,
|
private val userSessionRepository: UserSessionRepository,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
private val jellyfinApiClient: JellyfinApiClient
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
private val refreshHomeDataUseCase: RefreshHomeDataUseCase
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _url = userSessionRepository.serverUrl.stateIn(
|
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() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
userSessionRepository.setLoggedIn(false)
|
userSessionRepository.setLoggedIn(false)
|
||||||
|
|||||||
@@ -305,6 +305,42 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
return series.seasons.flatMap { it.episodes }
|
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 {
|
private suspend fun serverUrl(): String {
|
||||||
return userSessionRepository.serverUrl.first()
|
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, seasonId: UUID, episodeId: UUID) : Episode
|
||||||
suspend fun getEpisode(seriesId: 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)
|
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> {
|
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
|
||||||
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
|
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
|
||||||
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ interface EpisodeDao {
|
|||||||
@Query("SELECT * FROM episodes WHERE id = :id")
|
@Query("SELECT * FROM episodes WHERE id = :id")
|
||||||
suspend fun getById(id: UUID): EpisodeEntity?
|
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")
|
@Query("DELETE FROM episodes WHERE seriesId = :seriesId")
|
||||||
suspend fun deleteBySeriesId(seriesId: UUID)
|
suspend fun deleteBySeriesId(seriesId: UUID)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface MovieDao {
|
|||||||
@Query("SELECT * FROM movies WHERE id = :id")
|
@Query("SELECT * FROM movies WHERE id = :id")
|
||||||
suspend fun getById(id: UUID): MovieEntity?
|
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")
|
@Query("DELETE FROM movies")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface SeasonDao {
|
|||||||
@Query("SELECT * FROM seasons WHERE id = :id")
|
@Query("SELECT * FROM seasons WHERE id = :id")
|
||||||
suspend fun getById(id: UUID): SeasonEntity?
|
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")
|
@Query("DELETE FROM seasons WHERE seriesId = :seriesId")
|
||||||
suspend fun deleteBySeriesId(seriesId: UUID)
|
suspend fun deleteBySeriesId(seriesId: UUID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface SeriesDao {
|
|||||||
@Query("SELECT * FROM series WHERE id = :id")
|
@Query("SELECT * FROM series WHERE id = :id")
|
||||||
suspend fun getById(id: UUID): SeriesEntity?
|
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")
|
@Query("DELETE FROM series")
|
||||||
suspend fun clear()
|
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 android.util.Log
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import hu.bbara.purefin.client.JellyfinApiClient
|
import hu.bbara.purefin.client.JellyfinApiClient
|
||||||
|
import hu.bbara.purefin.domain.usecase.UpdateWatchProgressUseCase
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -18,12 +19,14 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@ViewModelScoped
|
@ViewModelScoped
|
||||||
class ProgressManager @Inject constructor(
|
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 val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private var progressJob: Job? = null
|
private var progressJob: Job? = null
|
||||||
private var activeItemId: UUID? = null
|
private var activeItemId: UUID? = null
|
||||||
private var lastPositionMs: Long = 0L
|
private var lastPositionMs: Long = 0L
|
||||||
|
private var lastDurationMs: Long = 0L
|
||||||
private var isPaused: Boolean = false
|
private var isPaused: Boolean = false
|
||||||
|
|
||||||
fun bind(
|
fun bind(
|
||||||
@@ -36,6 +39,7 @@ class ProgressManager @Inject constructor(
|
|||||||
Triple(state, prog, meta)
|
Triple(state, prog, meta)
|
||||||
}.collect { (state, prog, meta) ->
|
}.collect { (state, prog, meta) ->
|
||||||
lastPositionMs = prog.positionMs
|
lastPositionMs = prog.positionMs
|
||||||
|
lastDurationMs = prog.durationMs
|
||||||
isPaused = !state.isPlaying
|
isPaused = !state.isPlaying
|
||||||
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||||
|
|
||||||
@@ -65,7 +69,16 @@ class ProgressManager @Inject constructor(
|
|||||||
|
|
||||||
private fun stopSession() {
|
private fun stopSession() {
|
||||||
progressJob?.cancel()
|
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
|
activeItemId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +102,13 @@ class ProgressManager @Inject constructor(
|
|||||||
progressJob?.cancel()
|
progressJob?.cancel()
|
||||||
activeItemId?.let { itemId ->
|
activeItemId?.let { itemId ->
|
||||||
val ticks = lastPositionMs * 10_000
|
val ticks = lastPositionMs * 10_000
|
||||||
|
val posMs = lastPositionMs
|
||||||
|
val durMs = lastDurationMs
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e("ProgressManager", "Report failed", e)
|
Log.e("ProgressManager", "Report failed", e)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user