mirror of
https://github.com/bbara04/Purefin.git
synced 2026-04-01 01:30:08 +02:00
refactor: modularize app into multi-module architecture
This commit is contained in:
@@ -47,6 +47,11 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":feature:download"))
|
||||
implementation(project(":core:player"))
|
||||
implementation(project(":feature:shared"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Purefin" />
|
||||
<service
|
||||
android:name=".download.PurefinDownloadService"
|
||||
android:name="hu.bbara.purefin.feature.download.PurefinDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
@@ -31,15 +31,15 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.client.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.login.ui.LoginScreen
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package hu.bbara.purefin.app.content
|
||||
|
||||
import hu.bbara.purefin.app.content.episode.EpisodeUiModel
|
||||
import hu.bbara.purefin.app.content.movie.MovieUiModel
|
||||
import hu.bbara.purefin.app.content.series.SeriesCastMemberUiModel
|
||||
import hu.bbara.purefin.app.content.series.SeriesEpisodeUiModel
|
||||
import hu.bbara.purefin.app.content.series.SeriesSeasonUiModel
|
||||
import hu.bbara.purefin.app.content.series.SeriesUiModel
|
||||
import hu.bbara.purefin.feature.shared.content.episode.EpisodeUiModel
|
||||
import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesCastMemberUiModel
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesEpisodeUiModel
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesSeasonUiModel
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesUiModel
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import hu.bbara.purefin.app.content.episode.CastMember as EpisodeCastMember
|
||||
import hu.bbara.purefin.app.content.movie.CastMember as MovieCastMember
|
||||
import hu.bbara.purefin.feature.shared.content.episode.CastMember as EpisodeCastMember
|
||||
import hu.bbara.purefin.feature.shared.content.movie.CastMember as MovieCastMember
|
||||
|
||||
object ContentMockData {
|
||||
fun series(): SeriesUiModel {
|
||||
|
||||
@@ -37,7 +37,7 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaActionButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
data class CastMember(
|
||||
val name: String,
|
||||
val role: String,
|
||||
val imageUrl: String?
|
||||
)
|
||||
|
||||
data class EpisodeUiModel(
|
||||
val id: UUID,
|
||||
val title: String,
|
||||
val seasonNumber: Int,
|
||||
val episodeNumber: Int,
|
||||
val releaseDate: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val heroImageUrl: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String,
|
||||
val cast: List<CastMember>
|
||||
)
|
||||
@@ -16,8 +16,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
|
||||
|
||||
@Composable
|
||||
fun EpisodeScreen(
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EpisodeScreenViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
): ViewModel() {
|
||||
|
||||
private val _episodeId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
val episode: StateFlow<Episode?> = combine(
|
||||
_episodeId,
|
||||
mediaRepository.episodes
|
||||
) { id, episodesMap ->
|
||||
id?.let { episodesMap[it] }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
_episodeId.value = episodeId
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,7 +39,8 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaActionButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.download.DownloadState
|
||||
import hu.bbara.purefin.feature.download.DownloadState
|
||||
import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
data class CastMember(
|
||||
val name: String,
|
||||
val role: String,
|
||||
val imageUrl: String?
|
||||
)
|
||||
|
||||
data class MovieUiModel(
|
||||
val id: UUID,
|
||||
val title: String,
|
||||
val year: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val heroImageUrl: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String,
|
||||
val progress: Double?,
|
||||
val cast: List<CastMember>
|
||||
)
|
||||
@@ -22,8 +22,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.app.content.ContentMockData
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.download.DownloadState
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.data.navigation.MovieDto
|
||||
import hu.bbara.purefin.feature.download.DownloadState
|
||||
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel
|
||||
|
||||
@Composable
|
||||
fun MovieScreen(
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.download.DownloadState
|
||||
import hu.bbara.purefin.download.MediaDownloadManager
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MovieScreenViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadManager
|
||||
): ViewModel() {
|
||||
|
||||
private val _movie = MutableStateFlow<MovieUiModel?>(null)
|
||||
val movie = _movie.asStateFlow()
|
||||
|
||||
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
|
||||
fun onGoHome() {
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun selectMovie(movieId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val movieData = mediaRepository.movies.value[movieId]
|
||||
if (movieData == null) {
|
||||
_movie.value = null
|
||||
return@launch
|
||||
}
|
||||
_movie.value = movieData.toUiModel()
|
||||
|
||||
launch {
|
||||
mediaDownloadManager.observeDownloadState(movieId.toString()).collect {
|
||||
_downloadState.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadClick() {
|
||||
val movieId = _movie.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
when (_downloadState.value) {
|
||||
is DownloadState.NotDownloaded, is DownloadState.Failed -> {
|
||||
mediaDownloadManager.downloadMovie(movieId)
|
||||
}
|
||||
is DownloadState.Downloading -> {
|
||||
mediaDownloadManager.cancelDownload(movieId)
|
||||
}
|
||||
is DownloadState.Downloaded -> {
|
||||
mediaDownloadManager.cancelDownload(movieId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Movie.toUiModel(): MovieUiModel {
|
||||
return MovieUiModel(
|
||||
id = id,
|
||||
title = title,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles,
|
||||
progress = progress,
|
||||
cast = cast.map { CastMember(name = it.name, role = it.role, imageUrl = it.imageUrl) }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,10 +53,11 @@ import hu.bbara.purefin.common.ui.components.MediaActionButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
||||
import hu.bbara.purefin.data.model.CastMember
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||
|
||||
@Composable
|
||||
internal fun SeriesTopBar(
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.series
|
||||
|
||||
data class SeriesEpisodeUiModel(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val seasonNumber: Int,
|
||||
val episodeNumber: Int,
|
||||
val description: String,
|
||||
val duration: String,
|
||||
val imageUrl: String,
|
||||
val watched: Boolean,
|
||||
val progress: Double?
|
||||
)
|
||||
|
||||
data class SeriesSeasonUiModel(
|
||||
val name: String,
|
||||
val episodes: List<SeriesEpisodeUiModel>,
|
||||
val unplayedCount: Int?
|
||||
)
|
||||
|
||||
data class SeriesCastMemberUiModel(
|
||||
val name: String,
|
||||
val role: String,
|
||||
val imageUrl: String?
|
||||
)
|
||||
|
||||
data class SeriesUiModel(
|
||||
val title: String,
|
||||
val year: String,
|
||||
val rating: String,
|
||||
val seasons: String,
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val heroImageUrl: String,
|
||||
val seasonTabs: List<SeriesSeasonUiModel>,
|
||||
val cast: List<SeriesCastMemberUiModel>
|
||||
) {
|
||||
fun getNextEpisode(): SeriesEpisodeUiModel {
|
||||
for (season in seasonTabs) {
|
||||
for (episode in season.episodes) {
|
||||
if (!episode.watched) {
|
||||
return episode
|
||||
}
|
||||
}
|
||||
}
|
||||
return seasonTabs.first().episodes.first()
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.series
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SeriesViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _seriesId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val series: StateFlow<Series?> = _seriesId
|
||||
.flatMapLatest { id ->
|
||||
if (id != null) mediaRepository.observeSeriesWithContent(id) else flowOf(null)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onSelectEpisode(seriesId: UUID, seasonId:UUID, episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episodeId,
|
||||
seasonId = seasonId,
|
||||
seriesId = seriesId
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
fun onGoHome() {
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun selectSeries(seriesId: UUID) {
|
||||
_seriesId.value = seriesId
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package hu.bbara.purefin.app.home
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
|
||||
/**
|
||||
* Navigation 3 entry definition for the Home section.
|
||||
|
||||
@@ -25,6 +25,7 @@ import hu.bbara.purefin.app.home.ui.HomeDrawerContent
|
||||
import hu.bbara.purefin.app.home.ui.HomeMockData
|
||||
import hu.bbara.purefin.app.home.ui.HomeNavItem
|
||||
import hu.bbara.purefin.app.home.ui.HomeTopBar
|
||||
import hu.bbara.purefin.feature.shared.home.HomePageViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
@@ -74,7 +75,7 @@ fun HomePage(
|
||||
primaryNavItems = libraryNavItems,
|
||||
secondaryNavItems = HomeMockData.secondaryNavItems,
|
||||
user = HomeMockData.user,
|
||||
onLibrarySelected = viewModel::onLibrarySelected,
|
||||
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
|
||||
onLogout = viewModel::logout
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package hu.bbara.purefin.app.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.app.home.ui.ContinueWatchingItem
|
||||
import hu.bbara.purefin.app.home.ui.HomeNavItem
|
||||
import hu.bbara.purefin.app.home.ui.LibraryItem
|
||||
import hu.bbara.purefin.app.home.ui.NextUpItem
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
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
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomePageViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val refreshHomeDataUseCase: RefreshHomeDataUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _url = userSessionRepository.serverUrl.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ""
|
||||
)
|
||||
|
||||
val libraries = mediaRepository.libraries.map { libraries ->
|
||||
libraries.map {
|
||||
LibraryItem(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
type = it.type,
|
||||
isEmpty = when(it.type) {
|
||||
CollectionType.MOVIES -> mediaRepository.movies.value.isEmpty()
|
||||
CollectionType.TVSHOWS -> mediaRepository.series.value.isEmpty()
|
||||
else -> true
|
||||
}
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val isOfflineMode = userSessionRepository.isOfflineMode.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
val continueWatching = combine(
|
||||
mediaRepository.continueWatching,
|
||||
mediaRepository.movies,
|
||||
mediaRepository.episodes
|
||||
) { list, moviesMap, episodesMap ->
|
||||
list.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
ContinueWatchingItem(type = BaseItemKind.MOVIE, movie = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
ContinueWatchingItem(type = BaseItemKind.EPISODE, episode = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val nextUp = combine(
|
||||
mediaRepository.nextUp,
|
||||
mediaRepository.episodes
|
||||
) { list, episodesMap ->
|
||||
list.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
NextUpItem(episode = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val latestLibraryContent = combine(
|
||||
mediaRepository.latestLibraryContent,
|
||||
mediaRepository.movies,
|
||||
mediaRepository.series,
|
||||
mediaRepository.episodes
|
||||
) { libraryMap, moviesMap, seriesMap, episodesMap ->
|
||||
libraryMap.mapValues { (_, items) ->
|
||||
items.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
PosterItem(type = BaseItemKind.MOVIE, movie = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
PosterItem(type = BaseItemKind.EPISODE, episode = it)
|
||||
}
|
||||
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
|
||||
PosterItem(type = BaseItemKind.SERIES, series = it)
|
||||
}
|
||||
is Media.SeasonMedia -> seriesMap[media.seriesId]?.let {
|
||||
PosterItem(type = BaseItemKind.SERIES, series = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onLibrarySelected(library : HomeNavItem) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.LibraryRoute(library = LibraryDto(id = library.id, name = library.label)))
|
||||
}
|
||||
}
|
||||
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
navigationManager.navigate(Route.MovieRoute(
|
||||
MovieDto(
|
||||
id = movieId,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fun onSeriesSelected(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(
|
||||
SeriesDto(
|
||||
id = seriesId,
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onEpisodeSelected(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episodeId,
|
||||
seasonId = seasonId,
|
||||
seriesId = seriesId
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
|
||||
fun onGoHome() {
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun getImageUrl(itemId: UUID, type: ImageType): String {
|
||||
return JellyfinImageHelper.toImageUrl(
|
||||
url = _url.value,
|
||||
itemId = itemId,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleOfflineMode() {
|
||||
viewModelScope.launch {
|
||||
userSessionRepository.setOfflineMode(!isOfflineMode.value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,85 +1,7 @@
|
||||
package hu.bbara.purefin.app.home.ui
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
data class ContinueWatchingItem(
|
||||
val type: BaseItemKind,
|
||||
val movie: Movie? = null,
|
||||
val episode: Episode? = null
|
||||
) {
|
||||
val id: UUID = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.id
|
||||
BaseItemKind.EPISODE -> episode!!.id
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
val primaryText: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.title
|
||||
BaseItemKind.EPISODE -> episode!!.title
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
val secondaryText: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.year
|
||||
BaseItemKind.EPISODE -> episode!!.releaseDate
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
val progress: Double = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.progress ?: 0.0
|
||||
BaseItemKind.EPISODE -> episode!!.progress ?: 0.0
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
}
|
||||
|
||||
data class NextUpItem(
|
||||
val episode: Episode
|
||||
) {
|
||||
val id: UUID = episode.id
|
||||
val primaryText: String = episode.title
|
||||
val secondaryText: String = episode.releaseDate
|
||||
}
|
||||
|
||||
data class LibraryItem(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val type: CollectionType,
|
||||
val isEmpty: Boolean
|
||||
)
|
||||
|
||||
data class PosterItem(
|
||||
val type: BaseItemKind,
|
||||
val movie: Movie? = null,
|
||||
val series: Series? = null,
|
||||
val episode: Episode? = null
|
||||
) {
|
||||
val id: UUID = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.id
|
||||
BaseItemKind.EPISODE -> episode!!.id
|
||||
BaseItemKind.SERIES -> series!!.id
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
val title: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.title
|
||||
BaseItemKind.EPISODE -> episode!!.title
|
||||
BaseItemKind.SERIES -> series!!.name
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
val imageUrl: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.heroImageUrl
|
||||
BaseItemKind.EPISODE -> episode!!.heroImageUrl
|
||||
BaseItemKind.SERIES -> series!!.heroImageUrl
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
fun watched() = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.watched
|
||||
BaseItemKind.EPISODE -> episode!!.watched
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
}
|
||||
|
||||
data class HomeNavItem(
|
||||
val id: UUID,
|
||||
|
||||
@@ -40,6 +40,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.request.ImageRequest
|
||||
import hu.bbara.purefin.common.ui.PosterCard
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package hu.bbara.purefin.app.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
||||
|
||||
val contents: StateFlow<List<PosterItem>> = combine(selectedLibrary, mediaRepository.libraries) {
|
||||
libraryId, libraries ->
|
||||
if (libraryId == null) {
|
||||
return@combine emptyList()
|
||||
}
|
||||
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
|
||||
when (library.type) {
|
||||
CollectionType.TVSHOWS -> library.series!!.map { series ->
|
||||
PosterItem(type = BaseItemKind.SERIES, series = series)
|
||||
}
|
||||
CollectionType.MOVIES -> library.movies!!.map { movie ->
|
||||
PosterItem(type = BaseItemKind.MOVIE, movie = movie)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
navigationManager.navigate(Route.MovieRoute(
|
||||
MovieDto(
|
||||
id = movieId,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fun onSeriesSelected(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(
|
||||
SeriesDto(
|
||||
id = seriesId,
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
fun selectLibrary(libraryId: UUID) {
|
||||
viewModelScope.launch {
|
||||
selectedLibrary.value = libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,11 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.app.library.LibraryViewModel
|
||||
import hu.bbara.purefin.common.ui.PosterCard
|
||||
import hu.bbara.purefin.common.ui.components.PurefinIconButton
|
||||
import hu.bbara.purefin.navigation.LibraryDto
|
||||
import hu.bbara.purefin.core.data.navigation.LibraryDto
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package hu.bbara.purefin.client
|
||||
|
||||
import android.media.MediaCodecList
|
||||
import android.util.Log
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||
|
||||
/**
|
||||
* Creates a DeviceProfile for Android devices with proper codec support detection.
|
||||
* This prevents playback failures by requesting transcoding for unsupported formats like DTS-HD.
|
||||
*/
|
||||
object AndroidDeviceProfile {
|
||||
|
||||
fun create(): DeviceProfile {
|
||||
// Debug: Log all available decoders
|
||||
CodecDebugHelper.logAvailableDecoders()
|
||||
|
||||
val audioCodecs = getAudioCodecs()
|
||||
val videoCodecs = getVideoCodecs()
|
||||
|
||||
Log.d("AndroidDeviceProfile", "Supported audio codecs: ${audioCodecs.joinToString()}")
|
||||
Log.d("AndroidDeviceProfile", "Supported video codecs: ${videoCodecs.joinToString()}")
|
||||
|
||||
// Check specifically for DTS
|
||||
val hasDTS = CodecDebugHelper.hasDecoderFor("audio/vnd.dts")
|
||||
val hasDTSHD = CodecDebugHelper.hasDecoderFor("audio/vnd.dts.hd")
|
||||
Log.d("AndroidDeviceProfile", "Has DTS decoder: $hasDTS, Has DTS-HD decoder: $hasDTSHD")
|
||||
|
||||
return DeviceProfile(
|
||||
name = "Android",
|
||||
maxStaticBitrate = 100_000_000,
|
||||
maxStreamingBitrate = 100_000_000,
|
||||
|
||||
// Direct play profiles - what we can play natively
|
||||
// By specifying supported codecs, Jellyfin will transcode unsupported formats like DTS-HD
|
||||
directPlayProfiles = listOf(
|
||||
DirectPlayProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "mp4,m4v,mkv,webm",
|
||||
videoCodec = videoCodecs.joinToString(","),
|
||||
audioCodec = audioCodecs.joinToString(",")
|
||||
)
|
||||
),
|
||||
|
||||
// Empty transcoding profiles - Jellyfin will use its defaults
|
||||
transcodingProfiles = emptyList(),
|
||||
|
||||
codecProfiles = emptyList(),
|
||||
|
||||
subtitleProfiles = listOf(
|
||||
// Prefer EMBED so subtitles stay in the container — this gives
|
||||
// correct cues after seeking (Media3 parses them at extraction time).
|
||||
SubtitleProfile("srt", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("ass", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("ssa", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("subrip", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("sub", SubtitleDeliveryMethod.EMBED),
|
||||
// EXTERNAL fallback for when embedding isn't possible
|
||||
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL)
|
||||
),
|
||||
|
||||
containerProfiles = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported audio codecs on this device.
|
||||
* Excludes unsupported formats like DTS, DTS-HD, TrueHD which commonly cause playback failures.
|
||||
*/
|
||||
private fun getAudioCodecs(): List<String> {
|
||||
val supportedCodecs = mutableListOf<String>()
|
||||
|
||||
// Common codecs supported on most Android devices
|
||||
val commonCodecs = listOf(
|
||||
"aac" to android.media.MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
"mp3" to android.media.MediaFormat.MIMETYPE_AUDIO_MPEG,
|
||||
"ac3" to android.media.MediaFormat.MIMETYPE_AUDIO_AC3,
|
||||
"eac3" to android.media.MediaFormat.MIMETYPE_AUDIO_EAC3,
|
||||
"flac" to android.media.MediaFormat.MIMETYPE_AUDIO_FLAC,
|
||||
"vorbis" to android.media.MediaFormat.MIMETYPE_AUDIO_VORBIS,
|
||||
"opus" to android.media.MediaFormat.MIMETYPE_AUDIO_OPUS
|
||||
)
|
||||
|
||||
for ((codecName, mimeType) in commonCodecs) {
|
||||
if (isCodecSupported(mimeType)) {
|
||||
supportedCodecs.add(codecName)
|
||||
}
|
||||
}
|
||||
|
||||
// AAC is mandatory on Android - ensure it's always included
|
||||
if (!supportedCodecs.contains("aac")) {
|
||||
supportedCodecs.add("aac")
|
||||
}
|
||||
|
||||
return supportedCodecs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported video codecs on this device.
|
||||
*/
|
||||
private fun getVideoCodecs(): List<String> {
|
||||
val supportedCodecs = mutableListOf<String>()
|
||||
|
||||
val commonCodecs = listOf(
|
||||
"h264" to android.media.MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
"hevc" to android.media.MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
"vp9" to android.media.MediaFormat.MIMETYPE_VIDEO_VP9,
|
||||
"vp8" to android.media.MediaFormat.MIMETYPE_VIDEO_VP8,
|
||||
"mpeg4" to android.media.MediaFormat.MIMETYPE_VIDEO_MPEG4,
|
||||
"av1" to android.media.MediaFormat.MIMETYPE_VIDEO_AV1
|
||||
)
|
||||
|
||||
for ((codecName, mimeType) in commonCodecs) {
|
||||
if (isCodecSupported(mimeType)) {
|
||||
supportedCodecs.add(codecName)
|
||||
}
|
||||
}
|
||||
|
||||
// H.264 is mandatory on Android - ensure it's always included
|
||||
if (!supportedCodecs.contains("h264")) {
|
||||
supportedCodecs.add("h264")
|
||||
}
|
||||
|
||||
return supportedCodecs
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific decoder (not encoder) is supported on this device.
|
||||
*/
|
||||
private fun isCodecSupported(mimeType: String): Boolean {
|
||||
return try {
|
||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
codecList.codecInfos.any { codecInfo ->
|
||||
!codecInfo.isEncoder &&
|
||||
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// If we can't determine, assume not supported
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package hu.bbara.purefin.client
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Helper to debug available audio/video codecs on the device.
|
||||
*/
|
||||
object CodecDebugHelper {
|
||||
|
||||
private const val TAG = "CodecDebug"
|
||||
|
||||
/**
|
||||
* Logs all available decoders on this device.
|
||||
* Call this to understand what your device can actually decode.
|
||||
*/
|
||||
fun logAvailableDecoders() {
|
||||
try {
|
||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
Log.d(TAG, "=== Available Audio Decoders ===")
|
||||
|
||||
codecList.codecInfos
|
||||
.filter { !it.isEncoder }
|
||||
.forEach { codecInfo ->
|
||||
codecInfo.supportedTypes.forEach { mimeType ->
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
Log.d(TAG, "${codecInfo.name}: $mimeType")
|
||||
if (mimeType.contains("dts", ignoreCase = true) ||
|
||||
mimeType.contains("truehd", ignoreCase = true)) {
|
||||
Log.w(TAG, " ^^^ DTS/TrueHD decoder found! ^^^")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "=== Available Video Decoders ===")
|
||||
codecList.codecInfos
|
||||
.filter { !it.isEncoder }
|
||||
.forEach { codecInfo ->
|
||||
codecInfo.supportedTypes.forEach { mimeType ->
|
||||
if (mimeType.startsWith("video/")) {
|
||||
Log.d(TAG, "${codecInfo.name}: $mimeType")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to list codecs", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific MIME type has a decoder available.
|
||||
*/
|
||||
fun hasDecoderFor(mimeType: String): Boolean {
|
||||
return try {
|
||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
codecList.codecInfos.any { codecInfo ->
|
||||
!codecInfo.isEncoder &&
|
||||
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
package hu.bbara.purefin.client
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinApiClient @Inject constructor(
|
||||
@ApplicationContext private val applicationContext: Context,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
) {
|
||||
private val jellyfin = createJellyfin {
|
||||
context = applicationContext
|
||||
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
||||
}
|
||||
|
||||
private val api = jellyfin.createApi()
|
||||
|
||||
private suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
|
||||
|
||||
private suspend fun ensureConfigured(): Boolean {
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
val accessToken = userSessionRepository.accessToken.first().trim()
|
||||
if (serverUrl.isBlank() || accessToken.isBlank()) {
|
||||
userSessionRepository.setLoggedIn(false)
|
||||
return false
|
||||
}
|
||||
api.update(baseUrl = serverUrl, accessToken = accessToken)
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun login(url: String, username: String, password: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val trimmedUrl = url.trim()
|
||||
if (trimmedUrl.isBlank()) {
|
||||
return@withContext false
|
||||
}
|
||||
api.update(baseUrl = trimmedUrl)
|
||||
try {
|
||||
val response = api.userApi.authenticateUserByName(username = username, password = password)
|
||||
val authResult = response.content
|
||||
|
||||
val token = authResult.accessToken ?: return@withContext false
|
||||
val userId = authResult.user?.id ?: return@withContext false
|
||||
userSessionRepository.setAccessToken(accessToken = token)
|
||||
userSessionRepository.setUserId(userId)
|
||||
userSessionRepository.setLoggedIn(true)
|
||||
api.update(accessToken = token)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("JellyfinApiClient", "Login failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateApiClient() = withContext(Dispatchers.IO) {
|
||||
ensureConfigured()
|
||||
}
|
||||
|
||||
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val response = api.userViewsApi.getUserViews(
|
||||
userId = getUserId(),
|
||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||
includeHidden = false,
|
||||
)
|
||||
Log.d("getLibraries", response.content.toString())
|
||||
response.content.items
|
||||
}
|
||||
|
||||
private val itemFields =
|
||||
listOf(
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
ItemFields.DATE_LAST_REFRESHED,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.SEASON_USER_DATA
|
||||
)
|
||||
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val getItemsRequest = GetItemsRequest(
|
||||
userId = getUserId(),
|
||||
enableImages = false,
|
||||
parentId = libraryId,
|
||||
fields = itemFields,
|
||||
enableUserData = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
)
|
||||
val response = api.itemsApi.getItems(getItemsRequest)
|
||||
Log.d("getLibraryContent", response.content.toString())
|
||||
response.content.items
|
||||
}
|
||||
|
||||
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val userId = getUserId()
|
||||
if (userId == null) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = itemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
Log.d("getContinueWatching", response.content.toString())
|
||||
response.content.items
|
||||
}
|
||||
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
throw IllegalStateException("Not configured")
|
||||
}
|
||||
val getNextUpRequest = GetNextUpRequest(
|
||||
userId = getUserId(),
|
||||
fields = itemFields,
|
||||
enableResumable = false,
|
||||
)
|
||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||
Log.d("getNextUpEpisodes", result.content.toString())
|
||||
result.content.items
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest media items from a specified library including Movie, Episode, Season.
|
||||
*
|
||||
* @param libraryId The UUID of the library to fetch from
|
||||
* @return A list of [BaseItemDto] representing the latest media items that includes Movie, Episode, Season, or an empty list if not configured
|
||||
*/
|
||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
fields = itemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||
limit = 10
|
||||
)
|
||||
Log.d("getLatestFromLibrary", response.content.toString())
|
||||
response.content
|
||||
}
|
||||
|
||||
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext null
|
||||
}
|
||||
val result = api.userLibraryApi.getItem(
|
||||
itemId = mediaId,
|
||||
userId = getUserId()
|
||||
)
|
||||
Log.d("getItemInfo", result.content.toString())
|
||||
result.content
|
||||
}
|
||||
|
||||
suspend fun getSeasons(seriesId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val result = api.tvShowsApi.getSeasons(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
fields = itemFields,
|
||||
enableUserData = true
|
||||
)
|
||||
Log.d("getSeasons", result.content.toString())
|
||||
result.content.items
|
||||
}
|
||||
|
||||
suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val result = api.tvShowsApi.getEpisodes(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
fields = itemFields,
|
||||
enableUserData = true
|
||||
)
|
||||
Log.d("getEpisodesInSeason", result.content.toString())
|
||||
result.content.items
|
||||
}
|
||||
|
||||
suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
// TODO pass complete Episode object not only an id
|
||||
val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList()
|
||||
val seriesId = episodeInfo.seriesId ?: return@withContext emptyList()
|
||||
val nextUpEpisodesResult = api.tvShowsApi.getEpisodes(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
enableUserData = true,
|
||||
startItemId = episodeId,
|
||||
limit = count + 1
|
||||
)
|
||||
//Remove first element as we need only the next episodes
|
||||
val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1)
|
||||
Log.d("getNextEpisodes", nextUpEpisodes.toString())
|
||||
nextUpEpisodes
|
||||
}
|
||||
|
||||
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
||||
val result = api.mediaInfoApi
|
||||
.getPostedPlaybackInfo(
|
||||
mediaId,
|
||||
PlaybackInfoDto(
|
||||
userId = getUserId(),
|
||||
deviceProfile = AndroidDeviceProfile.create(),
|
||||
maxStreamingBitrate = 100_000_000,
|
||||
),
|
||||
)
|
||||
Log.d("getMediaSources", result.toString())
|
||||
result.content.mediaSources
|
||||
}
|
||||
|
||||
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
// Check if transcoding is required based on the MediaSourceInfo from getMediaSources
|
||||
val shouldTranscode = mediaSource.supportsTranscoding == true &&
|
||||
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
|
||||
|
||||
val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) {
|
||||
// Use transcoding URL
|
||||
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
|
||||
"$baseUrl${mediaSource.transcodingUrl}"
|
||||
} else {
|
||||
// Use direct play URL
|
||||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = mediaId,
|
||||
static = true,
|
||||
mediaSourceId = mediaSource.id,
|
||||
)
|
||||
}
|
||||
|
||||
Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url")
|
||||
url
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = positionTicks,
|
||||
canSeek = true,
|
||||
isPaused = false,
|
||||
isMuted = false,
|
||||
playMethod = PlayMethod.DIRECT_PLAY,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = positionTicks,
|
||||
canSeek = true,
|
||||
isPaused = isPaused,
|
||||
isMuted = false,
|
||||
playMethod = PlayMethod.DIRECT_PLAY,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = positionTicks,
|
||||
failed = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package hu.bbara.purefin.client
|
||||
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinAuthInterceptor @Inject constructor(
|
||||
userSessionRepository: UserSessionRepository
|
||||
) : Interceptor {
|
||||
|
||||
@Volatile
|
||||
private var cachedToken: String = ""
|
||||
|
||||
init {
|
||||
userSessionRepository.accessToken
|
||||
.onEach { cachedToken = it }
|
||||
.launchIn(CoroutineScope(SupervisorJob() + Dispatchers.IO))
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = cachedToken
|
||||
val request = chain.request().newBuilder()
|
||||
.addHeader("X-Emby-Token", token)
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.data.model.CastMember
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
|
||||
@Composable
|
||||
fun MediaMetaChip(
|
||||
|
||||
@@ -23,10 +23,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.request.ImageRequest
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
|
||||
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
@@ -49,11 +49,10 @@ fun PosterCard(
|
||||
when (posterItem.type) {
|
||||
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
|
||||
BaseItemKind.SERIES -> onSeriesSelected(posterItem.id)
|
||||
BaseItemKind.EPISODE -> onEpisodeSelected(
|
||||
posterItem.episode!!.seriesId,
|
||||
posterItem.episode.seasonId,
|
||||
posterItem.episode.id
|
||||
)
|
||||
BaseItemKind.EPISODE -> {
|
||||
val ep = posterItem.episode!!
|
||||
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -79,20 +78,26 @@ fun PosterCard(
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> WatchStateIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
watched = item.movie!!.watched,
|
||||
started = (item.movie.progress ?: 0.0) > 0
|
||||
)
|
||||
BaseItemKind.EPISODE -> WatchStateIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
watched = item.episode!!.watched,
|
||||
started = (item.episode.progress ?: 0.0) > 0
|
||||
)
|
||||
BaseItemKind.MOVIE -> {
|
||||
val m = item.movie!!
|
||||
WatchStateIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
watched = m.watched,
|
||||
started = (m.progress ?: 0.0) > 0
|
||||
)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val ep = item.episode!!
|
||||
WatchStateIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
watched = ep.watched,
|
||||
started = (ep.progress ?: 0.0) > 0
|
||||
)
|
||||
}
|
||||
BaseItemKind.SERIES -> UnwatchedEpisodeIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.data.local.room.OfflineRepository
|
||||
import hu.bbara.purefin.data.local.room.OnlineRepository
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Library
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Active media repository that delegates to either online or offline repository
|
||||
* based on user preference.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Singleton
|
||||
class ActiveMediaRepository @Inject constructor(
|
||||
@OnlineRepository private val onlineRepository: MediaRepository,
|
||||
@OfflineRepository private val offlineRepository: MediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) : MediaRepository {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// Switch between repositories based on offline mode preference
|
||||
private val activeRepository: StateFlow<MediaRepository> =
|
||||
userSessionRepository.isOfflineMode
|
||||
.map { isOffline ->
|
||||
if (isOffline) offlineRepository else onlineRepository
|
||||
}
|
||||
.stateIn(scope, SharingStarted.Eagerly, onlineRepository)
|
||||
|
||||
// Delegate all MediaRepository interface methods to the active repository
|
||||
override val libraries: StateFlow<List<Library>> =
|
||||
activeRepository.flatMapLatest { it.libraries }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> =
|
||||
activeRepository.flatMapLatest { it.movies }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val series: StateFlow<Map<UUID, Series>> =
|
||||
activeRepository.flatMapLatest { it.series }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> =
|
||||
activeRepository.flatMapLatest { it.episodes }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val state: StateFlow<MediaRepositoryState> =
|
||||
activeRepository.flatMapLatest { it.state }
|
||||
.stateIn(scope, SharingStarted.Eagerly, MediaRepositoryState.Loading)
|
||||
|
||||
override val continueWatching: StateFlow<List<Media>> =
|
||||
activeRepository.flatMapLatest { it.continueWatching }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val nextUp: StateFlow<List<Media>> =
|
||||
activeRepository.flatMapLatest { it.nextUp }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> =
|
||||
activeRepository.flatMapLatest { it.latestLibraryContent }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
|
||||
activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
|
||||
|
||||
override suspend fun ensureReady() {
|
||||
activeRepository.value.ensureReady()
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
activeRepository.value.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
}
|
||||
|
||||
override suspend fun refreshHomeData() {
|
||||
activeRepository.value.refreshHomeData()
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.cache.CachedMediaItem
|
||||
import hu.bbara.purefin.data.cache.HomeCache
|
||||
import hu.bbara.purefin.data.local.room.OfflineDatabase
|
||||
import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.data.local.room.RoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Library
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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.coroutines.sync.Mutex
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class InMemoryMediaRepository @Inject constructor(
|
||||
val userSessionRepository: UserSessionRepository,
|
||||
val jellyfinApiClient: JellyfinApiClient,
|
||||
private val localDataSource: RoomMediaLocalDataSource,
|
||||
@OfflineDatabase private val offlineDataSource: OfflineRoomMediaLocalDataSource,
|
||||
private val homeCacheDataStore: DataStore<HomeCache>
|
||||
) : MediaRepository {
|
||||
|
||||
private val ready = CompletableDeferred<Unit>()
|
||||
private val readyMutex = Mutex()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var initialLoadTimestamp = 0L
|
||||
|
||||
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
|
||||
override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
|
||||
|
||||
override val libraries: StateFlow<List<Library>> = localDataSource.librariesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
scope.launch {
|
||||
awaitReady()
|
||||
ensureSeriesContentLoaded(seriesId)
|
||||
}
|
||||
return localDataSource.observeSeriesWithContent(seriesId)
|
||||
}
|
||||
|
||||
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
|
||||
|
||||
private val _nextUp: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
override val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
|
||||
|
||||
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
loadFromCache()
|
||||
runCatching { ensureReady() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadFromCache() {
|
||||
val cache = homeCacheDataStore.data.first()
|
||||
if (cache.continueWatching.isNotEmpty()) {
|
||||
_continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() }
|
||||
}
|
||||
if (cache.nextUp.isNotEmpty()) {
|
||||
_nextUp.value = cache.nextUp.mapNotNull { it.toMedia() }
|
||||
}
|
||||
if (cache.latestLibraryContent.isNotEmpty()) {
|
||||
_latestLibraryContent.value = cache.latestLibraryContent.mapNotNull { (key, items) ->
|
||||
val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@mapNotNull null
|
||||
uuid to items.mapNotNull { it.toMedia() }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistHomeCache() {
|
||||
val cache = HomeCache(
|
||||
continueWatching = _continueWatching.value.map { it.toCachedItem() },
|
||||
nextUp = _nextUp.value.map { it.toCachedItem() },
|
||||
latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) ->
|
||||
uuid.toString() to items.map { it.toCachedItem() }
|
||||
}.toMap()
|
||||
)
|
||||
homeCacheDataStore.updateData { cache }
|
||||
}
|
||||
|
||||
private fun Media.toCachedItem(): CachedMediaItem = when (this) {
|
||||
is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString())
|
||||
is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString())
|
||||
is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), seriesId = seriesId.toString())
|
||||
is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString())
|
||||
}
|
||||
|
||||
private fun CachedMediaItem.toMedia(): Media? {
|
||||
val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null
|
||||
val seriesUuid = seriesId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
return when (type) {
|
||||
"MOVIE" -> Media.MovieMedia(movieId = uuid)
|
||||
"SERIES" -> Media.SeriesMedia(seriesId = uuid)
|
||||
"SEASON" -> Media.SeasonMedia(seasonId = uuid, seriesId = seriesUuid ?: return null)
|
||||
"EPISODE" -> Media.EpisodeMedia(episodeId = uuid, seriesId = seriesUuid ?: return null)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun ensureReady() {
|
||||
if (ready.isCompleted) {
|
||||
ready.await() // rethrows if completed exceptionally
|
||||
return
|
||||
}
|
||||
|
||||
// Only the first caller runs the loading logic; others wait on the deferred.
|
||||
if (readyMutex.tryLock()) {
|
||||
try {
|
||||
if (ready.isCompleted) {
|
||||
ready.await()
|
||||
return
|
||||
}
|
||||
loadLibraries()
|
||||
loadContinueWatching()
|
||||
loadNextUp()
|
||||
loadLatestLibraryContent()
|
||||
persistHomeCache()
|
||||
_state.value = MediaRepositoryState.Ready
|
||||
initialLoadTimestamp = System.currentTimeMillis()
|
||||
ready.complete(Unit)
|
||||
} catch (t: Throwable) {
|
||||
_state.value = MediaRepositoryState.Error(t)
|
||||
ready.completeExceptionally(t)
|
||||
throw t
|
||||
} finally {
|
||||
readyMutex.unlock()
|
||||
}
|
||||
} else {
|
||||
ready.await()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitReady() {
|
||||
ready.await()
|
||||
}
|
||||
|
||||
suspend fun loadLibraries() {
|
||||
val librariesItem = jellyfinApiClient.getLibraries()
|
||||
//TODO add support for playlists
|
||||
val filteredLibraries =
|
||||
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
|
||||
val emptyLibraries = filteredLibraries.map {
|
||||
it.toLibrary()
|
||||
}
|
||||
localDataSource.saveLibraries(emptyLibraries)
|
||||
offlineDataSource.saveLibraries(emptyLibraries)
|
||||
|
||||
val filledLibraries = emptyLibraries.map { library ->
|
||||
return@map loadLibrary(library)
|
||||
}
|
||||
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
|
||||
localDataSource.saveMovies(movies)
|
||||
|
||||
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
|
||||
localDataSource.saveSeries(series)
|
||||
}
|
||||
|
||||
suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = jellyfinApiClient.getLibraryContent(library.id)
|
||||
when (library.type) {
|
||||
CollectionType.MOVIES -> {
|
||||
val movies = contentItem.map { it.toMovie(serverUrl(), library.id) }
|
||||
return library.copy(movies = movies)
|
||||
}
|
||||
CollectionType.TVSHOWS -> {
|
||||
val series = contentItem.map { it.toSeries(serverUrl(), library.id) }
|
||||
return library.copy(series = series)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: ${library.type}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadMovie(movie: Movie) : Movie {
|
||||
val movieItem = jellyfinApiClient.getItemInfo(movie.id)
|
||||
?: throw RuntimeException("Movie not found")
|
||||
val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId)
|
||||
localDataSource.saveMovies(listOf(updatedMovie))
|
||||
return updatedMovie
|
||||
}
|
||||
|
||||
suspend fun loadSeries(series: Series) : Series {
|
||||
val seriesItem = jellyfinApiClient.getItemInfo(series.id)
|
||||
?: throw RuntimeException("Series not found")
|
||||
val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId)
|
||||
localDataSource.saveSeries(listOf(updatedSeries))
|
||||
return updatedSeries
|
||||
}
|
||||
|
||||
suspend fun loadContinueWatching() {
|
||||
val continueWatchingItems = jellyfinApiClient.getContinueWatching()
|
||||
val items = continueWatchingItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
BaseItemKind.EPISODE -> Media.EpisodeMedia(
|
||||
episodeId = item.id,
|
||||
seriesId = item.seriesId!!
|
||||
)
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||
}
|
||||
}
|
||||
_continueWatching.value = items
|
||||
|
||||
//Load episodes, Movies are already loaded at this point.
|
||||
continueWatchingItems.forEach { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = item.toEpisode(serverUrl())
|
||||
localDataSource.saveEpisode(episode)
|
||||
}
|
||||
else -> { /* Do nothing */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadNextUp() {
|
||||
val nextUpItems = jellyfinApiClient.getNextUpEpisodes()
|
||||
val items = nextUpItems.map { item ->
|
||||
Media.EpisodeMedia(
|
||||
episodeId = item.id,
|
||||
seriesId = item.seriesId!!
|
||||
)
|
||||
}
|
||||
_nextUp.value = items
|
||||
|
||||
// Load episodes
|
||||
nextUpItems.forEach { item ->
|
||||
val episode = item.toEpisode(serverUrl())
|
||||
localDataSource.saveEpisode(episode)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadLatestLibraryContent() {
|
||||
// TODO Make libraries accessible in a field or something that is not this ugly.
|
||||
val librariesItem = jellyfinApiClient.getLibraries()
|
||||
val filterLibraries =
|
||||
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
|
||||
val latestLibraryContents = filterLibraries.associate { library ->
|
||||
val latestFromLibrary = jellyfinApiClient.getLatestFromLibrary(library.id)
|
||||
library.id to when (library.collectionType) {
|
||||
CollectionType.MOVIES -> {
|
||||
latestFromLibrary.map {
|
||||
val movie = it.toMovie(serverUrl(), library.id)
|
||||
Media.MovieMedia(movieId = movie.id)
|
||||
}
|
||||
}
|
||||
CollectionType.TVSHOWS -> {
|
||||
latestFromLibrary.map {
|
||||
when (it.type) {
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = it.toSeries(serverUrl(), library.id)
|
||||
Media.SeriesMedia(seriesId = series.id)
|
||||
}
|
||||
BaseItemKind.SEASON -> {
|
||||
val season = it.toSeason(serverUrl())
|
||||
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = it.toEpisode(serverUrl())
|
||||
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
||||
} else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
|
||||
}
|
||||
}
|
||||
_latestLibraryContent.value = latestLibraryContents
|
||||
|
||||
//TODO Load seasons and episodes, other types are already loaded at this point.
|
||||
}
|
||||
|
||||
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
|
||||
awaitReady()
|
||||
// Skip if content is already cached in Room
|
||||
localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let {
|
||||
return
|
||||
}
|
||||
|
||||
val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found")
|
||||
|
||||
val emptySeasonsItem = jellyfinApiClient.getSeasons(seriesId)
|
||||
val emptySeasons = emptySeasonsItem.map { it.toSeason(serverUrl()) }
|
||||
val filledSeasons = emptySeasons.map { season ->
|
||||
val episodesItem = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
|
||||
val episodes = episodesItem.map { it.toEpisode(serverUrl()) }
|
||||
season.copy(episodes = episodes)
|
||||
}
|
||||
val updatedSeries = series.copy(seasons = filledSeasons)
|
||||
localDataSource.saveSeries(listOf(updatedSeries))
|
||||
localDataSource.saveSeriesContent(updatedSeries)
|
||||
}
|
||||
|
||||
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
|
||||
// Write to Room — the reactive Flows propagate changes to UI automatically
|
||||
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REFRESH_MIN_INTERVAL_MS = 30_000L
|
||||
}
|
||||
|
||||
override suspend fun refreshHomeData() {
|
||||
awaitReady()
|
||||
// Skip refresh if the initial load (or last refresh) just happened
|
||||
val elapsed = System.currentTimeMillis() - initialLoadTimestamp
|
||||
if (elapsed < REFRESH_MIN_INTERVAL_MS) return
|
||||
|
||||
loadLibraries()
|
||||
loadContinueWatching()
|
||||
loadNextUp()
|
||||
loadLatestLibraryContent()
|
||||
persistHomeCache()
|
||||
initialLoadTimestamp = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private suspend fun serverUrl(): String {
|
||||
return userSessionRepository.serverUrl.first()
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toLibrary(): Library {
|
||||
return when (this.collectionType) {
|
||||
CollectionType.MOVIES -> Library(
|
||||
id = this.id,
|
||||
name = this.name!!,
|
||||
type = CollectionType.MOVIES,
|
||||
movies = emptyList()
|
||||
)
|
||||
CollectionType.TVSHOWS -> Library(
|
||||
id = this.id,
|
||||
name = this.name!!,
|
||||
type = CollectionType.TVSHOWS,
|
||||
series = emptyList()
|
||||
)
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: ${this.collectionType}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID) : Movie {
|
||||
return Movie(
|
||||
id = this.id,
|
||||
libraryId = libraryId,
|
||||
title = this.name ?: "Unknown title",
|
||||
progress = this.userData!!.playedPercentage,
|
||||
watched = this.userData!!.played,
|
||||
year = this.productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
|
||||
rating = this.officialRating
|
||||
?: "NR",
|
||||
runtime = formatRuntime(this.runTimeTicks),
|
||||
synopsis = this.overview ?: "No synopsis available",
|
||||
format = container?.uppercase() ?: "VIDEO",
|
||||
heroImageUrl = JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = this.id,
|
||||
type = ImageType.PRIMARY
|
||||
),
|
||||
subtitles = "ENG",
|
||||
audioTrack = "ENG",
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
|
||||
return Series(
|
||||
id = this.id,
|
||||
libraryId = libraryId,
|
||||
name = this.name ?: "Unknown",
|
||||
synopsis = this.overview ?: "No synopsis available",
|
||||
year = this.productionYear?.toString()
|
||||
?: this.premiereDate?.year?.toString().orEmpty(),
|
||||
heroImageUrl = JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = this.id,
|
||||
type = ImageType.PRIMARY
|
||||
),
|
||||
unwatchedEpisodeCount = this.userData!!.unplayedItemCount!!,
|
||||
seasonCount = this.childCount!!,
|
||||
seasons = emptyList(),
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeason(serverUrl: String): Season {
|
||||
return Season(
|
||||
id = this.id,
|
||||
seriesId = this.seriesId!!,
|
||||
name = this.name ?: "Unknown",
|
||||
index = this.indexNumber ?: 0,
|
||||
unwatchedEpisodeCount = this.userData!!.unplayedItemCount!!,
|
||||
episodeCount = this.childCount!!,
|
||||
episodes = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
|
||||
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
||||
val rating = officialRating ?: "NR"
|
||||
val runtime = formatRuntime(runTimeTicks)
|
||||
val format = container?.uppercase() ?: "VIDEO"
|
||||
val synopsis = overview ?: "No synopsis available."
|
||||
val heroImageUrl = id?.let { itemId ->
|
||||
JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = itemId,
|
||||
type = ImageType.PRIMARY
|
||||
)
|
||||
} ?: ""
|
||||
return Episode(
|
||||
id = id,
|
||||
seriesId = seriesId!!,
|
||||
seasonId = parentId!!,
|
||||
title = name ?: "Unknown title",
|
||||
index = indexNumber!!,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = userData!!.playedPercentage,
|
||||
watched = userData!!.played,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
|
||||
if (date == null) {
|
||||
return fallbackYear?.toString() ?: "—"
|
||||
}
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
|
||||
return date.toLocalDate().format(formatter)
|
||||
}
|
||||
|
||||
private fun formatRuntime(ticks: Long?): String {
|
||||
if (ticks == null || ticks <= 0) return "—"
|
||||
val totalSeconds = ticks / 10_000_000
|
||||
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
|
||||
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
|
||||
return if (hours > 0) {
|
||||
"${hours}h ${minutes}m"
|
||||
} else {
|
||||
"${minutes}m"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Library
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.UUID
|
||||
|
||||
interface MediaRepository {
|
||||
|
||||
val libraries: StateFlow<List<Library>>
|
||||
val movies: StateFlow<Map<UUID, Movie>>
|
||||
val series: StateFlow<Map<UUID, Series>>
|
||||
val episodes: StateFlow<Map<UUID, Episode>>
|
||||
val state: StateFlow<MediaRepositoryState>
|
||||
|
||||
val continueWatching: StateFlow<List<Media>>
|
||||
val nextUp: StateFlow<List<Media>>
|
||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
||||
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
|
||||
|
||||
suspend fun ensureReady()
|
||||
|
||||
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
|
||||
suspend fun refreshHomeData()
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.data.local.room.OnlineRepository
|
||||
import hu.bbara.purefin.data.local.room.OfflineRepository
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class MediaRepositoryModule {
|
||||
|
||||
@Binds
|
||||
@OnlineRepository
|
||||
abstract fun bindOnlineMediaRepository(impl: InMemoryMediaRepository): MediaRepository
|
||||
|
||||
@Binds
|
||||
@OfflineRepository
|
||||
abstract fun bindOfflineMediaRepository(impl: OfflineMediaRepository): MediaRepository
|
||||
|
||||
// Default binding delegates to online/offline based on user preference
|
||||
@Binds
|
||||
abstract fun bindDefaultMediaRepository(impl: ActiveMediaRepository): MediaRepository
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
sealed interface MediaRepositoryState {
|
||||
data object Loading : MediaRepositoryState
|
||||
data object Ready : MediaRepositoryState
|
||||
data class Error(val throwable: Throwable) : MediaRepositoryState
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.data.local.room.OfflineDatabase
|
||||
import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Library
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Offline media repository for managing downloaded content.
|
||||
* This repository only accesses the local offline database and does not make network calls.
|
||||
*/
|
||||
@Singleton
|
||||
class OfflineMediaRepository @Inject constructor(
|
||||
@OfflineDatabase private val localDataSource: OfflineRoomMediaLocalDataSource
|
||||
) : MediaRepository {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// Offline repository is always ready (no network loading required)
|
||||
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Ready)
|
||||
override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
|
||||
|
||||
override val libraries: StateFlow<List<Library>> = localDataSource.librariesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
// Offline mode doesn't support these server-side features
|
||||
override val continueWatching: StateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
override val nextUp: StateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
return localDataSource.observeSeriesWithContent(seriesId)
|
||||
}
|
||||
|
||||
override suspend fun ensureReady() {
|
||||
// Offline repository is always ready - no initialization needed
|
||||
}
|
||||
|
||||
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
|
||||
// Write to offline database - the reactive Flows propagate changes to UI automatically
|
||||
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
|
||||
}
|
||||
|
||||
override suspend fun refreshHomeData() {
|
||||
// No-op for offline repository - no network refresh available
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package hu.bbara.purefin.data.cache
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CachedMediaItem(
|
||||
val type: String,
|
||||
val id: String,
|
||||
val seriesId: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HomeCache(
|
||||
val continueWatching: List<CachedMediaItem> = emptyList(),
|
||||
val nextUp: List<CachedMediaItem> = emptyList(),
|
||||
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap()
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package hu.bbara.purefin.data.cache
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class HomeCacheModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHomeCacheDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): DataStore<HomeCache> {
|
||||
return DataStoreFactory.create(
|
||||
serializer = HomeCacheSerializer,
|
||||
produceFile = { context.dataStoreFile("home_cache.json") },
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||
produceNewData = { HomeCacheSerializer.defaultValue }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package hu.bbara.purefin.data.cache
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object HomeCacheSerializer : Serializer<HomeCache> {
|
||||
override val defaultValue: HomeCache
|
||||
get() = HomeCache()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): HomeCache {
|
||||
try {
|
||||
return Json.decodeFromString<HomeCache>(
|
||||
input.readBytes().decodeToString()
|
||||
)
|
||||
} catch (serialization: SerializationException) {
|
||||
throw CorruptionException("proto", serialization)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: HomeCache, output: OutputStream) {
|
||||
output.write(
|
||||
Json.encodeToString(t)
|
||||
.encodeToByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "cast_members",
|
||||
indices = [Index("movieId"), Index("seriesId"), Index("episodeId")]
|
||||
)
|
||||
data class CastMemberEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val role: String,
|
||||
val imageUrl: String?,
|
||||
val movieId: UUID? = null,
|
||||
val seriesId: UUID? = null,
|
||||
val episodeId: UUID? = null
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Qualifier for online database and its components.
|
||||
* Used for the primary MediaDatabase that syncs with the Jellyfin server.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class OnlineDatabase
|
||||
|
||||
/**
|
||||
* Qualifier for offline database and its components.
|
||||
* Used for the OfflineMediaDatabase that stores downloaded content for offline viewing.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class OfflineDatabase
|
||||
|
||||
/**
|
||||
* Qualifier for the online media repository.
|
||||
* Provides access to media synced from the Jellyfin server.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class OnlineRepository
|
||||
|
||||
/**
|
||||
* Qualifier for the offline media repository.
|
||||
* Provides access to media downloaded for offline viewing.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class OfflineRepository
|
||||
@@ -1,35 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "episodes",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SeriesEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["seriesId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("seriesId"), Index("seasonId")]
|
||||
)
|
||||
data class EpisodeEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val seriesId: UUID,
|
||||
val seasonId: UUID,
|
||||
val index: Int,
|
||||
val title: String,
|
||||
val synopsis: String,
|
||||
val releaseDate: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val progress: Double?,
|
||||
val watched: Boolean,
|
||||
val format: String,
|
||||
val heroImageUrl: String
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(tableName = "library")
|
||||
data class LibraryEntity (
|
||||
@PrimaryKey
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val type: String,
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
|
||||
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.data.local.room.dao.MovieDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeriesDao
|
||||
import hu.bbara.purefin.data.local.room.dao.LibraryDao
|
||||
@Database(
|
||||
entities = [
|
||||
MovieEntity::class,
|
||||
SeriesEntity::class,
|
||||
SeasonEntity::class,
|
||||
EpisodeEntity::class,
|
||||
LibraryEntity::class,
|
||||
CastMemberEntity::class
|
||||
],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(UuidConverters::class)
|
||||
abstract class MediaDatabase : RoomDatabase() {
|
||||
abstract fun movieDao(): MovieDao
|
||||
abstract fun seriesDao(): SeriesDao
|
||||
abstract fun seasonDao(): SeasonDao
|
||||
abstract fun episodeDao(): EpisodeDao
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
abstract fun castMemberDao(): CastMemberDao
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
|
||||
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.data.local.room.dao.LibraryDao
|
||||
import hu.bbara.purefin.data.local.room.dao.MovieDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeriesDao
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object MediaDatabaseModule {
|
||||
|
||||
// Online Database and DAOs
|
||||
@Provides
|
||||
@Singleton
|
||||
@OnlineDatabase
|
||||
fun provideOnlineDatabase(@ApplicationContext context: Context): MediaDatabase =
|
||||
Room.inMemoryDatabaseBuilder(context, MediaDatabase::class.java)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@OnlineDatabase
|
||||
fun provideOnlineMovieDao(@OnlineDatabase db: MediaDatabase) = db.movieDao()
|
||||
|
||||
@Provides
|
||||
@OnlineDatabase
|
||||
fun provideOnlineSeriesDao(@OnlineDatabase db: MediaDatabase) = db.seriesDao()
|
||||
|
||||
@Provides
|
||||
@OnlineDatabase
|
||||
fun provideOnlineSeasonDao(@OnlineDatabase db: MediaDatabase) = db.seasonDao()
|
||||
|
||||
@Provides
|
||||
@OnlineDatabase
|
||||
fun provideOnlineEpisodeDao(@OnlineDatabase db: MediaDatabase) = db.episodeDao()
|
||||
|
||||
@Provides
|
||||
@OnlineDatabase
|
||||
fun provideOnlineCastMemberDao(@OnlineDatabase db: MediaDatabase) = db.castMemberDao()
|
||||
|
||||
@Provides
|
||||
@OnlineDatabase
|
||||
fun provideOnlineLibraryDao(@OnlineDatabase db: MediaDatabase) = db.libraryDao()
|
||||
|
||||
// Offline Database and DAOs
|
||||
@Provides
|
||||
@Singleton
|
||||
@OfflineDatabase
|
||||
fun provideOfflineDatabase(@ApplicationContext context: Context): OfflineMediaDatabase =
|
||||
Room.databaseBuilder(context, OfflineMediaDatabase::class.java, "offline_media_database")
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@OfflineDatabase
|
||||
fun provideOfflineMovieDao(@OfflineDatabase db: OfflineMediaDatabase) = db.movieDao()
|
||||
|
||||
@Provides
|
||||
@OfflineDatabase
|
||||
fun provideOfflineSeriesDao(@OfflineDatabase db: OfflineMediaDatabase) = db.seriesDao()
|
||||
|
||||
@Provides
|
||||
@OfflineDatabase
|
||||
fun provideOfflineSeasonDao(@OfflineDatabase db: OfflineMediaDatabase) = db.seasonDao()
|
||||
|
||||
@Provides
|
||||
@OfflineDatabase
|
||||
fun provideOfflineEpisodeDao(@OfflineDatabase db: OfflineMediaDatabase) = db.episodeDao()
|
||||
|
||||
@Provides
|
||||
@OfflineDatabase
|
||||
fun provideOfflineCastMemberDao(@OfflineDatabase db: OfflineMediaDatabase) = db.castMemberDao()
|
||||
|
||||
@Provides
|
||||
@OfflineDatabase
|
||||
fun provideOfflineLibraryDao(@OfflineDatabase db: OfflineMediaDatabase) = db.libraryDao()
|
||||
|
||||
// Data Sources
|
||||
@Provides
|
||||
@Singleton
|
||||
@OnlineDatabase
|
||||
fun provideOnlineDataSource(
|
||||
@OnlineDatabase database: MediaDatabase,
|
||||
@OnlineDatabase movieDao: MovieDao,
|
||||
@OnlineDatabase seriesDao: SeriesDao,
|
||||
@OnlineDatabase seasonDao: SeasonDao,
|
||||
@OnlineDatabase episodeDao: EpisodeDao,
|
||||
@OnlineDatabase castMemberDao: CastMemberDao,
|
||||
@OnlineDatabase libraryDao: LibraryDao
|
||||
): RoomMediaLocalDataSource = RoomMediaLocalDataSource(
|
||||
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao, libraryDao
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@OfflineDatabase
|
||||
fun provideOfflineDataSource(
|
||||
@OfflineDatabase database: OfflineMediaDatabase,
|
||||
@OfflineDatabase movieDao: MovieDao,
|
||||
@OfflineDatabase seriesDao: SeriesDao,
|
||||
@OfflineDatabase seasonDao: SeasonDao,
|
||||
@OfflineDatabase episodeDao: EpisodeDao,
|
||||
@OfflineDatabase castMemberDao: CastMemberDao,
|
||||
@OfflineDatabase libraryDao: LibraryDao
|
||||
): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource(
|
||||
database, movieDao, seriesDao, seasonDao, episodeDao, castMemberDao, libraryDao
|
||||
)
|
||||
|
||||
// Default (unqualified) data source for backward compatibility
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDefaultDataSource(
|
||||
@OnlineDatabase dataSource: RoomMediaLocalDataSource
|
||||
): RoomMediaLocalDataSource = dataSource
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "movies",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = LibraryEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["libraryId"],
|
||||
),
|
||||
],
|
||||
indices = [Index("libraryId")]
|
||||
)
|
||||
data class MovieEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val libraryId: UUID,
|
||||
val title: String,
|
||||
val progress: Double?,
|
||||
val watched: Boolean,
|
||||
val year: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val heroImageUrl: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
|
||||
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.data.local.room.dao.MovieDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeriesDao
|
||||
import hu.bbara.purefin.data.local.room.dao.LibraryDao
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MovieEntity::class,
|
||||
SeriesEntity::class,
|
||||
SeasonEntity::class,
|
||||
EpisodeEntity::class,
|
||||
LibraryEntity::class,
|
||||
CastMemberEntity::class
|
||||
],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(UuidConverters::class)
|
||||
abstract class OfflineMediaDatabase : RoomDatabase() {
|
||||
abstract fun movieDao(): MovieDao
|
||||
abstract fun seriesDao(): SeriesDao
|
||||
abstract fun seasonDao(): SeasonDao
|
||||
abstract fun episodeDao(): EpisodeDao
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
abstract fun castMemberDao(): CastMemberDao
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
|
||||
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.data.local.room.dao.LibraryDao
|
||||
import hu.bbara.purefin.data.local.room.dao.MovieDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeriesDao
|
||||
import hu.bbara.purefin.data.model.CastMember
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Library
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OfflineRoomMediaLocalDataSource(
|
||||
private val database: OfflineMediaDatabase,
|
||||
private val movieDao: MovieDao,
|
||||
private val seriesDao: SeriesDao,
|
||||
private val seasonDao: SeasonDao,
|
||||
private val episodeDao: EpisodeDao,
|
||||
private val castMemberDao: CastMemberDao,
|
||||
private val libraryDao: LibraryDao
|
||||
) {
|
||||
|
||||
// Lightweight Flows for list screens (home, library)
|
||||
val librariesFlow: Flow<List<Library>> = libraryDao.observeAllWithContent()
|
||||
.map { relation ->
|
||||
relation.map {
|
||||
it.library.toDomain(
|
||||
movies = it.movies.map { e -> e.toDomain(cast = emptyList()) },
|
||||
series = it.series.map { e -> e.toDomain(seasons = emptyList(), cast = emptyList()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val moviesFlow: Flow<Map<UUID, Movie>> = movieDao.observeAll()
|
||||
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
|
||||
|
||||
val seriesFlow: Flow<Map<UUID, Series>> = seriesDao.observeAll()
|
||||
.map { entities ->
|
||||
entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) }
|
||||
}
|
||||
|
||||
val episodesFlow: Flow<Map<UUID, Episode>> = episodeDao.observeAll()
|
||||
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
|
||||
|
||||
// Full content Flow for series detail screen (scoped to one series)
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
|
||||
seriesDao.observeWithContent(seriesId).map { relation ->
|
||||
relation?.let {
|
||||
it.series.toDomain(
|
||||
seasons = it.seasons.map { swe ->
|
||||
swe.season.toDomain(
|
||||
episodes = swe.episodes.map { ep -> ep.toDomain(cast = emptyList()) }
|
||||
)
|
||||
},
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveLibraries(libraries: List<Library>) {
|
||||
database.withTransaction {
|
||||
libraryDao.deleteAll()
|
||||
libraryDao.upsertAll(libraries.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveMovies(movies: List<Movie>) {
|
||||
database.withTransaction {
|
||||
movieDao.upsertAll(movies.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSeries(seriesList: List<Series>) {
|
||||
database.withTransaction {
|
||||
seriesDao.upsertAll(seriesList.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSeriesContent(series: Series) {
|
||||
database.withTransaction {
|
||||
// First ensure the series exists before adding seasons/episodes/cast
|
||||
seriesDao.upsert(series.toEntity())
|
||||
|
||||
episodeDao.deleteBySeriesId(series.id)
|
||||
seasonDao.deleteBySeriesId(series.id)
|
||||
|
||||
series.seasons.forEach { season ->
|
||||
seasonDao.upsert(season.toEntity())
|
||||
season.episodes.forEach { episode ->
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveEpisode(episode: Episode) {
|
||||
database.withTransaction {
|
||||
seriesDao.getById(episode.seriesId)
|
||||
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
|
||||
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMovies(): List<Movie> {
|
||||
val movies = movieDao.getAll()
|
||||
return movies.map { entity ->
|
||||
val cast = castMemberDao.getByMovieId(entity.id).map { it.toDomain() }
|
||||
entity.toDomain(cast)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMovie(id: UUID): Movie? {
|
||||
val entity = movieDao.getById(id) ?: return null
|
||||
val cast = castMemberDao.getByMovieId(id).map { it.toDomain() }
|
||||
return entity.toDomain(cast)
|
||||
}
|
||||
|
||||
suspend fun getSeries(): List<Series> {
|
||||
return seriesDao.getAll().mapNotNull { entity -> getSeriesInternal(entity.id, includeContent = false) }
|
||||
}
|
||||
|
||||
suspend fun getSeriesBasic(id: UUID): Series? = getSeriesInternal(id, includeContent = false)
|
||||
|
||||
suspend fun getSeriesWithContent(id: UUID): Series? = getSeriesInternal(id, includeContent = true)
|
||||
|
||||
private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? {
|
||||
val entity = seriesDao.getById(id) ?: return null
|
||||
val cast = castMemberDao.getBySeriesId(id).map { it.toDomain() }
|
||||
val seasons = if (includeContent) {
|
||||
seasonDao.getBySeriesId(id).map { seasonEntity ->
|
||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(episodeCast)
|
||||
}
|
||||
seasonEntity.toDomain(episodes)
|
||||
}
|
||||
} else emptyList()
|
||||
return entity.toDomain(seasons, cast)
|
||||
}
|
||||
|
||||
suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? {
|
||||
val seasonEntity = seasonDao.getById(seasonId) ?: return null
|
||||
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
|
||||
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(episodeCast)
|
||||
}
|
||||
return seasonEntity.toDomain(episodes)
|
||||
}
|
||||
|
||||
suspend fun getSeasons(seriesId: UUID): List<Season> {
|
||||
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
|
||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(episodeCast)
|
||||
}
|
||||
seasonEntity.toDomain(episodes)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
|
||||
val episodeEntity = episodeDao.getById(episodeId) ?: return null
|
||||
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
|
||||
return episodeEntity.toDomain(cast)
|
||||
}
|
||||
|
||||
suspend fun getEpisodeById(episodeId: UUID): Episode? {
|
||||
val episodeEntity = episodeDao.getById(episodeId) ?: return null
|
||||
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
|
||||
return episodeEntity.toDomain(cast)
|
||||
}
|
||||
|
||||
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
|
||||
movieDao.getById(mediaId)?.let {
|
||||
movieDao.updateProgress(mediaId, progress, watched)
|
||||
return
|
||||
}
|
||||
|
||||
episodeDao.getById(mediaId)?.let { episode ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
|
||||
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
|
||||
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(cast)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Library.toEntity() = LibraryEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
type = when (type) {
|
||||
CollectionType.MOVIES -> "MOVIES"
|
||||
CollectionType.TVSHOWS -> "TVSHOWS"
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: $type")
|
||||
}
|
||||
)
|
||||
|
||||
private fun LibraryEntity.toDomain(series: List<Series>, movies: List<Movie>) = Library(
|
||||
id = id,
|
||||
name = name,
|
||||
type = when (type) {
|
||||
"MOVIES" -> CollectionType.MOVIES
|
||||
"TVSHOWS" -> CollectionType.TVSHOWS
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: $type")
|
||||
},
|
||||
movies = if (type == "MOVIES") movies else null,
|
||||
series = if (type == "TVSHOWS") series else null,
|
||||
)
|
||||
|
||||
private fun Movie.toEntity() = MovieEntity(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
title = title,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles
|
||||
)
|
||||
|
||||
private fun Series.toEntity() = SeriesEntity(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name,
|
||||
synopsis = synopsis,
|
||||
year = year,
|
||||
heroImageUrl = heroImageUrl,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
seasonCount = seasonCount
|
||||
)
|
||||
|
||||
private fun Season.toEntity() = SeasonEntity(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
name = name,
|
||||
index = index,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
episodeCount = episodeCount
|
||||
)
|
||||
|
||||
private fun Episode.toEntity() = EpisodeEntity(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = index,
|
||||
title = title,
|
||||
synopsis = synopsis,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
format = format,
|
||||
heroImageUrl = heroImageUrl
|
||||
)
|
||||
|
||||
private fun MovieEntity.toDomain(cast: List<CastMember>) = Movie(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
title = title,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles,
|
||||
cast = cast
|
||||
)
|
||||
|
||||
private fun SeriesEntity.toDomain(seasons: List<Season>, cast: List<CastMember>) = Series(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name,
|
||||
synopsis = synopsis,
|
||||
year = year,
|
||||
heroImageUrl = heroImageUrl,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
seasonCount = seasonCount,
|
||||
seasons = seasons,
|
||||
cast = cast
|
||||
)
|
||||
|
||||
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
name = name,
|
||||
index = index,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
episodeCount = episodeCount,
|
||||
episodes = episodes
|
||||
)
|
||||
|
||||
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = index,
|
||||
title = title,
|
||||
synopsis = synopsis,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
format = format,
|
||||
heroImageUrl = heroImageUrl,
|
||||
cast = cast
|
||||
)
|
||||
|
||||
private fun CastMember.toMovieEntity(movieId: UUID) = CastMemberEntity(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl,
|
||||
movieId = movieId
|
||||
)
|
||||
|
||||
private fun CastMember.toSeriesEntity(seriesId: UUID) = CastMemberEntity(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl,
|
||||
seriesId = seriesId
|
||||
)
|
||||
|
||||
private fun CastMember.toEpisodeEntity(episodeId: UUID) = CastMemberEntity(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl,
|
||||
episodeId = episodeId
|
||||
)
|
||||
|
||||
private fun CastMemberEntity.toDomain() = CastMember(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl
|
||||
)
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
|
||||
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.data.local.room.dao.LibraryDao
|
||||
import hu.bbara.purefin.data.local.room.dao.MovieDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.data.local.room.dao.SeriesDao
|
||||
import hu.bbara.purefin.data.model.CastMember
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Library
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import java.util.UUID
|
||||
import javax.inject.Singleton
|
||||
import kotlin.collections.map
|
||||
|
||||
@Singleton
|
||||
class RoomMediaLocalDataSource(
|
||||
private val database: MediaDatabase,
|
||||
private val movieDao: MovieDao,
|
||||
private val seriesDao: SeriesDao,
|
||||
private val seasonDao: SeasonDao,
|
||||
private val episodeDao: EpisodeDao,
|
||||
private val castMemberDao: CastMemberDao,
|
||||
private val libraryDao: LibraryDao
|
||||
) {
|
||||
|
||||
// Lightweight Flows for list screens (home, library)
|
||||
val librariesFlow: Flow<List<Library>> = libraryDao.observeAllWithContent()
|
||||
.map { relation ->
|
||||
relation.map { libraryEntity ->
|
||||
libraryEntity.library.toDomain(
|
||||
movies = libraryEntity.movies.map { it.toDomain(listOf()) },
|
||||
series = libraryEntity.series.map { it.toDomain(listOf(), listOf()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val moviesFlow: Flow<Map<UUID, Movie>> = movieDao.observeAll()
|
||||
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
|
||||
|
||||
val seriesFlow: Flow<Map<UUID, Series>> = seriesDao.observeAll()
|
||||
.map { entities ->
|
||||
entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) }
|
||||
}
|
||||
|
||||
val episodesFlow: Flow<Map<UUID, Episode>> = episodeDao.observeAll()
|
||||
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
|
||||
|
||||
// Full content Flow for series detail screen (scoped to one series)
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
|
||||
seriesDao.observeWithContent(seriesId).map { relation ->
|
||||
relation?.let {
|
||||
it.series.toDomain(
|
||||
seasons = it.seasons.map { swe ->
|
||||
swe.season.toDomain(
|
||||
episodes = swe.episodes.map { ep -> ep.toDomain(cast = emptyList()) }
|
||||
)
|
||||
},
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveLibraries(libraries: List<Library>) {
|
||||
database.withTransaction {
|
||||
libraryDao.deleteAll()
|
||||
libraryDao.upsertAll(libraries.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveMovies(movies: List<Movie>) {
|
||||
database.withTransaction {
|
||||
movieDao.upsertAll(movies.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSeries(seriesList: List<Series>) {
|
||||
database.withTransaction {
|
||||
seriesDao.upsertAll(seriesList.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSeriesContent(series: Series) {
|
||||
database.withTransaction {
|
||||
// First ensure the series exists before adding seasons/episodes/cast
|
||||
seriesDao.upsert(series.toEntity())
|
||||
|
||||
episodeDao.deleteBySeriesId(series.id)
|
||||
seasonDao.deleteBySeriesId(series.id)
|
||||
|
||||
series.seasons.forEach { season ->
|
||||
seasonDao.upsert(season.toEntity())
|
||||
season.episodes.forEach { episode ->
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveEpisode(episode: Episode) {
|
||||
database.withTransaction {
|
||||
seriesDao.getById(episode.seriesId)
|
||||
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
|
||||
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMovies(): List<Movie> {
|
||||
val movies = movieDao.getAll()
|
||||
return movies.map { entity ->
|
||||
val cast = castMemberDao.getByMovieId(entity.id).map { it.toDomain() }
|
||||
entity.toDomain(cast)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMovie(id: UUID): Movie? {
|
||||
val entity = movieDao.getById(id) ?: return null
|
||||
val cast = castMemberDao.getByMovieId(id).map { it.toDomain() }
|
||||
return entity.toDomain(cast)
|
||||
}
|
||||
|
||||
suspend fun getSeries(): List<Series> {
|
||||
return seriesDao.getAll().mapNotNull { entity -> getSeriesInternal(entity.id, includeContent = false) }
|
||||
}
|
||||
|
||||
suspend fun getSeriesBasic(id: UUID): Series? = getSeriesInternal(id, includeContent = false)
|
||||
|
||||
suspend fun getSeriesWithContent(id: UUID): Series? = getSeriesInternal(id, includeContent = true)
|
||||
|
||||
private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? {
|
||||
val entity = seriesDao.getById(id) ?: return null
|
||||
val cast = castMemberDao.getBySeriesId(id).map { it.toDomain() }
|
||||
val seasons = if (includeContent) {
|
||||
seasonDao.getBySeriesId(id).map { seasonEntity ->
|
||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(episodeCast)
|
||||
}
|
||||
seasonEntity.toDomain(episodes)
|
||||
}
|
||||
} else emptyList()
|
||||
return entity.toDomain(seasons, cast)
|
||||
}
|
||||
|
||||
suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? {
|
||||
val seasonEntity = seasonDao.getById(seasonId) ?: return null
|
||||
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
|
||||
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(episodeCast)
|
||||
}
|
||||
return seasonEntity.toDomain(episodes)
|
||||
}
|
||||
|
||||
suspend fun getSeasons(seriesId: UUID): List<Season> {
|
||||
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
|
||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(episodeCast)
|
||||
}
|
||||
seasonEntity.toDomain(episodes)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
|
||||
val episodeEntity = episodeDao.getById(episodeId) ?: return null
|
||||
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
|
||||
return episodeEntity.toDomain(cast)
|
||||
}
|
||||
|
||||
suspend fun getEpisodeById(episodeId: UUID): Episode? {
|
||||
val episodeEntity = episodeDao.getById(episodeId) ?: return null
|
||||
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
|
||||
return episodeEntity.toDomain(cast)
|
||||
}
|
||||
|
||||
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
|
||||
movieDao.getById(mediaId)?.let {
|
||||
movieDao.updateProgress(mediaId, progress, watched)
|
||||
return
|
||||
}
|
||||
|
||||
episodeDao.getById(mediaId)?.let { episode ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
|
||||
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
|
||||
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
|
||||
episodeEntity.toDomain(cast)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Library.toEntity() = LibraryEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
type = when (type) {
|
||||
CollectionType.MOVIES -> "MOVIES"
|
||||
CollectionType.TVSHOWS -> "TVSHOWS"
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: $type")
|
||||
}
|
||||
)
|
||||
|
||||
private fun LibraryEntity.toDomain(series: List<Series>, movies: List<Movie>) = Library(
|
||||
id = id,
|
||||
name = name,
|
||||
type = when (type) {
|
||||
"MOVIES" -> CollectionType.MOVIES
|
||||
"TVSHOWS" -> CollectionType.TVSHOWS
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: $type")
|
||||
},
|
||||
movies = if (type == "MOVIES") movies else null,
|
||||
series = if (type == "TVSHOWS") series else null,
|
||||
)
|
||||
|
||||
private fun Movie.toEntity() = MovieEntity(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
title = title,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles
|
||||
)
|
||||
|
||||
private fun Series.toEntity() = SeriesEntity(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name,
|
||||
synopsis = synopsis,
|
||||
year = year,
|
||||
heroImageUrl = heroImageUrl,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
seasonCount = seasonCount
|
||||
)
|
||||
|
||||
private fun Season.toEntity() = SeasonEntity(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
name = name,
|
||||
index = index,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
episodeCount = episodeCount
|
||||
)
|
||||
|
||||
private fun Episode.toEntity() = EpisodeEntity(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = index,
|
||||
title = title,
|
||||
synopsis = synopsis,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
format = format,
|
||||
heroImageUrl = heroImageUrl
|
||||
)
|
||||
|
||||
private fun MovieEntity.toDomain(cast: List<CastMember>) = Movie(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
title = title,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles,
|
||||
cast = cast
|
||||
)
|
||||
|
||||
private fun SeriesEntity.toDomain(seasons: List<Season>, cast: List<CastMember>) = Series(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name,
|
||||
synopsis = synopsis,
|
||||
year = year,
|
||||
heroImageUrl = heroImageUrl,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
seasonCount = seasonCount,
|
||||
seasons = seasons,
|
||||
cast = cast
|
||||
)
|
||||
|
||||
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
name = name,
|
||||
index = index,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
episodeCount = episodeCount,
|
||||
episodes = episodes
|
||||
)
|
||||
|
||||
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = index,
|
||||
title = title,
|
||||
synopsis = synopsis,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
format = format,
|
||||
heroImageUrl = heroImageUrl,
|
||||
cast = cast
|
||||
)
|
||||
|
||||
private fun CastMember.toMovieEntity(movieId: UUID) = CastMemberEntity(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl,
|
||||
movieId = movieId
|
||||
)
|
||||
|
||||
private fun CastMember.toSeriesEntity(seriesId: UUID) = CastMemberEntity(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl,
|
||||
seriesId = seriesId
|
||||
)
|
||||
|
||||
private fun CastMember.toEpisodeEntity(episodeId: UUID) = CastMemberEntity(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl,
|
||||
episodeId = episodeId
|
||||
)
|
||||
|
||||
private fun CastMemberEntity.toDomain() = CastMember(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class SeasonWithEpisodes(
|
||||
@Embedded val season: SeasonEntity,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "seasonId"
|
||||
)
|
||||
val episodes: List<EpisodeEntity>
|
||||
)
|
||||
|
||||
data class SeriesWithSeasonsAndEpisodes(
|
||||
@Embedded val series: SeriesEntity,
|
||||
@Relation(
|
||||
entity = SeasonEntity::class,
|
||||
parentColumn = "id",
|
||||
entityColumn = "seriesId"
|
||||
)
|
||||
val seasons: List<SeasonWithEpisodes>
|
||||
)
|
||||
|
||||
data class LibraryWithContent(
|
||||
@Embedded val library: LibraryEntity,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "libraryId"
|
||||
)
|
||||
val series: List<SeriesEntity>,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "libraryId"
|
||||
)
|
||||
val movies: List<MovieEntity>
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "seasons",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SeriesEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["seriesId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("seriesId")]
|
||||
)
|
||||
data class SeasonEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val seriesId: UUID,
|
||||
val name: String,
|
||||
val index: Int,
|
||||
val unwatchedEpisodeCount: Int,
|
||||
val episodeCount: Int
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "series",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = LibraryEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["libraryId"]
|
||||
),
|
||||
],
|
||||
indices = [Index("libraryId")]
|
||||
)
|
||||
data class SeriesEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val libraryId: UUID,
|
||||
val name: String,
|
||||
val synopsis: String,
|
||||
val year: String,
|
||||
val heroImageUrl: String,
|
||||
val unwatchedEpisodeCount: Int,
|
||||
val seasonCount: Int
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Stores UUIDs as strings for Room in-memory database.
|
||||
*/
|
||||
class UuidConverters {
|
||||
@TypeConverter
|
||||
fun fromString(value: String?): UUID? = value?.let(UUID::fromString)
|
||||
|
||||
@TypeConverter
|
||||
fun uuidToString(uuid: UUID?): String? = uuid?.toString()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.data.local.room.CastMemberEntity
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface CastMemberDao {
|
||||
@Upsert
|
||||
suspend fun upsertAll(cast: List<CastMemberEntity>)
|
||||
|
||||
@Query("SELECT * FROM cast_members WHERE movieId = :movieId")
|
||||
suspend fun getByMovieId(movieId: UUID): List<CastMemberEntity>
|
||||
|
||||
@Query("SELECT * FROM cast_members WHERE seriesId = :seriesId")
|
||||
suspend fun getBySeriesId(seriesId: UUID): List<CastMemberEntity>
|
||||
|
||||
@Query("SELECT * FROM cast_members WHERE episodeId = :episodeId")
|
||||
suspend fun getByEpisodeId(episodeId: UUID): List<CastMemberEntity>
|
||||
|
||||
@Query("DELETE FROM cast_members WHERE movieId = :movieId")
|
||||
suspend fun deleteByMovieId(movieId: UUID)
|
||||
|
||||
@Query("DELETE FROM cast_members WHERE seriesId = :seriesId")
|
||||
suspend fun deleteBySeriesId(seriesId: UUID)
|
||||
|
||||
@Query("DELETE FROM cast_members WHERE episodeId = :episodeId")
|
||||
suspend fun deleteByEpisodeId(episodeId: UUID)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.data.local.room.EpisodeEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface EpisodeDao {
|
||||
@Upsert
|
||||
suspend fun upsert(episode: EpisodeEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(episodes: List<EpisodeEntity>)
|
||||
|
||||
@Query("SELECT * FROM episodes WHERE seriesId = :seriesId")
|
||||
suspend fun getBySeriesId(seriesId: UUID): List<EpisodeEntity>
|
||||
|
||||
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId")
|
||||
suspend fun getBySeasonId(seasonId: UUID): List<EpisodeEntity>
|
||||
|
||||
@Query("SELECT * FROM episodes")
|
||||
fun observeAll(): Flow<List<EpisodeEntity>>
|
||||
|
||||
@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)
|
||||
|
||||
@Query("DELETE FROM episodes WHERE seasonId = :seasonId")
|
||||
suspend fun deleteBySeasonId(seasonId: UUID)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.data.local.room.LibraryEntity
|
||||
import hu.bbara.purefin.data.local.room.LibraryWithContent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface LibraryDao {
|
||||
@Upsert
|
||||
suspend fun upsert(library: LibraryEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(libraries: List<LibraryEntity>)
|
||||
|
||||
@Query("SELECT * FROM library")
|
||||
fun observeAll(): Flow<List<LibraryEntity>>
|
||||
|
||||
@Query("SELECT * FROM library")
|
||||
fun observeAllWithContent(): Flow<List<LibraryWithContent>>
|
||||
|
||||
@Query("SELECT * FROM library")
|
||||
suspend fun getAll(): List<LibraryEntity>
|
||||
|
||||
@Query("DELETE FROM library")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.data.local.room.MovieEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface MovieDao {
|
||||
@Upsert
|
||||
suspend fun upsert(movie: MovieEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(movies: List<MovieEntity>)
|
||||
|
||||
@Query("SELECT * FROM movies")
|
||||
suspend fun getAll(): List<MovieEntity>
|
||||
|
||||
@Query("SELECT * FROM movies")
|
||||
fun observeAll(): Flow<List<MovieEntity>>
|
||||
|
||||
@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 WHERE id = :id")
|
||||
suspend fun deleteById(id: UUID)
|
||||
|
||||
@Query("DELETE FROM movies")
|
||||
suspend fun clear()
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.data.local.room.SeasonEntity
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface SeasonDao {
|
||||
@Upsert
|
||||
suspend fun upsert(season: SeasonEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(seasons: List<SeasonEntity>)
|
||||
|
||||
@Query("SELECT * FROM seasons WHERE seriesId = :seriesId")
|
||||
suspend fun getBySeriesId(seriesId: UUID): List<SeasonEntity>
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package hu.bbara.purefin.data.local.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.data.local.room.SeriesEntity
|
||||
import hu.bbara.purefin.data.local.room.SeriesWithSeasonsAndEpisodes
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface SeriesDao {
|
||||
@Upsert
|
||||
suspend fun upsert(series: SeriesEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(series: List<SeriesEntity>)
|
||||
|
||||
@Query("SELECT * FROM series")
|
||||
suspend fun getAll(): List<SeriesEntity>
|
||||
|
||||
@Query("SELECT * FROM series")
|
||||
fun observeAll(): Flow<List<SeriesEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM series WHERE id = :id")
|
||||
fun observeWithContent(id: UUID): Flow<SeriesWithSeasonsAndEpisodes?>
|
||||
|
||||
@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()
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package hu.bbara.purefin.data.model
|
||||
|
||||
data class CastMember(
|
||||
val name: String,
|
||||
val role: String,
|
||||
val imageUrl: String?
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
package hu.bbara.purefin.data.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class Episode(
|
||||
val id: UUID,
|
||||
val seriesId: UUID,
|
||||
val seasonId: UUID,
|
||||
val index: Int,
|
||||
val title: String,
|
||||
val synopsis: String,
|
||||
val releaseDate: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val progress: Double?,
|
||||
val watched: Boolean,
|
||||
val format: String,
|
||||
val heroImageUrl: String,
|
||||
val cast: List<CastMember>
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
package hu.bbara.purefin.data.model
|
||||
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import java.util.UUID
|
||||
|
||||
data class Library(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val type: CollectionType,
|
||||
val series: List<Series>? = null,
|
||||
val movies: List<Movie>? = null,
|
||||
) {
|
||||
init {
|
||||
require(series != null || movies != null) { "Either series or movie must be provided" }
|
||||
require(series == null || movies == null) { "Only one of series or movie can be provided" }
|
||||
require(type == CollectionType.TVSHOWS || type == CollectionType.MOVIES) { "Invalid type: $type" }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package hu.bbara.purefin.data.model
|
||||
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
sealed class Media(
|
||||
val id: UUID,
|
||||
val type: BaseItemKind
|
||||
) {
|
||||
class MovieMedia(val movieId: UUID) : Media(movieId, BaseItemKind.MOVIE)
|
||||
class SeriesMedia(val seriesId: UUID) : Media(seriesId, BaseItemKind.SERIES)
|
||||
class SeasonMedia(val seasonId: UUID, val seriesId: UUID) : Media(seasonId, BaseItemKind.SEASON)
|
||||
class EpisodeMedia(val episodeId: UUID, val seriesId: UUID) : Media(episodeId, BaseItemKind.EPISODE)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package hu.bbara.purefin.data.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class Movie(
|
||||
val id: UUID,
|
||||
val libraryId: UUID,
|
||||
val title: String,
|
||||
val progress: Double?,
|
||||
val watched: Boolean,
|
||||
val year: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val heroImageUrl: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String,
|
||||
val cast: List<CastMember>
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
package hu.bbara.purefin.data.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class Season(
|
||||
val id: UUID,
|
||||
val seriesId: UUID,
|
||||
val name: String,
|
||||
val index: Int,
|
||||
val unwatchedEpisodeCount: Int,
|
||||
val episodeCount: Int,
|
||||
val episodes: List<Episode>
|
||||
) {
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package hu.bbara.purefin.data.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class Series(
|
||||
val id: UUID,
|
||||
val libraryId: UUID,
|
||||
val name: String,
|
||||
val synopsis: String,
|
||||
val year: String,
|
||||
val heroImageUrl: String,
|
||||
val unwatchedEpisodeCount: Int,
|
||||
val seasonCount: Int,
|
||||
val seasons: List<Season>,
|
||||
val cast: List<CastMember>
|
||||
) {
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package hu.bbara.purefin.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DownloadModule {
|
||||
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
|
||||
val downloadDir = File(context.getExternalFilesDir(null), "downloads")
|
||||
return SimpleCache(downloadDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(context))
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
|
||||
return OkHttpDataSource.Factory(okHttpClient)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadNotificationHelper(@ApplicationContext context: Context): DownloadNotificationHelper {
|
||||
return DownloadNotificationHelper(context, DOWNLOAD_CHANNEL_ID)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadManager(
|
||||
@ApplicationContext context: Context,
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DownloadManager {
|
||||
return DownloadManager(
|
||||
context,
|
||||
StandaloneDatabaseProvider(context),
|
||||
cache,
|
||||
okHttpDataSourceFactory,
|
||||
Executors.newFixedThreadPool(2)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCacheDataSourceFactory(
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): CacheDataSource.Factory {
|
||||
return CacheDataSource.Factory()
|
||||
.setCache(cache)
|
||||
.setUpstreamDataSourceFactory(okHttpDataSourceFactory)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package hu.bbara.purefin.download
|
||||
|
||||
sealed class DownloadState {
|
||||
data object NotDownloaded : DownloadState()
|
||||
data class Downloading(val progressPercent: Float) : DownloadState()
|
||||
data object Downloaded : DownloadState()
|
||||
data object Failed : DownloadState()
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package hu.bbara.purefin.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.data.local.room.OfflineDatabase
|
||||
import hu.bbara.purefin.data.local.room.dao.MovieDao
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class MediaDownloadManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
@OfflineDatabase private val offlineDataSource: OfflineRoomMediaLocalDataSource,
|
||||
@OfflineDatabase private val movieDao: MovieDao,
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) {
|
||||
|
||||
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
|
||||
|
||||
init {
|
||||
downloadManager.resumeDownloads()
|
||||
|
||||
downloadManager.addListener(object : DownloadManager.Listener {
|
||||
override fun onDownloadChanged(
|
||||
manager: DownloadManager,
|
||||
download: Download,
|
||||
finalException: Exception?
|
||||
) {
|
||||
val contentId = download.request.id
|
||||
val state = download.toDownloadState()
|
||||
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
|
||||
if (finalException != null) {
|
||||
Log.e(TAG, "Download exception for $contentId", finalException)
|
||||
}
|
||||
getOrCreateStateFlow(contentId).value = state
|
||||
}
|
||||
|
||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||
val contentId = download.request.id
|
||||
Log.d(TAG, "Download removed: $contentId")
|
||||
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||
val flow = getOrCreateStateFlow(contentId)
|
||||
// Initialize from current download index
|
||||
val download = downloadManager.downloadIndex.getDownload(contentId)
|
||||
flow.value = download?.toDownloadState() ?: DownloadState.NotDownloaded
|
||||
return flow
|
||||
}
|
||||
|
||||
fun isDownloaded(contentId: String): Boolean {
|
||||
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
|
||||
}
|
||||
|
||||
suspend fun downloadMovie(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sources = jellyfinApiClient.getMediaSources(movieId)
|
||||
val source = sources.firstOrNull() ?: run {
|
||||
Log.e(TAG, "No media sources for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
|
||||
Log.e(TAG, "No playback URL for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: run {
|
||||
Log.e(TAG, "No item info for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
val movie = Movie(
|
||||
id = itemInfo.id,
|
||||
libraryId = itemInfo.parentId ?: UUID.randomUUID(),
|
||||
title = itemInfo.name ?: "Unknown title",
|
||||
progress = itemInfo.userData?.playedPercentage,
|
||||
watched = itemInfo.userData?.played ?: false,
|
||||
year = itemInfo.productionYear?.toString()
|
||||
?: itemInfo.premiereDate?.year?.toString().orEmpty(),
|
||||
rating = itemInfo.officialRating ?: "NR",
|
||||
runtime = formatRuntime(itemInfo.runTimeTicks),
|
||||
synopsis = itemInfo.overview ?: "No synopsis available",
|
||||
format = itemInfo.container?.uppercase() ?: "VIDEO",
|
||||
heroImageUrl = JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = itemInfo.id,
|
||||
type = ImageType.PRIMARY
|
||||
),
|
||||
subtitles = "ENG",
|
||||
audioTrack = "ENG",
|
||||
cast = emptyList()
|
||||
)
|
||||
|
||||
offlineDataSource.saveMovies(listOf(movie))
|
||||
|
||||
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
|
||||
val request = DownloadRequest.Builder(movieId.toString(), url.toUri()).build()
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for $movieId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for $movieId", e)
|
||||
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cancelDownload(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
|
||||
try {
|
||||
movieDao.deleteById(movieId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove movie from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
|
||||
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
|
||||
}
|
||||
|
||||
private fun Download.toDownloadState(): DownloadState = when (state) {
|
||||
Download.STATE_COMPLETED -> DownloadState.Downloaded
|
||||
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
|
||||
Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadState.Downloading(0f)
|
||||
Download.STATE_FAILED -> DownloadState.Failed
|
||||
Download.STATE_REMOVING -> DownloadState.NotDownloaded
|
||||
Download.STATE_STOPPED -> DownloadState.NotDownloaded
|
||||
else -> DownloadState.NotDownloaded
|
||||
}
|
||||
|
||||
private fun formatRuntime(ticks: Long?): String {
|
||||
if (ticks == null || ticks <= 0) return "—"
|
||||
val totalSeconds = ticks / 10_000_000
|
||||
val hours = java.util.concurrent.TimeUnit.SECONDS.toHours(totalSeconds)
|
||||
val minutes = java.util.concurrent.TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
|
||||
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MediaDownloadManager"
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package hu.bbara.purefin.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.R
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PurefinDownloadService : DownloadService(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||
DOWNLOAD_CHANNEL_ID,
|
||||
R.string.download_channel_name,
|
||||
R.string.download_channel_description
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DownloadServiceEntryPoint {
|
||||
fun downloadManager(): DownloadManager
|
||||
fun downloadNotificationHelper(): DownloadNotificationHelper
|
||||
}
|
||||
|
||||
private val entryPoint: DownloadServiceEntryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication(applicationContext, DownloadServiceEntryPoint::class.java)
|
||||
}
|
||||
|
||||
private var lastBytesDownloaded: Long = 0L
|
||||
private var lastUpdateTimeMs: Long = 0L
|
||||
|
||||
override fun getDownloadManager(): DownloadManager = entryPoint.downloadManager()
|
||||
|
||||
override fun getForegroundNotification(
|
||||
downloads: MutableList<Download>,
|
||||
notMetRequirements: Int
|
||||
): Notification {
|
||||
val activeDownloads = downloads.filter { it.state == Download.STATE_DOWNLOADING }
|
||||
|
||||
if (activeDownloads.isEmpty()) {
|
||||
return entryPoint.downloadNotificationHelper().buildProgressNotification(
|
||||
this,
|
||||
R.drawable.ic_launcher_foreground,
|
||||
null,
|
||||
null,
|
||||
downloads,
|
||||
notMetRequirements
|
||||
)
|
||||
}
|
||||
|
||||
val totalBytes = activeDownloads.sumOf { it.bytesDownloaded }
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val speedText = if (lastUpdateTimeMs > 0L) {
|
||||
val elapsed = (now - lastUpdateTimeMs).coerceAtLeast(1)
|
||||
val bytesPerSec = (totalBytes - lastBytesDownloaded) * 1000L / elapsed
|
||||
formatSpeed(bytesPerSec)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
lastBytesDownloaded = totalBytes
|
||||
lastUpdateTimeMs = now
|
||||
|
||||
val percent = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].percentDownloaded
|
||||
} else {
|
||||
activeDownloads.map { it.percentDownloaded }.average().toFloat()
|
||||
}
|
||||
|
||||
val title = if (activeDownloads.size == 1) {
|
||||
"Downloading"
|
||||
} else {
|
||||
"Downloading ${activeDownloads.size} files"
|
||||
}
|
||||
|
||||
val contentText = buildString {
|
||||
append("${percent.toInt()}%")
|
||||
if (speedText.isNotEmpty()) {
|
||||
append(" · $speedText")
|
||||
}
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(contentText)
|
||||
.setProgress(100, percent.toInt(), false)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getScheduler(): Scheduler? = null
|
||||
|
||||
private fun formatSpeed(bytesPerSec: Long): String {
|
||||
if (bytesPerSec <= 0) return ""
|
||||
return when {
|
||||
bytesPerSec >= 1_000_000 -> String.format("%.1f MB/s", bytesPerSec / 1_000_000.0)
|
||||
bytesPerSec >= 1_000 -> String.format("%.0f KB/s", bytesPerSec / 1_000.0)
|
||||
else -> "$bytesPerSec B/s"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 1
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
fun sendAddDownload(context: Context, request: DownloadRequest) {
|
||||
sendAddDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
request,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
fun sendRemoveDownload(context: Context, contentId: String) {
|
||||
sendRemoveDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
contentId,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package hu.bbara.purefin.image
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.client.JellyfinAuthInterceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ImageModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: JellyfinAuthInterceptor): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package hu.bbara.purefin.image
|
||||
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
class JellyfinImageHelper {
|
||||
companion object {
|
||||
fun toImageUrl(url: String, itemId: UUID, type: ImageType): String {
|
||||
if (url.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return StringBuilder()
|
||||
.append(url)
|
||||
.append("/Items/")
|
||||
.append(itemId)
|
||||
.append("/Images/")
|
||||
.append(type.serialName)
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import hu.bbara.purefin.common.ui.PurefinComplexTextField
|
||||
import hu.bbara.purefin.common.ui.PurefinPasswordField
|
||||
import hu.bbara.purefin.common.ui.PurefinTextButton
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.login.viewmodel.LoginViewModel
|
||||
import hu.bbara.purefin.feature.shared.login.LoginViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package hu.bbara.purefin.login.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
) : ViewModel() {
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
private val _password = MutableStateFlow("")
|
||||
val password: StateFlow<String> = _password.asStateFlow()
|
||||
private val _url = MutableStateFlow("")
|
||||
val url: StateFlow<String> = _url.asStateFlow()
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_url.value = userSessionRepository.serverUrl.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun setUrl(url: String) {
|
||||
_url.value = url
|
||||
}
|
||||
|
||||
fun setUsername(username: String) {
|
||||
_username.value = username
|
||||
}
|
||||
|
||||
fun setPassword(password: String) {
|
||||
_password.value = password
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
suspend fun clearFields() {
|
||||
userSessionRepository.setServerUrl("");
|
||||
_username.value = ""
|
||||
_password.value = ""
|
||||
}
|
||||
|
||||
suspend fun login(): Boolean {
|
||||
_errorMessage.value = null
|
||||
userSessionRepository.setServerUrl(url.value)
|
||||
val success = jellyfinApiClient.login(url.value, username.value, password.value)
|
||||
if (!success) {
|
||||
_errorMessage.value = "Login failed. Check your server URL, username, and password."
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class EpisodeDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val seasonId: UUID,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val seriesId: UUID,
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
||||
@Serializable
|
||||
data class LibraryDto (
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID,
|
||||
val name: String
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class MovieDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
sealed interface NavigationCommand {
|
||||
data class Navigate(val route: Route) : NavigationCommand
|
||||
data class ReplaceAll(val route: Route) : NavigationCommand
|
||||
data object Pop : NavigationCommand
|
||||
}
|
||||
|
||||
interface NavigationManager {
|
||||
val commands: SharedFlow<NavigationCommand>
|
||||
fun navigate(route: Route)
|
||||
fun replaceAll(route: Route)
|
||||
fun pop()
|
||||
}
|
||||
|
||||
class DefaultNavigationManager : NavigationManager {
|
||||
private val _commands =
|
||||
MutableSharedFlow<NavigationCommand>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
override val commands: SharedFlow<NavigationCommand> = _commands.asSharedFlow()
|
||||
|
||||
override fun navigate(route: Route) {
|
||||
_commands.tryEmit(NavigationCommand.Navigate(route))
|
||||
}
|
||||
|
||||
override fun replaceAll(route: Route) {
|
||||
_commands.tryEmit(NavigationCommand.ReplaceAll(route))
|
||||
}
|
||||
|
||||
override fun pop() {
|
||||
_commands.tryEmit(NavigationCommand.Pop)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
||||
staticCompositionLocalOf { error("NavigationManager not provided") }
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NavigationManagerModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNavigationManager(): NavigationManager = DefaultNavigationManager()
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ActivityRetainedComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityRetainedComponent::class)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface Route : NavKey {
|
||||
@Serializable
|
||||
data object Home: Route
|
||||
|
||||
@Serializable
|
||||
data class MovieRoute(val item : MovieDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class SeriesRoute(val item : SeriesDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class EpisodeRoute(val item : EpisodeDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class LibraryRoute(val library : LibraryDto) : Route
|
||||
|
||||
@Serializable
|
||||
data object LoginRoute : Route
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import hu.bbara.purefin.app.content.movie.MovieScreen
|
||||
import hu.bbara.purefin.app.content.series.SeriesScreen
|
||||
import hu.bbara.purefin.app.home.HomePage
|
||||
import hu.bbara.purefin.app.library.ui.LibraryScreen
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.login.ui.LoginScreen
|
||||
|
||||
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class SeriesDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID
|
||||
)
|
||||
@@ -11,8 +11,8 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.player.ui.PlayerScreen
|
||||
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
package hu.bbara.purefin.player.data
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@ViewModelScoped
|
||||
class PlayerMediaRepository @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) {
|
||||
|
||||
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
||||
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
|
||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null
|
||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
||||
mediaId = mediaId,
|
||||
mediaSource = selectedMediaSource
|
||||
) ?: return@withContext null
|
||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
// Calculate resume position
|
||||
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
|
||||
|
||||
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource)
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = playbackUrl,
|
||||
title = baseItem?.name ?: selectedMediaSource.name!!,
|
||||
subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}",
|
||||
artworkUrl = artworkUrl,
|
||||
subtitleConfigurations = subtitleConfigs
|
||||
)
|
||||
|
||||
Pair(mediaItem, resumePositionMs)
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
baseItem: BaseItemDto?,
|
||||
mediaSource: MediaSourceInfo
|
||||
): Long? {
|
||||
val userData = baseItem?.userData ?: return null
|
||||
|
||||
// Get runtime in ticks
|
||||
val runtimeTicks = mediaSource.runTimeTicks ?: baseItem.runTimeTicks ?: 0L
|
||||
if (runtimeTicks == 0L) return null
|
||||
|
||||
// Get saved playback position from userData
|
||||
val playbackPositionTicks = userData.playbackPositionTicks ?: 0L
|
||||
if (playbackPositionTicks == 0L) return null
|
||||
|
||||
// Convert ticks to milliseconds
|
||||
val positionMs = playbackPositionTicks / 10_000
|
||||
|
||||
// Calculate percentage for threshold check
|
||||
val percentage = (playbackPositionTicks.toDouble() / runtimeTicks.toDouble()) * 100.0
|
||||
|
||||
// Apply thresholds: resume only if 5% ≤ progress ≤ 95%
|
||||
return if (percentage in 5.0..95.0) positionMs else null
|
||||
}
|
||||
|
||||
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
|
||||
episodes.mapNotNull { episode ->
|
||||
val id = episode.id ?: return@mapNotNull null
|
||||
val stringId = id.toString()
|
||||
if (existingIds.contains(stringId)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val mediaSources = jellyfinApiClient.getMediaSources(id)
|
||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
|
||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
||||
mediaId = id,
|
||||
mediaSource = selectedMediaSource
|
||||
) ?: return@mapNotNull null
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
|
||||
createMediaItem(
|
||||
mediaId = stringId,
|
||||
playbackUrl = playbackUrl,
|
||||
title = episode.name ?: selectedMediaSource.name!!,
|
||||
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
|
||||
artworkUrl = artworkUrl,
|
||||
subtitleConfigurations = subtitleConfigs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun buildExternalSubtitleConfigs(
|
||||
serverUrl: String,
|
||||
mediaId: UUID,
|
||||
mediaSource: MediaSourceInfo
|
||||
): List<MediaItem.SubtitleConfiguration> {
|
||||
val streams = mediaSource.mediaStreams ?: return emptyList()
|
||||
val mediaSourceId = mediaSource.id ?: return emptyList()
|
||||
val baseUrl = serverUrl.trimEnd('/')
|
||||
|
||||
return streams
|
||||
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL }
|
||||
.mapNotNull { stream ->
|
||||
val codec = stream.codec ?: return@mapNotNull null
|
||||
val mimeType = subtitleCodecToMimeType(codec) ?: return@mapNotNull null
|
||||
// Use deliveryUrl from server if available, otherwise construct it
|
||||
val url = if (!stream.deliveryUrl.isNullOrBlank()) {
|
||||
if (stream.deliveryUrl!!.startsWith("http")) {
|
||||
stream.deliveryUrl!!
|
||||
} else {
|
||||
"$baseUrl${stream.deliveryUrl}"
|
||||
}
|
||||
} else {
|
||||
val format = if (codec == "subrip") "srt" else codec
|
||||
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
|
||||
}
|
||||
|
||||
Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url")
|
||||
|
||||
MediaItem.SubtitleConfiguration.Builder(url.toUri())
|
||||
.setMimeType(mimeType)
|
||||
.setLanguage(stream.language)
|
||||
.setLabel(stream.displayTitle ?: stream.language ?: "Track ${stream.index}")
|
||||
.setSelectionFlags(
|
||||
if (stream.isForced) C.SELECTION_FLAG_FORCED
|
||||
else if (stream.isDefault) C.SELECTION_FLAG_DEFAULT
|
||||
else 0
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun subtitleCodecToMimeType(codec: String): String? = when (codec.lowercase()) {
|
||||
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||
"ass", "ssa" -> MimeTypes.TEXT_SSA
|
||||
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
|
||||
"ttml", "dfxp" -> MimeTypes.APPLICATION_TTML
|
||||
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
|
||||
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
|
||||
else -> {
|
||||
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaItem(
|
||||
mediaId: String,
|
||||
playbackUrl: String,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||
): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setArtworkUri(artworkUrl.toUri())
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(playbackUrl.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setSubtitleConfigurations(subtitleConfigurations)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
package hu.bbara.purefin.player.manager
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.player.model.QueueItemUi
|
||||
import hu.bbara.purefin.player.model.TrackOption
|
||||
import hu.bbara.purefin.player.model.TrackType
|
||||
import hu.bbara.purefin.player.preference.AudioTrackProperties
|
||||
import hu.bbara.purefin.player.preference.SubtitleTrackProperties
|
||||
import hu.bbara.purefin.player.preference.TrackMatcher
|
||||
import hu.bbara.purefin.player.preference.TrackPreferencesRepository
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Encapsulates the Media3 [Player] wiring and exposes reactive updates for the UI layer.
|
||||
*/
|
||||
@ViewModelScoped
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlayerManager @Inject constructor(
|
||||
val player: Player,
|
||||
private val trackMapper: TrackMapper,
|
||||
private val trackPreferencesRepository: TrackPreferencesRepository,
|
||||
private val trackMatcher: TrackMatcher
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var currentMediaContext: MediaContext? = null
|
||||
|
||||
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
|
||||
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
|
||||
|
||||
private val _progress = MutableStateFlow(PlaybackProgressSnapshot())
|
||||
val progress: StateFlow<PlaybackProgressSnapshot> = _progress.asStateFlow()
|
||||
|
||||
private val _metadata = MutableStateFlow(MetadataState())
|
||||
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
|
||||
|
||||
private val _tracks = MutableStateFlow(TrackSelectionState())
|
||||
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
||||
|
||||
private val _queue = MutableStateFlow<List<QueueItemUi>>(emptyList())
|
||||
val queue: StateFlow<List<QueueItemUi>> = _queue.asStateFlow()
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_playbackState.update { it.copy(isPlaying = isPlaying, isBuffering = false, isEnded = false) }
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
val buffering = playbackState == Player.STATE_BUFFERING
|
||||
val ended = playbackState == Player.STATE_ENDED
|
||||
_playbackState.update { state ->
|
||||
state.copy(
|
||||
isBuffering = buffering,
|
||||
isEnded = ended,
|
||||
error = if (playbackState == Player.STATE_IDLE) state.error else null
|
||||
)
|
||||
}
|
||||
if (ended) player.pause()
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
refreshTracks(tracks)
|
||||
scope.launch {
|
||||
applyTrackPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
if (reason == Player.DISCONTINUITY_REASON_SEEK &&
|
||||
newPosition.positionMs < oldPosition.positionMs
|
||||
) {
|
||||
refreshSubtitleRendererOnBackwardSeek()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(listener)
|
||||
refreshMetadata(player.currentMediaItem)
|
||||
refreshTracks(player.currentTracks)
|
||||
refreshQueue()
|
||||
startProgressLoop()
|
||||
}
|
||||
|
||||
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
|
||||
currentMediaContext = mediaContext
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
_playbackState.update { it.copy(isEnded = false, error = null) }
|
||||
}
|
||||
|
||||
fun addToQueue(mediaItem: MediaItem) {
|
||||
player.addMediaItem(mediaItem)
|
||||
refreshQueue()
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
player.seekTo(positionMs)
|
||||
}
|
||||
|
||||
fun seekBy(deltaMs: Long) {
|
||||
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L)
|
||||
seekTo(target)
|
||||
}
|
||||
|
||||
fun seekToLiveEdge() {
|
||||
if (player.isCurrentMediaItemLive) {
|
||||
player.seekToDefaultPosition()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
fun next() {
|
||||
if (player.hasNextMediaItem()) {
|
||||
player.seekToNextMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
fun previous() {
|
||||
if (player.hasPreviousMediaItem()) {
|
||||
player.seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTrack(option: TrackOption) {
|
||||
val builder = player.trackSelectionParameters.buildUpon()
|
||||
when (option.type) {
|
||||
TrackType.TEXT -> {
|
||||
if (option.isOff) {
|
||||
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
} else {
|
||||
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
|
||||
builder.addOverride(
|
||||
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TrackType.AUDIO -> {
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
|
||||
builder.addOverride(
|
||||
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
|
||||
)
|
||||
}
|
||||
|
||||
TrackType.VIDEO -> {
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
|
||||
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
|
||||
builder.addOverride(
|
||||
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
|
||||
)
|
||||
}
|
||||
}
|
||||
player.trackSelectionParameters = builder.build()
|
||||
refreshTracks(player.currentTracks)
|
||||
|
||||
// Save track preference if media context is available
|
||||
currentMediaContext?.let { context ->
|
||||
scope.launch {
|
||||
saveTrackPreference(option, context.preferenceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
player.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
fun playQueueItem(id: String) {
|
||||
val items = _queue.value
|
||||
val targetIndex = items.indexOfFirst { it.id == id }
|
||||
if (targetIndex >= 0) {
|
||||
player.seekToDefaultPosition(targetIndex)
|
||||
player.playWhenReady = true
|
||||
refreshQueue()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_playbackState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
private suspend fun applyTrackPreferences() {
|
||||
val context = currentMediaContext ?: return
|
||||
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return
|
||||
|
||||
val currentTrackState = _tracks.value
|
||||
|
||||
// Apply audio preference
|
||||
preferences.audioPreference?.let { audioPreference ->
|
||||
val matchedAudio = trackMatcher.findBestAudioMatch(
|
||||
currentTrackState.audioTracks,
|
||||
audioPreference
|
||||
)
|
||||
matchedAudio?.let { selectTrack(it) }
|
||||
}
|
||||
|
||||
// Apply subtitle preference
|
||||
preferences.subtitlePreference?.let { subtitlePreference ->
|
||||
val matchedSubtitle = trackMatcher.findBestSubtitleMatch(
|
||||
currentTrackState.textTracks,
|
||||
subtitlePreference
|
||||
)
|
||||
matchedSubtitle?.let { selectTrack(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveTrackPreference(option: TrackOption, preferenceKey: String) {
|
||||
when (option.type) {
|
||||
TrackType.AUDIO -> {
|
||||
val properties = AudioTrackProperties(
|
||||
language = option.language,
|
||||
channelCount = option.channelCount,
|
||||
label = option.label
|
||||
)
|
||||
trackPreferencesRepository.saveAudioPreference(preferenceKey, properties)
|
||||
}
|
||||
|
||||
TrackType.TEXT -> {
|
||||
val properties = SubtitleTrackProperties(
|
||||
language = option.language,
|
||||
forced = option.forced,
|
||||
label = option.label,
|
||||
isOff = option.isOff
|
||||
)
|
||||
trackPreferencesRepository.saveSubtitlePreference(preferenceKey, properties)
|
||||
}
|
||||
|
||||
TrackType.VIDEO -> {
|
||||
// Video preferences not implemented in this feature
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSubtitleRendererOnBackwardSeek() {
|
||||
val currentParams = player.trackSelectionParameters
|
||||
if (C.TRACK_TYPE_TEXT in currentParams.disabledTrackTypes) return
|
||||
scope.launch {
|
||||
player.trackSelectionParameters = currentParams.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
.build()
|
||||
player.trackSelectionParameters = currentParams
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
scope.cancel()
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
|
||||
private fun startProgressLoop() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
|
||||
val position = player.currentPosition
|
||||
val buffered = player.bufferedPosition
|
||||
_progress.value = PlaybackProgressSnapshot(
|
||||
durationMs = duration,
|
||||
positionMs = position,
|
||||
bufferedMs = buffered,
|
||||
isLive = player.isCurrentMediaItemLive
|
||||
)
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshQueue() {
|
||||
val items = mutableListOf<QueueItemUi>()
|
||||
for (i in 0 until player.mediaItemCount) {
|
||||
val mediaItem = player.getMediaItemAt(i)
|
||||
items.add(
|
||||
QueueItemUi(
|
||||
id = mediaItem.mediaId.ifEmpty { i.toString() },
|
||||
title = mediaItem.mediaMetadata.title?.toString() ?: "Item ${i + 1}",
|
||||
subtitle = mediaItem.mediaMetadata.subtitle?.toString(),
|
||||
artworkUrl = mediaItem.mediaMetadata.artworkUri?.toString(),
|
||||
isCurrent = i == player.currentMediaItemIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
_queue.value = items
|
||||
}
|
||||
|
||||
private fun refreshMetadata(mediaItem: MediaItem?) {
|
||||
_metadata.value = MetadataState(
|
||||
mediaId = mediaItem?.mediaId,
|
||||
title = mediaItem?.mediaMetadata?.title?.toString(),
|
||||
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshTracks(tracks: Tracks) {
|
||||
_tracks.value = trackMapper.map(tracks)
|
||||
}
|
||||
}
|
||||
|
||||
data class PlaybackStateSnapshot(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val isEnded: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class PlaybackProgressSnapshot(
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val bufferedMs: Long = 0L,
|
||||
val isLive: Boolean = false
|
||||
)
|
||||
|
||||
data class MetadataState(
|
||||
val mediaId: String? = null,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null
|
||||
)
|
||||
|
||||
data class MediaContext(
|
||||
val mediaId: String,
|
||||
val preferenceKey: String
|
||||
)
|
||||
@@ -1,119 +0,0 @@
|
||||
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
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@ViewModelScoped
|
||||
class ProgressManager @Inject constructor(
|
||||
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(
|
||||
playbackState: StateFlow<PlaybackStateSnapshot>,
|
||||
progress: StateFlow<PlaybackProgressSnapshot>,
|
||||
metadata: StateFlow<MetadataState>
|
||||
) {
|
||||
scope.launch {
|
||||
combine(playbackState, progress, metadata) { state, prog, meta ->
|
||||
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() }
|
||||
|
||||
// Media changed or ended - stop session
|
||||
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
|
||||
stopSession()
|
||||
}
|
||||
|
||||
// Start session when we have a media item and none is active
|
||||
if (activeItemId == null && mediaId != null && !state.isEnded) {
|
||||
startSession(mediaId, prog.positionMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSession(itemId: UUID, positionMs: Long) {
|
||||
activeItemId = itemId
|
||||
report(itemId, positionMs, isStart = true)
|
||||
progressJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(5000)
|
||||
report(itemId, lastPositionMs, isPaused = isPaused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSession() {
|
||||
progressJob?.cancel()
|
||||
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
|
||||
}
|
||||
|
||||
private fun report(itemId: UUID, positionMs: Long, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false) {
|
||||
val ticks = positionMs * 10_000
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
when {
|
||||
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks)
|
||||
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
||||
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
|
||||
}
|
||||
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
|
||||
} catch (e: Exception) {
|
||||
Log.e("ProgressManager", "Report failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
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)
|
||||
updateWatchProgressUseCase(itemId, posMs, durMs)
|
||||
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
|
||||
} catch (e: Exception) {
|
||||
Log.e("ProgressManager", "Report failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package hu.bbara.purefin.player.manager
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import hu.bbara.purefin.player.model.TrackOption
|
||||
import hu.bbara.purefin.player.model.TrackType
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TrackSelectionState(
|
||||
val audioTracks: List<TrackOption> = emptyList(),
|
||||
val textTracks: List<TrackOption> = emptyList(),
|
||||
val videoTracks: List<TrackOption> = emptyList(),
|
||||
val selectedAudioTrackId: String? = null,
|
||||
val selectedTextTrackId: String? = null,
|
||||
val selectedVideoTrackId: String? = null
|
||||
)
|
||||
|
||||
class TrackMapper @Inject constructor() {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun map(tracks: Tracks): TrackSelectionState {
|
||||
val audio = mutableListOf<TrackOption>()
|
||||
val text = mutableListOf<TrackOption>()
|
||||
val video = mutableListOf<TrackOption>()
|
||||
var selectedAudio: String? = null
|
||||
var selectedText: String? = null
|
||||
var selectedVideo: String? = null
|
||||
|
||||
tracks.groups.forEachIndexed { groupIndex, group ->
|
||||
when (group.type) {
|
||||
C.TRACK_TYPE_AUDIO -> {
|
||||
repeat(group.length) { trackIndex ->
|
||||
val format = group.getTrackFormat(trackIndex)
|
||||
val id = "a_${groupIndex}_${trackIndex}"
|
||||
val label = format.label
|
||||
?: format.language
|
||||
?: "${format.channelCount}ch"
|
||||
?: "Audio ${trackIndex}"
|
||||
val option = TrackOption(
|
||||
id = id,
|
||||
label = label,
|
||||
language = format.language,
|
||||
bitrate = format.bitrate,
|
||||
channelCount = format.channelCount,
|
||||
height = null,
|
||||
groupIndex = groupIndex,
|
||||
trackIndex = trackIndex,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
)
|
||||
audio.add(option)
|
||||
if (group.isTrackSelected(trackIndex)) selectedAudio = id
|
||||
}
|
||||
}
|
||||
|
||||
C.TRACK_TYPE_TEXT -> {
|
||||
repeat(group.length) { trackIndex ->
|
||||
val format = group.getTrackFormat(trackIndex)
|
||||
val id = "t_${groupIndex}_${trackIndex}"
|
||||
val label = format.label
|
||||
?: format.language
|
||||
?: "Subtitle ${trackIndex}"
|
||||
val isForced = (format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0
|
||||
val option = TrackOption(
|
||||
id = id,
|
||||
label = label,
|
||||
language = format.language,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = groupIndex,
|
||||
trackIndex = trackIndex,
|
||||
type = TrackType.TEXT,
|
||||
isOff = false,
|
||||
forced = isForced
|
||||
)
|
||||
text.add(option)
|
||||
if (group.isTrackSelected(trackIndex)) selectedText = id
|
||||
}
|
||||
}
|
||||
|
||||
C.TRACK_TYPE_VIDEO -> {
|
||||
repeat(group.length) { trackIndex ->
|
||||
val format = group.getTrackFormat(trackIndex)
|
||||
val id = "v_${groupIndex}_${trackIndex}"
|
||||
val res = if (format.height != Format.NO_VALUE) "${format.height}p" else null
|
||||
val label = res ?: format.label ?: "Video ${trackIndex}"
|
||||
val option = TrackOption(
|
||||
id = id,
|
||||
label = label,
|
||||
language = null,
|
||||
bitrate = format.bitrate,
|
||||
channelCount = null,
|
||||
height = format.height.takeIf { it > 0 },
|
||||
groupIndex = groupIndex,
|
||||
trackIndex = trackIndex,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
)
|
||||
video.add(option)
|
||||
if (group.isTrackSelected(trackIndex)) selectedVideo = id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text.isNotEmpty()) {
|
||||
text.add(
|
||||
0,
|
||||
TrackOption(
|
||||
id = "text_off",
|
||||
label = "Off",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = -1,
|
||||
trackIndex = -1,
|
||||
type = TrackType.TEXT,
|
||||
isOff = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return TrackSelectionState(
|
||||
audioTracks = audio,
|
||||
textTracks = text,
|
||||
videoTracks = video,
|
||||
selectedAudioTrackId = selectedAudio,
|
||||
selectedTextTrackId = selectedText ?: text.firstOrNull { option -> option.isOff }?.id,
|
||||
selectedVideoTrackId = selectedVideo
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package hu.bbara.purefin.player.model
|
||||
|
||||
data class PlayerUiState(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val isEnded: Boolean = false,
|
||||
val isLive: Boolean = false,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val bufferedMs: Long = 0L,
|
||||
val error: String? = null,
|
||||
val playbackSpeed: Float = 1f,
|
||||
val chapters: List<TimedMarker> = emptyList(),
|
||||
val ads: List<TimedMarker> = emptyList(),
|
||||
val queue: List<QueueItemUi> = emptyList(),
|
||||
val audioTracks: List<TrackOption> = emptyList(),
|
||||
val textTracks: List<TrackOption> = emptyList(),
|
||||
val qualityTracks: List<TrackOption> = emptyList(),
|
||||
val selectedAudioTrackId: String? = null,
|
||||
val selectedTextTrackId: String? = null,
|
||||
val selectedQualityTrackId: String? = null,
|
||||
)
|
||||
|
||||
data class TrackOption(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val language: String?,
|
||||
val bitrate: Int?,
|
||||
val channelCount: Int?,
|
||||
val height: Int?,
|
||||
val groupIndex: Int,
|
||||
val trackIndex: Int,
|
||||
val type: TrackType,
|
||||
val isOff: Boolean,
|
||||
val forced: Boolean = false
|
||||
)
|
||||
|
||||
enum class TrackType { AUDIO, TEXT, VIDEO }
|
||||
|
||||
data class TimedMarker(
|
||||
val positionMs: Long,
|
||||
val type: MarkerType,
|
||||
val label: String? = null
|
||||
)
|
||||
|
||||
enum class MarkerType { CHAPTER, AD }
|
||||
|
||||
data class QueueItemUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val artworkUrl: String?,
|
||||
val isCurrent: Boolean
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
package hu.bbara.purefin.player.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
|
||||
data class VideoItem(
|
||||
val title: String,
|
||||
val mediaItem: MediaItem,
|
||||
val uri: Uri
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
package hu.bbara.purefin.player.module
|
||||
|
||||
import android.app.Application
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.SeekParameters
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ViewModelComponent
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
|
||||
@Module
|
||||
@InstallIn(ViewModelComponent::class)
|
||||
object VideoPlayerModule {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
|
||||
val trackSelector = DefaultTrackSelector(application)
|
||||
val audioAttributes =
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
trackSelector.setParameters(
|
||||
trackSelector
|
||||
.buildUponParameters()
|
||||
.setTunnelingEnabled(true)
|
||||
// .setPreferredAudioLanguage(
|
||||
// appPreferences.getValue(appPreferences.preferredAudioLanguage)
|
||||
// )
|
||||
// .setPreferredTextLanguage(
|
||||
// appPreferences.getValue(appPreferences.preferredSubtitleLanguage)
|
||||
// )
|
||||
)
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
25_000,
|
||||
55_000,
|
||||
5_000,
|
||||
5_000
|
||||
)
|
||||
.build()
|
||||
|
||||
// Configure RenderersFactory to use all available decoders and enable passthrough
|
||||
val renderersFactory = DefaultRenderersFactory(application)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
.setEnableDecoderFallback(true)
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory)
|
||||
|
||||
return ExoPlayer.Builder(application, renderersFactory)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setPauseAtEndOfMediaItems(true)
|
||||
.setLoadControl(loadControl)
|
||||
.setSeekParameters(SeekParameters.PREVIOUS_SYNC)
|
||||
.setAudioAttributes(audioAttributes, true)
|
||||
.build()
|
||||
.apply {
|
||||
playWhenReady = true
|
||||
pauseAtEndOfMediaItems = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package hu.bbara.purefin.player.preference
|
||||
|
||||
import hu.bbara.purefin.player.model.TrackOption
|
||||
import hu.bbara.purefin.player.model.TrackType
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrackMatcher @Inject constructor() {
|
||||
|
||||
/**
|
||||
* Finds the best matching audio track based on stored preferences.
|
||||
* Scoring: language (3) + channelCount (2) + label (1)
|
||||
* Requires minimum score of 3 (language match) to auto-select.
|
||||
*
|
||||
* @return The best matching TrackOption, or null if no good match found
|
||||
*/
|
||||
fun findBestAudioMatch(
|
||||
availableTracks: List<TrackOption>,
|
||||
preference: AudioTrackProperties
|
||||
): TrackOption? {
|
||||
if (availableTracks.isEmpty()) return null
|
||||
|
||||
val audioTracks = availableTracks.filter { it.type == TrackType.AUDIO }
|
||||
if (audioTracks.isEmpty()) return null
|
||||
|
||||
val scoredTracks = audioTracks.map { track ->
|
||||
track to calculateAudioScore(track, preference)
|
||||
}
|
||||
|
||||
val bestMatch = scoredTracks.maxByOrNull { it.second }
|
||||
|
||||
// Require minimum score of 3 (language match) to auto-select
|
||||
return if (bestMatch != null && bestMatch.second >= 3) {
|
||||
bestMatch.first
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best matching subtitle track based on stored preferences.
|
||||
* Scoring: language (3) + forced (2) + label (1)
|
||||
* Requires minimum score of 3 (language match) to auto-select.
|
||||
* Handles "Off" preference explicitly.
|
||||
*
|
||||
* @return The best matching TrackOption, or the "Off" option if preference.isOff is true, or null
|
||||
*/
|
||||
fun findBestSubtitleMatch(
|
||||
availableTracks: List<TrackOption>,
|
||||
preference: SubtitleTrackProperties
|
||||
): TrackOption? {
|
||||
if (availableTracks.isEmpty()) return null
|
||||
|
||||
val subtitleTracks = availableTracks.filter { it.type == TrackType.TEXT }
|
||||
if (subtitleTracks.isEmpty()) return null
|
||||
|
||||
// Handle "Off" preference
|
||||
if (preference.isOff) {
|
||||
return subtitleTracks.firstOrNull { it.isOff }
|
||||
}
|
||||
|
||||
val scoredTracks = subtitleTracks
|
||||
.filter { !it.isOff } // Exclude "Off" option when matching specific preferences
|
||||
.map { track ->
|
||||
track to calculateSubtitleScore(track, preference)
|
||||
}
|
||||
|
||||
val bestMatch = scoredTracks.maxByOrNull { it.second }
|
||||
|
||||
// Require minimum score of 3 (language match) to auto-select
|
||||
return if (bestMatch != null && bestMatch.second >= 3) {
|
||||
bestMatch.first
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateAudioScore(
|
||||
track: TrackOption,
|
||||
preference: AudioTrackProperties
|
||||
): Int {
|
||||
var score = 0
|
||||
|
||||
// Language match: 3 points
|
||||
if (track.language != null && track.language == preference.language) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
// Channel count match: 2 points
|
||||
if (track.channelCount != null && track.channelCount == preference.channelCount) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
// Label match: 1 point
|
||||
if (track.label == preference.label) {
|
||||
score += 1
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
private fun calculateSubtitleScore(
|
||||
track: TrackOption,
|
||||
preference: SubtitleTrackProperties
|
||||
): Int {
|
||||
var score = 0
|
||||
|
||||
// Language match: 3 points
|
||||
if (track.language != null && track.language == preference.language) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
// Forced flag match: 2 points
|
||||
if (track.forced == preference.forced) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
// Label match: 1 point
|
||||
if (track.label == preference.label) {
|
||||
score += 1
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package hu.bbara.purefin.player.preference
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TrackPreferences(
|
||||
val mediaPreferences: Map<String, MediaTrackPreferences> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTrackPreferences(
|
||||
val mediaId: String,
|
||||
val audioPreference: AudioTrackProperties? = null,
|
||||
val subtitlePreference: SubtitleTrackProperties? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AudioTrackProperties(
|
||||
val language: String? = null,
|
||||
val channelCount: Int? = null,
|
||||
val label: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubtitleTrackProperties(
|
||||
val language: String? = null,
|
||||
val forced: Boolean = false,
|
||||
val label: String? = null,
|
||||
val isOff: Boolean = false
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
package hu.bbara.purefin.player.preference
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class TrackPreferencesModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTrackPreferencesDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): DataStore<TrackPreferences> {
|
||||
return DataStoreFactory.create(
|
||||
serializer = TrackPreferencesSerializer,
|
||||
produceFile = { context.dataStoreFile("track_preferences.json") },
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||
produceNewData = { TrackPreferencesSerializer.defaultValue }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTrackPreferencesRepository(
|
||||
trackPreferencesDataStore: DataStore<TrackPreferences>
|
||||
): TrackPreferencesRepository {
|
||||
return TrackPreferencesRepository(trackPreferencesDataStore)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package hu.bbara.purefin.player.preference
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrackPreferencesRepository @Inject constructor(
|
||||
private val trackPreferencesDataStore: DataStore<TrackPreferences>
|
||||
) {
|
||||
val preferences: Flow<TrackPreferences> = trackPreferencesDataStore.data
|
||||
|
||||
fun getMediaPreferences(mediaId: String): Flow<MediaTrackPreferences?> {
|
||||
return preferences.map { it.mediaPreferences[mediaId] }
|
||||
}
|
||||
|
||||
suspend fun saveAudioPreference(
|
||||
mediaId: String,
|
||||
properties: AudioTrackProperties
|
||||
) {
|
||||
trackPreferencesDataStore.updateData { current ->
|
||||
val existingMediaPrefs = current.mediaPreferences[mediaId]
|
||||
val updatedMediaPrefs = existingMediaPrefs?.copy(audioPreference = properties)
|
||||
?: MediaTrackPreferences(
|
||||
mediaId = mediaId,
|
||||
audioPreference = properties
|
||||
)
|
||||
|
||||
current.copy(
|
||||
mediaPreferences = current.mediaPreferences + (mediaId to updatedMediaPrefs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSubtitlePreference(
|
||||
mediaId: String,
|
||||
properties: SubtitleTrackProperties
|
||||
) {
|
||||
trackPreferencesDataStore.updateData { current ->
|
||||
val existingMediaPrefs = current.mediaPreferences[mediaId]
|
||||
val updatedMediaPrefs = existingMediaPrefs?.copy(subtitlePreference = properties)
|
||||
?: MediaTrackPreferences(
|
||||
mediaId = mediaId,
|
||||
subtitlePreference = properties
|
||||
)
|
||||
|
||||
current.copy(
|
||||
mediaPreferences = current.mediaPreferences + (mediaId to updatedMediaPrefs)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package hu.bbara.purefin.player.preference
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object TrackPreferencesSerializer : Serializer<TrackPreferences> {
|
||||
override val defaultValue: TrackPreferences
|
||||
get() = TrackPreferences()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): TrackPreferences {
|
||||
try {
|
||||
return Json.decodeFromString<TrackPreferences>(
|
||||
input.readBytes().decodeToString()
|
||||
)
|
||||
} catch (serialization: SerializationException) {
|
||||
throw CorruptionException("Unable to read TrackPreferences", serialization)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: TrackPreferences, output: OutputStream) {
|
||||
output.write(
|
||||
Json.encodeToString(TrackPreferences.serializer(), t)
|
||||
.encodeToByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package hu.bbara.purefin.player.stream
|
||||
|
||||
class MediaSourceSelector {
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import hu.bbara.purefin.common.ui.components.EmptyValueTimedVisibility
|
||||
import hu.bbara.purefin.common.ui.components.ValueChangeTimedVisibility
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.player.ui.components.PersistentOverlayContainer
|
||||
import hu.bbara.purefin.player.ui.components.PlayerAdjustmentIndicator
|
||||
import hu.bbara.purefin.player.ui.components.PlayerControlsOverlay
|
||||
@@ -46,7 +47,6 @@ import hu.bbara.purefin.player.ui.components.PlayerGesturesLayer
|
||||
import hu.bbara.purefin.player.ui.components.PlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.player.ui.components.PlayerQueuePanel
|
||||
import hu.bbara.purefin.player.ui.components.rememberPersistentOverlayController
|
||||
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user