refactor: modularize app into multi-module architecture

This commit is contained in:
2026-02-21 00:15:51 +01:00
parent 8601ef0236
commit 7333781f83
123 changed files with 668 additions and 404 deletions

View File

@@ -0,0 +1,38 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "hu.bbara.purefin.feature.shared"
compileSdk = 36
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":feature:download"))
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.jellyfin.core)
implementation(libs.androidx.lifecycle.viewmodel.compose)
}

View File

@@ -0,0 +1,25 @@
package hu.bbara.purefin.feature.shared.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

@@ -0,0 +1,45 @@
package hu.bbara.purefin.feature.shared.content.episode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.model.Episode
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

@@ -0,0 +1,24 @@
package hu.bbara.purefin.feature.shared.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

@@ -0,0 +1,92 @@
package hu.bbara.purefin.feature.shared.content.movie
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager
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

@@ -0,0 +1,48 @@
package hu.bbara.purefin.feature.shared.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

@@ -0,0 +1,64 @@
package hu.bbara.purefin.feature.shared.content.series
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.Series
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

@@ -0,0 +1,81 @@
package hu.bbara.purefin.feature.shared.home
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.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")
}
}

View File

@@ -0,0 +1,213 @@
package hu.bbara.purefin.feature.shared.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.data.domain.usecase.RefreshHomeDataUseCase
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LibraryDto
import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.data.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(id: UUID, name: String) {
viewModelScope.launch {
navigationManager.navigate(Route.LibraryRoute(library = LibraryDto(id = id, name = name)))
}
}
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

@@ -0,0 +1,79 @@
package hu.bbara.purefin.feature.shared.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.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

@@ -0,0 +1,67 @@
package hu.bbara.purefin.feature.shared.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.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
}
}