refactor: modularize app into multi-module architecture

This commit is contained in:
2026-02-21 00:15:51 +01:00
parent a14a694e19
commit 0de4ddcbc3
123 changed files with 668 additions and 404 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
package hu.bbara.purefin.data.model
data class CastMember(
val name: String,
val role: String,
val imageUrl: String?
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
package hu.bbara.purefin.player.stream
class MediaSourceSelector {
}

View File

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