refactor: update package structure and imports for consistency across UI components

This commit is contained in:
2026-04-14 22:03:36 +02:00
parent 1e6c0e838e
commit b67c8076f1
146 changed files with 1239 additions and 1267 deletions

View File

@@ -21,9 +21,9 @@ import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.tv.navigation.LocalNavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
import org.jellyfin.sdk.model.api.ImageType

View File

@@ -21,7 +21,7 @@ import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.feature.shared.navigation.MovieDto
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel

View File

@@ -22,11 +22,11 @@ import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.feature.shared.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
import org.jellyfin.sdk.model.UUID
import java.util.UUID
@Composable
fun TvSeriesScreen(

View File

@@ -34,8 +34,8 @@ import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
@Composable
fun PosterCard(
@@ -57,9 +57,9 @@ fun PosterCard(
fun openItem(posterItem: PosterItem) {
when (posterItem.type) {
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
BaseItemKind.SERIES -> onSeriesSelected(posterItem.id)
BaseItemKind.EPISODE -> {
MediaKind.MOVIE -> onMovieSelected(posterItem.id)
MediaKind.SERIES -> onSeriesSelected(posterItem.id)
MediaKind.EPISODE -> {
val ep = posterItem.episode!!
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
}
@@ -99,7 +99,7 @@ fun PosterCard(
contentScale = ContentScale.Crop
)
when (item.type) {
BaseItemKind.MOVIE -> {
MediaKind.MOVIE -> {
val m = item.movie!!
WatchStateIndicator(
size = indicatorSize,
@@ -109,7 +109,7 @@ fun PosterCard(
started = (m.progress ?: 0.0) > 0
)
}
BaseItemKind.EPISODE -> {
MediaKind.EPISODE -> {
val ep = item.episode!!
WatchStateIndicator(
size = indicatorSize,
@@ -119,7 +119,7 @@ fun PosterCard(
started = (ep.progress ?: 0.0) > 0
)
}
BaseItemKind.SERIES -> UnwatchedEpisodeIndicator(
MediaKind.SERIES -> UnwatchedEpisodeIndicator(
size = indicatorSize,
modifier = Modifier.align(Alignment.TopEnd)
.padding(indicatorPadding),

View File

@@ -39,13 +39,13 @@ import dagger.hilt.android.AndroidEntryPoint
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.LocalNavigationBackStack
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.feature.shared.navigation.NavigationCommand
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.tv.navigation.LocalNavigationBackStack
import hu.bbara.purefin.tv.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark
import kotlinx.coroutines.launch

View File

@@ -21,7 +21,7 @@ import hu.bbara.purefin.tv.home.ui.TvDrawerDestination
import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem
import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer
import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen
import org.jellyfin.sdk.model.api.CollectionType
import hu.bbara.purefin.core.model.LibraryKind
@Composable
fun TvAppScreen(
@@ -48,8 +48,8 @@ fun TvAppScreen(
destination = TvDrawerDestination.LIBRARIES,
label = "Libraries",
icon = when {
libraries.any { it.type == CollectionType.MOVIES } -> Icons.Outlined.Movie
libraries.any { it.type == CollectionType.TVSHOWS } -> Icons.Outlined.Tv
libraries.any { it.type == LibraryKind.MOVIES } -> Icons.Outlined.Movie
libraries.any { it.type == LibraryKind.SERIES } -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
},
selected = selectedDestination == TvDrawerDestination.LIBRARIES

View File

@@ -11,7 +11,7 @@ import hu.bbara.purefin.core.data.download.MediaDownloadController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import org.jellyfin.sdk.model.UUID
import java.util.UUID
import javax.inject.Singleton
@Module

View File

@@ -16,7 +16,7 @@ import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.ui.TvFocusedItemHero
import hu.bbara.purefin.tv.home.ui.TvHomeContent
import hu.bbara.purefin.tv.home.ui.rememberTvHomeHeroState
import org.jellyfin.sdk.model.UUID
import java.util.UUID
private const val TvHomeHeroHeightFraction = 0.32f
private val TvHomeMinHeroHeight = 160.dp

View File

@@ -8,8 +8,8 @@ import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType
import kotlin.math.roundToInt
@@ -26,16 +26,16 @@ internal data class TvFocusedHeroModel(
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
return when (this) {
is ContinueWatchingItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
MediaKind.MOVIE -> movie!!.toTvFocusedHeroModel()
MediaKind.EPISODE -> episode!!.toTvFocusedHeroModel()
else -> unsupportedType(type)
}
is NextUpItem -> episode.toTvFocusedHeroModel()
is PosterItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel()
MediaKind.MOVIE -> movie!!.toTvFocusedHeroModel()
MediaKind.EPISODE -> episode!!.toTvFocusedHeroModel()
MediaKind.SERIES -> series!!.toTvFocusedHeroModel()
else -> unsupportedType(type)
}
}
@@ -117,6 +117,6 @@ private fun seasonLabel(seasonCount: Int): String {
return if (seasonCount == 1) "1 season" else "$seasonCount seasons"
}
private fun unsupportedType(type: BaseItemKind): Nothing {
private fun unsupportedType(type: MediaKind): Nothing {
throw UnsupportedOperationException("Unsupported item type: $type")
}

View File

@@ -29,7 +29,7 @@ import hu.bbara.purefin.feature.shared.home.FocusableItem
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
import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"

View File

@@ -10,7 +10,7 @@ import hu.bbara.purefin.feature.shared.home.FocusableItem
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
import java.util.UUID
internal data class TvHomeItemRegistry(
val visibleLibraries: List<LibraryItem>,

View File

@@ -52,8 +52,8 @@ import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
@@ -93,12 +93,12 @@ fun TvContinueWatchingSection(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = when (item.type) {
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
@@ -124,8 +124,8 @@ fun TvContinueWatchingSection(
onFocusedItem = { onFocusedItem(item) },
onClick = {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
MediaKind.MOVIE -> onMovieSelected(item.movie!!.id)
MediaKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}

View File

@@ -45,7 +45,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.LibraryItem
import org.jellyfin.sdk.model.api.CollectionType
import hu.bbara.purefin.core.model.LibraryKind
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
@@ -161,9 +161,8 @@ private fun TvLibraryOverviewCard(
} else {
Icon(
imageVector = when (item.type) {
CollectionType.MOVIES -> Icons.Outlined.Movie
CollectionType.TVSHOWS -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
LibraryKind.MOVIES -> Icons.Outlined.Movie
LibraryKind.SERIES -> Icons.Outlined.Tv
},
contentDescription = null,
tint = scheme.onSurfaceVariant,

View File

@@ -24,10 +24,10 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.core.data.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
import org.jellyfin.sdk.model.UUID
import java.util.UUID
@Composable
fun TvLibraryScreen(

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.tv.navigation
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }

View File

@@ -6,7 +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
import hu.bbara.purefin.feature.shared.navigation.Route
@Module
@InstallIn(ActivityRetainedComponent::class)

View File

@@ -4,8 +4,7 @@ import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.app.content.episode.TvEpisodeScreen
import hu.bbara.purefin.app.content.movie.TvMovieScreen
import hu.bbara.purefin.app.content.series.TvSeriesScreen
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.tv.TvAppScreen
import hu.bbara.purefin.tv.library.ui.TvLibraryScreen

View File

@@ -36,16 +36,16 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
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.feature.shared.navigation.NavigationCommand
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark
import kotlinx.coroutines.launch

View File

@@ -1,13 +0,0 @@
package hu.bbara.purefin.app.home
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.core.data.navigation.Route
/**
* Navigation 3 entry definition for the Home section.
*/
fun EntryProviderScope<Route>.homeSection() {
entry<Route.Home> {
AppScreen()
}
}

View File

@@ -1,111 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.SearchResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PurefinSearchBar(
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
searchResults: List<SearchResult>,
onExpandedChange: (Boolean) -> Unit = {},
modifier: Modifier = Modifier
) {
var query by remember { mutableStateOf("") }
var expanded by rememberSaveable { mutableStateOf(false) }
val setExpanded: (Boolean) -> Unit = {
expanded = it
onExpandedChange(it)
}
Box(
modifier
.fillMaxWidth()
.semantics { isTraversalGroup = true }
) {
SearchBar(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.semantics { traversalIndex = 0f },
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = {
query = it
onQueryChange(it)
},
onSearch = {
onSearch(query)
setExpanded(false)
},
expanded = expanded,
onExpandedChange = setExpanded,
placeholder = { Text("Search") }
)
},
expanded = expanded,
onExpandedChange = setExpanded,
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.background)
) {
items(searchResults) { item ->
SearchResultCard(item)
}
}
}
}
}
@Composable
private fun SearchResultCard(
item: SearchResult,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
PurefinAsyncImage(
model = item.posterUrl,
contentDescription = item.title,
modifier = Modifier.height(150.dp),
contentScale = ContentScale.Fit
)
Text(
text = item.title,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}

View File

@@ -1,56 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun SearchField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
backgroundColor: Color = Color.Unspecified,
textColor: Color = Color.Unspecified,
cursorColor: Color = Color.Unspecified,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val resolvedBackgroundColor =
if (backgroundColor == Color.Unspecified) scheme.surfaceVariant else backgroundColor
val resolvedTextColor = if (textColor == Color.Unspecified) scheme.onSurface else textColor
val resolvedCursorColor = if (cursorColor == Color.Unspecified) scheme.primary else cursorColor
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
singleLine = true,
placeholder = { Text(placeholder) },
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = null) },
colors = TextFieldDefaults.colors(
focusedContainerColor = resolvedBackgroundColor,
unfocusedContainerColor = resolvedBackgroundColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = resolvedCursorColor,
focusedTextColor = resolvedTextColor,
unfocusedTextColor = resolvedTextColor,
focusedLeadingIconColor = scheme.onSurfaceVariant,
unfocusedLeadingIconColor = scheme.onSurfaceVariant,
focusedPlaceholderColor = scheme.onSurfaceVariant,
unfocusedPlaceholderColor = scheme.onSurfaceVariant,
))
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.navigation
package hu.bbara.purefin.modules
import androidx.navigation3.runtime.EntryProviderScope
import dagger.Module
@@ -6,7 +6,8 @@ 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
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.navigation.appRouteEntryBuilder
@Module
@InstallIn(ActivityRetainedComponent::class)

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.di
package hu.bbara.purefin.modules
import dagger.Module
import dagger.Provides
@@ -12,4 +12,4 @@ object PlaybackProfileFamilyModule {
@Provides
fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.MOBILE
}
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.navigation
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }

View File

@@ -1,13 +1,13 @@
package hu.bbara.purefin.navigation
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.ui.screen.library.LibraryScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.AppScreen
import hu.bbara.purefin.ui.screen.episode.EpisodeScreen
import hu.bbara.purefin.ui.screen.movie.MovieScreen
import hu.bbara.purefin.ui.screen.series.SeriesScreen
import hu.bbara.purefin.app.home.AppScreen
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() {
entry<Route.Home> {

View File

@@ -12,7 +12,7 @@ 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.ui.screen.player.PlayerScreen
import hu.bbara.purefin.ui.theme.AppTheme
@AndroidEntryPoint

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui
package hu.bbara.purefin.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -27,7 +27,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.components.PurefinSearchBar
import hu.bbara.purefin.feature.shared.search.SearchViewModel
@Composable
@@ -56,24 +55,6 @@ fun DefaultTopBar(
.fillMaxWidth(),
contentAlignment = Alignment.CenterEnd
) {
PurefinSearchBar(
onQueryChange = {
searchViewModel.search(it)
},
onSearch = {
searchViewModel.search(it)
},
onExpandedChange = { expanded ->
isSearchExpanded = expanded
if (expanded) {
isProfileMenuExpanded = false
}
},
searchResults = searchResult.value,
modifier = Modifier
.fillMaxWidth()
.padding(end = if (isSearchExpanded) 0.dp else 72.dp),
)
if (!isSearchExpanded) {
Box {
IconButton(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.shared
package hu.bbara.purefin.ui.common.badge
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.badge
import androidx.compose.foundation.background
import androidx.compose.foundation.border

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.badge
import androidx.compose.foundation.background
import androidx.compose.foundation.border

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.bar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -77,7 +77,7 @@ fun MediaResumeButton(
}
@Composable
private fun ButtonContent(text: String, color: androidx.compose.ui.graphics.Color) {
private fun ButtonContent(text: String, color: Color) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Button

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui
package hu.bbara.purefin.ui.common.card
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -23,12 +23,12 @@ 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.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
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeIndicator
import hu.bbara.purefin.ui.common.badge.WatchStateIndicator
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
@Composable
fun PosterCard(
@@ -47,9 +47,9 @@ fun PosterCard(
fun openItem(posterItem: PosterItem) {
when (posterItem.type) {
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
BaseItemKind.SERIES -> onSeriesSelected(posterItem.id)
BaseItemKind.EPISODE -> {
MediaKind.MOVIE -> onMovieSelected(posterItem.id)
MediaKind.SERIES -> onSeriesSelected(posterItem.id)
MediaKind.EPISODE -> {
val ep = posterItem.episode!!
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
}
@@ -78,7 +78,7 @@ fun PosterCard(
contentScale = ContentScale.Crop
)
when (item.type) {
BaseItemKind.MOVIE -> {
MediaKind.MOVIE -> {
val m = item.movie!!
WatchStateIndicator(
size = 28,
@@ -88,7 +88,7 @@ fun PosterCard(
started = (m.progress ?: 0.0) > 0
)
}
BaseItemKind.EPISODE -> {
MediaKind.EPISODE -> {
val ep = item.episode!!
WatchStateIndicator(
size = 28,
@@ -98,7 +98,7 @@ fun PosterCard(
started = (ep.progress ?: 0.0) > 0
)
}
BaseItemKind.SERIES -> UnwatchedEpisodeIndicator(
MediaKind.SERIES -> UnwatchedEpisodeIndicator(
size = 28,
modifier = Modifier.align(Alignment.TopEnd)
.padding(8.dp),

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.shared
package hu.bbara.purefin.ui.common.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.image
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui
package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -33,8 +33,8 @@ import androidx.compose.ui.unit.Dp
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.core.model.CastMember
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable
fun MediaMetaChip(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -11,6 +11,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable
fun MediaHero(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui
package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui
package hu.bbara.purefin.ui.common.textfield
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui
package hu.bbara.purefin.ui.common.textfield
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.visual
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.ui.screen
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.ui.screen
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -10,8 +10,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.feature.shared.home.AppViewModel
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen
@Composable
fun AppScreen(
@@ -52,7 +55,12 @@ fun AppScreen(
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
onLibrarySelected = { library -> viewModel.onLibrarySelected(library.id, library.name) },
onLibrarySelected = { library ->
viewModel.onLibrarySelected(
library.id,
library.name
)
},
onProfileClick = {},
onSettingsClick = {},
onLogoutClick = viewModel::logout,

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.ui.screen.download
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -6,7 +6,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.app.home.ui.downloads.DownloadsContent
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
import hu.bbara.purefin.ui.screen.AppBottomBar
@Composable
fun DownloadsScreen(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.downloads
package hu.bbara.purefin.ui.screen.download.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -26,7 +26,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.download.ActiveDownloadItem
@Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.downloads
package hu.bbara.purefin.ui.screen.download.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -23,7 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.feature.shared.download.DownloadsViewModel
@Composable

View File

@@ -34,14 +34,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel

View File

@@ -34,12 +34,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaSynopsis
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.ui.common.media.MediaCastRow
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.player.PlayerActivity

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -13,15 +13,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import hu.bbara.purefin.app.home.ui.HomeContent
import hu.bbara.purefin.app.home.ui.HomeTopBar
import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay
import hu.bbara.purefin.ui.screen.home.components.HomeContent
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
import hu.bbara.purefin.ui.screen.home.components.search.HomeSearchOverlay
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 hu.bbara.purefin.feature.shared.home.SuggestedItem
import org.jellyfin.sdk.model.UUID
import hu.bbara.purefin.ui.screen.AppBottomBar
import java.util.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui
package hu.bbara.purefin.ui.screen.home.components
import android.util.Log
import androidx.compose.foundation.background
@@ -22,18 +22,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.continuewatching.ContinueWatchingSection
import hu.bbara.purefin.app.home.ui.featured.SuggestionsSection
import hu.bbara.purefin.app.home.ui.library.LibraryPosterSection
import hu.bbara.purefin.app.home.ui.nextup.NextUpSection
import hu.bbara.purefin.app.home.ui.shared.HomeEmptyState
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 hu.bbara.purefin.feature.shared.home.SuggestedItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection
import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection
import hu.bbara.purefin.ui.screen.home.components.nextup.NextUpSection
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -106,9 +105,9 @@ fun HomeContent(
items = suggestions,
onItemOpen = { item ->
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.id)
BaseItemKind.SERIES -> onSeriesSelected(item.id)
BaseItemKind.EPISODE -> onEpisodeSelected(item.id, item.id, item.id)
MediaKind.MOVIE -> onMovieSelected(item.id)
MediaKind.SERIES -> onSeriesSelected(item.id)
MediaKind.EPISODE -> onEpisodeSelected(item.id, item.id, item.id)
else -> {
Log.e("HomeContent", "Unsupported item type: ${item.type}")
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.shared
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.badge.ContentBadge
@Composable
fun HomeEmptyState(

View File

@@ -1,6 +1,6 @@
package hu.bbara.purefin.app.home.ui
package hu.bbara.purefin.ui.screen.home.components
import org.jellyfin.sdk.model.UUID
import java.util.UUID
data class HomeNavItem(
val id: UUID,

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -31,7 +31,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.PurefinLogo
import hu.bbara.purefin.ui.common.image.PurefinLogo
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.continuewatching
package hu.bbara.purefin.ui.screen.home.components.continuewatching
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -23,12 +23,12 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType
@Composable
@@ -40,12 +40,12 @@ internal fun ContinueWatchingCard(
) {
val scheme = MaterialTheme.colorScheme
val supportingText = when (item.type) {
BaseItemKind.MOVIE -> listOf(
MediaKind.MOVIE -> listOf(
item.movie?.year,
item.movie?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
BaseItemKind.EPISODE -> listOf(
MediaKind.EPISODE -> listOf(
"Episode ${item.episode?.index}",
item.episode?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
@@ -53,11 +53,11 @@ internal fun ContinueWatchingCard(
else -> ""
}
val imageUrl = when (item.type) {
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
@@ -74,8 +74,8 @@ internal fun ContinueWatchingCard(
.fillMaxWidth()
.clickable {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
MediaKind.MOVIE -> onMovieSelected(item.movie!!.id)
MediaKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.continuewatching
package hu.bbara.purefin.ui.screen.home.components.continuewatching
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,9 +9,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.shared.SectionHeader
import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import org.jellyfin.sdk.model.UUID
import java.util.UUID
@Composable
fun ContinueWatchingSection(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.featured
package hu.bbara.purefin.ui.screen.home.components.featured
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -24,8 +24,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.SuggestedItem
@Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.featured
package hu.bbara.purefin.ui.screen.home.components.featured
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.library
package hu.bbara.purefin.ui.screen.home.components.library
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -23,12 +23,12 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeIndicator
import hu.bbara.purefin.ui.common.badge.WatchStateIndicator
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
@Composable
internal fun HomeBrowseCard(
@@ -40,16 +40,16 @@ internal fun HomeBrowseCard(
) {
val scheme = MaterialTheme.colorScheme
val supportingText = when (item.type) {
BaseItemKind.MOVIE -> listOf(
MediaKind.MOVIE -> listOf(
item.movie?.year,
item.movie?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
BaseItemKind.SERIES -> item.series!!.let { series ->
MediaKind.SERIES -> item.series!!.let { series ->
if (series.seasonCount == 1) "1 season" else "${series.seasonCount} seasons"
}
BaseItemKind.EPISODE -> listOf(
MediaKind.EPISODE -> listOf(
"Episode ${item.episode?.index}",
item.episode?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
@@ -67,9 +67,9 @@ internal fun HomeBrowseCard(
.fillMaxWidth()
.clickable {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.id)
BaseItemKind.SERIES -> onSeriesSelected(item.id)
BaseItemKind.EPISODE -> {
MediaKind.MOVIE -> onMovieSelected(item.id)
MediaKind.SERIES -> onSeriesSelected(item.id)
MediaKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
@@ -92,7 +92,7 @@ internal fun HomeBrowseCard(
contentScale = ContentScale.Crop
)
when (item.type) {
BaseItemKind.MOVIE -> {
MediaKind.MOVIE -> {
val movie = item.movie!!
WatchStateIndicator(
size = 28,
@@ -104,7 +104,7 @@ internal fun HomeBrowseCard(
)
}
BaseItemKind.EPISODE -> {
MediaKind.EPISODE -> {
val episode = item.episode!!
WatchStateIndicator(
size = 28,
@@ -116,7 +116,7 @@ internal fun HomeBrowseCard(
)
}
BaseItemKind.SERIES -> {
MediaKind.SERIES -> {
UnwatchedEpisodeIndicator(
size = 28,
modifier = Modifier

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.library
package hu.bbara.purefin.ui.screen.home.components.library
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,10 +9,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.shared.SectionHeader
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import hu.bbara.purefin.ui.common.header.SectionHeader
import java.util.UUID
@Composable
fun LibraryPosterSection(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.nextup
package hu.bbara.purefin.ui.screen.home.components.nextup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -22,10 +22,10 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.feature.shared.home.NextUpItem
import org.jellyfin.sdk.model.UUID
import java.util.UUID
import org.jellyfin.sdk.model.api.ImageType
@Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.nextup
package hu.bbara.purefin.ui.screen.home.components.nextup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,9 +9,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.shared.SectionHeader
import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.feature.shared.home.NextUpItem
import org.jellyfin.sdk.model.UUID
import java.util.UUID
@Composable
fun NextUpSection(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.search
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
@@ -36,8 +36,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.shared.search.SearchViewModel
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -142,8 +142,8 @@ fun HomeSearchOverlay(
item = item,
onClick = {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.id)
BaseItemKind.SERIES -> onSeriesSelected(item.id)
MediaKind.MOVIE -> onMovieSelected(item.id)
MediaKind.SERIES -> onSeriesSelected(item.id)
else -> Unit
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.search
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -17,9 +17,9 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.model.SearchResult
import org.jellyfin.sdk.model.api.BaseItemKind
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.shared.search.SearchResult
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable
internal fun HomeSearchResultCard(
@@ -59,8 +59,8 @@ internal fun HomeSearchResultCard(
)
Text(
text = when (item.type) {
BaseItemKind.MOVIE -> "Movie"
BaseItemKind.SERIES -> "Series"
MediaKind.MOVIE -> "Movie"
MediaKind.SERIES -> "Series"
else -> "Title"
},
style = MaterialTheme.typography.labelMedium,

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.search
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.ui.screen.libraries
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -6,9 +6,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.app.home.ui.DefaultTopBar
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.app.home.ui.libraries.LibrariesContent
import hu.bbara.purefin.ui.common.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
@Composable
fun LibrariesScreen(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.libraries
package hu.bbara.purefin.ui.screen.libraries.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -9,7 +9,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
@Composable
fun LibrariesContent(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui.libraries
package hu.bbara.purefin.ui.screen.libraries.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -11,8 +11,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable
fun LibraryListItem(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.library.ui
package hu.bbara.purefin.ui.screen.library
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -21,9 +21,9 @@ 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.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.core.data.navigation.LibraryDto
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.feature.shared.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.login.ui
package hu.bbara.purefin.ui.screen.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -37,10 +37,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
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.ui.common.textfield.PurefinComplexTextField
import hu.bbara.purefin.ui.common.textfield.PurefinPasswordField
import hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.feature.shared.login.LoginViewModel
import kotlinx.coroutines.launch

View File

@@ -32,12 +32,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.feature.shared.navigation.MovieDto
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel

View File

@@ -29,12 +29,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaSynopsis
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.ui.common.media.MediaCastRow
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.player.PlayerActivity

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui
package hu.bbara.purefin.ui.screen.player
import android.app.Activity
import android.content.Context
@@ -37,16 +37,16 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.util.UnstableApi
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
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.ui.common.visual.EmptyValueTimedVisibility
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer
import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -101,7 +101,7 @@ fun PlayerScreen(
onTap = { viewModel.toggleControlsVisibility() },
onDoubleTapRight = { viewModel.seekBy(30_000) },
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
onDoubleTapCenter = {viewModel.togglePlayPause()},
onDoubleTapCenter = { viewModel.togglePlayPause() },
onVerticalDragLeft = { delta ->
val diff = (-delta / 800f)
brightness = (brightness + diff).coerceIn(0f, 1f)

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -35,8 +35,8 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -30,7 +30,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.ui.components
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -35,7 +35,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.core.player.model.TrackOption
@Composable

View File

@@ -30,12 +30,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season

View File

@@ -57,19 +57,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.badge.WatchStateIndicator
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui
package hu.bbara.purefin.ui.screen.waiting
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode

View File

@@ -3,8 +3,9 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}
}

View File

@@ -3,7 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
@@ -21,10 +20,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
kotlin {
@@ -43,12 +38,7 @@ dependencies {
implementation(libs.datastore)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.navigation3.runtime)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
testImplementation(libs.junit)
}

View File

@@ -1,10 +1,11 @@
package hu.bbara.purefin.core.data
import android.util.Log
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -12,41 +13,22 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Switches between [OfflineMediaRepository] and [AppContentRepository] based on
* [UserSessionRepository.isOfflineMode]. When offline mode is enabled, all reads
* and writes go through the offline (downloaded) repository; otherwise the online
* repository is used.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class CompositeMediaRepository @Inject constructor(
private val offlineRepository: OfflineMediaRepository,
private val onlineRepository: InMemoryMediaRepository,
private val networkMonitor: NetworkMonitor,
) : MediaRepository {
) : MediaCatalogReader, MediaProgressWriter {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val ready: StateFlow<Boolean> = combine(
offlineRepository.ready,
onlineRepository.ready
) { offlineReady, onlineReady ->
offlineReady && onlineReady
}.stateIn(scope, SharingStarted.Eagerly, false)
private val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn(scope, SharingStarted.Eagerly, false)
private val activeRepository: Flow<MediaRepository> =
private val activeRepository: Flow<MediaCatalogReader> =
networkMonitor.isOnline.flatMapLatest { online ->
flowOf(if (online) onlineRepository else offlineRepository)
}
@@ -63,42 +45,12 @@ class CompositeMediaRepository @Inject constructor(
.flatMapLatest { it.episodes }
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun upsertMovies(movies: List<Movie>) {
if (!isOnline.value) {
Log.e("CompositeMediaRepository", "upsertMovies called in offline mode")
return
}
onlineRepository.upsertMovies(movies)
}
override fun upsertSeries(series: List<Series>) {
if (!isOnline.value) {
Log.e("CompositeMediaRepository", "upsertSeries called in offline mode")
return
}
onlineRepository.upsertSeries(series)
}
override fun upsertEpisodes(episodes: List<Episode>) {
if (!isOnline.value) {
Log.e("CompositeMediaRepository", "upsertEpisodes called in offline mode")
return
}
onlineRepository.upsertEpisodes(episodes)
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
val isOnline = networkMonitor.isOnline.stateIn(scope).value
val repo = if (isOnline) onlineRepository else offlineRepository
repo.updateWatchProgress(mediaId, positionMs, durationMs)
}
override fun setReady() {
onlineRepository.setReady()
offlineRepository.setReady()
val repository = if (networkMonitor.isOnline.first()) onlineRepository else offlineRepository
repository.updateWatchProgress(mediaId, positionMs, durationMs)
}
}

View File

@@ -0,0 +1,14 @@
package hu.bbara.purefin.core.data
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DefaultEpisodeSeriesLookup @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader,
) : EpisodeSeriesLookup {
override suspend fun preferenceKeyFor(mediaId: UUID): String {
return mediaCatalogReader.episodes.value[mediaId]?.seriesId?.toString() ?: mediaId.toString()
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.player.data
package hu.bbara.purefin.core.data
import android.util.Log
import androidx.annotation.OptIn
@@ -6,29 +6,29 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackDecision
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
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 java.util.UUID
import javax.inject.Inject
@ViewModelScoped
class PlayerMediaRepository @Inject constructor(
@Singleton
class DefaultPlayableMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository
) {
private val userSessionRepository: UserSessionRepository,
) : PlayableMediaRepository {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
override suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
val playbackDecision = jellyfinApiClient.getPlaybackDecision(mediaId) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -49,7 +49,11 @@ class PlayerMediaRepository @Inject constructor(
Pair(mediaItem, resumePositionMs)
}
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 9): List<MediaItem> = withContext(Dispatchers.IO) {
override suspend fun getNextUpMediaItems(
episodeId: UUID,
existingIds: Set<String>,
count: Int,
): List<MediaItem> = withContext(Dispatchers.IO) {
runCatching {
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
@@ -71,7 +75,7 @@ class PlayerMediaRepository @Inject constructor(
)
}
}.getOrElse { error ->
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error)
emptyList()
}
}
@@ -99,7 +103,7 @@ class PlayerMediaRepository @Inject constructor(
playbackCustomCacheKey(
mediaId = mediaId,
playbackUrl = playbackDecision.url,
playMethod = playbackDecision.reportContext.playMethod
playMethod = playbackDecision.reportContext.playMethod,
)?.let(builder::setCustomCacheKey)
return builder.build()
@@ -107,25 +111,17 @@ class PlayerMediaRepository @Inject constructor(
private fun calculateResumePosition(
baseItem: BaseItemDto?,
mediaSource: MediaSourceInfo
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
}

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.data
import java.util.UUID
interface EpisodeSeriesLookup {
suspend fun preferenceKeyFor(mediaId: UUID): String
}

View File

@@ -2,11 +2,10 @@ package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.Media
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
import kotlinx.coroutines.flow.StateFlow
interface AppContentRepository : MediaRepository {
interface HomeRepository {
val libraries: StateFlow<List<Library>>
val suggestions: StateFlow<List<Media>>
val continueWatching: StateFlow<List<Media>>

View File

@@ -9,27 +9,11 @@ import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
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
@@ -37,75 +21,114 @@ import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
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 kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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
@Singleton
class InMemoryAppContentRepository @Inject constructor(
val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache>,
private val mediaRepository: CompositeMediaRepository,
private val onlineMediaRepository: InMemoryMediaRepository,
private val networkMonitor: NetworkMonitor,
) : AppContentRepository {
) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var loadJob: Job? = null
private val ensureReadyMutex = Mutex()
private var refreshJob: Job? = null
private var cacheHydrated = false
private var initialized = false
private val contentRepositoryReady : MutableStateFlow<Boolean> = MutableStateFlow(false)
override val ready: StateFlow<Boolean> = combine(contentRepositoryReady, mediaRepository.ready) {
contentRepositoryReady, mediaRepositoryReady ->
contentRepositoryReady && mediaRepositoryReady
}.stateIn(scope, SharingStarted.Eagerly, false)
private val librariesState = MutableStateFlow<List<Library>>(emptyList())
override val libraries: StateFlow<List<Library>> = librariesState.asStateFlow()
private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList())
private val suggestionsState = MutableStateFlow<List<Media>>(emptyList())
override val suggestions: StateFlow<List<Media>> = suggestionsState.asStateFlow()
override val libraries: StateFlow<List<Library>> = _libraries.asStateFlow()
private val _suggestions: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
private val continueWatchingState = MutableStateFlow<List<Media>>(emptyList())
override val continueWatching: StateFlow<List<Media>> = continueWatchingState.asStateFlow()
override val suggestions: StateFlow<List<Media>> = _suggestions.asStateFlow()
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
private val nextUpState = MutableStateFlow<List<Media>>(emptyList())
override val nextUp: StateFlow<List<Media>> = nextUpState.asStateFlow()
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()
override val movies: StateFlow<Map<UUID, Movie>> = mediaRepository.movies
override val series: StateFlow<Map<UUID, Series>> = mediaRepository.series
override val episodes: StateFlow<Map<UUID, Episode>> = mediaRepository.episodes
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return mediaRepository.observeSeriesWithContent(seriesId)
}
override suspend fun updateWatchProgress(
mediaId: UUID,
positionMs: Long,
durationMs: Long
) {
mediaRepository.updateWatchProgress(mediaId, positionMs, durationMs)
}
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
init {
scope.launch {
loadFromCache()
ensureReady()
}
}
override suspend fun ensureReady() {
ensureReadyMutex.withLock {
hydrateCacheIfNeeded()
if (initialized) {
return
}
initialized = true
}
refreshHomeData()
}
override suspend fun refreshHomeData() {
ensureReadyMutex.withLock {
hydrateCacheIfNeeded()
}
if (refreshJob?.isActive == true) {
refreshJob?.join()
return
}
val job = scope.launch {
runCatching {
if (!networkMonitor.isOnline.first()) {
return@runCatching
}
loadLibraries()
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
}
refreshJob = job
job.join()
}
private suspend fun hydrateCacheIfNeeded() {
if (cacheHydrated) return
loadFromCache()
cacheHydrated = true
}
private suspend fun loadFromCache() {
val cache = homeCacheDataStore.data.first()
if (cache.suggestions.isNotEmpty()) {
_suggestions.value = cache.suggestions.mapNotNull { it.toMedia() }
suggestionsState.value = cache.suggestions.mapNotNull { it.toMedia() }
}
if (cache.continueWatching.isNotEmpty()) {
_continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() }
continueWatchingState.value = cache.continueWatching.mapNotNull { it.toMedia() }
}
if (cache.nextUp.isNotEmpty()) {
_nextUp.value = cache.nextUp.mapNotNull { it.toMedia() }
nextUpState.value = cache.nextUp.mapNotNull { it.toMedia() }
}
if (cache.latestLibraryContent.isNotEmpty()) {
_latestLibraryContent.value = cache.latestLibraryContent.mapNotNull { (key, items) ->
latestLibraryContentState.value = cache.latestLibraryContent.mapNotNull { (key, items) ->
val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@mapNotNull null
uuid to items.mapNotNull { it.toMedia() }
}.toMap()
@@ -114,12 +137,12 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun persistHomeCache() {
val cache = HomeCache(
suggestions = _suggestions.value.map { it.toCachedItem() },
continueWatching = _continueWatching.value.map { it.toCachedItem() },
nextUp = _nextUp.value.map { it.toCachedItem() },
latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) ->
suggestions = suggestionsState.value.map { it.toCachedItem() },
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
nextUp = nextUpState.value.map { it.toCachedItem() },
latestLibraryContent = latestLibraryContentState.value.map { (uuid, items) ->
uuid.toString() to items.map { it.toCachedItem() }
}.toMap()
}.toMap(),
)
homeCacheDataStore.updateData { cache }
}
@@ -143,52 +166,26 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
override suspend fun ensureReady() {
// check for combined ready state
if (ready.value) {
return
}
if (loadJob?.isActive == true) {
return
}
if (!contentRepositoryReady.value) {
return
}
contentRepositoryReady.value = true
loadJob?.cancel()
loadJob = scope.launch {
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
loadLibraries()
mediaRepository.setReady()
persistHomeCache()
}
}
suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load libraries", error)
return
}
//TODO add support for playlists
val filteredLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) }
_libraries.value = emptyLibraries
val filledLibraries = emptyLibraries.map { library ->
return@map loadLibrary(library)
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
_libraries.value = filledLibraries
val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) }
librariesState.value = emptyLibraries
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
mediaRepository.upsertMovies(movies)
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
librariesState.value = filledLibraries
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
mediaRepository.upsertSeries(series)
val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
onlineMediaRepository.upsertMovies(movies)
val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
onlineMediaRepository.upsertSeries(series)
}
suspend fun loadLibrary(library: Library): Library {
@@ -197,71 +194,33 @@ class InMemoryAppContentRepository @Inject constructor(
Log.w(TAG, "Unable to load library ${library.id}", error)
return library
}
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}")
return when (library.type) {
LibraryKind.MOVIES -> library.copy(
movies = contentItem.map { it.toMovie(serverUrl(), library.id) },
)
LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl(), library.id) },
)
}
}
suspend fun loadMovie(movieId: UUID): Movie {
val cachedMovie = mediaRepository.movies.value[movieId]
val movieItem = runCatching { jellyfinApiClient.getItemInfo(movieId) }
.getOrElse { error ->
Log.w(TAG, "Unable to load movie $movieId", error)
null
}
?: return cachedMovie ?: throw RuntimeException("Movie not found")
val updatedMovie = movieItem.toMovie(serverUrl(), movieItem.parentId!!)
mediaRepository.upsertMovies(listOf(updatedMovie))
return updatedMovie
}
suspend fun loadSeries(seriesId: UUID): Series {
val cachedSeries = mediaRepository.series.value[seriesId]
val seriesItem = runCatching { jellyfinApiClient.getItemInfo(seriesId) }
.getOrElse { error ->
Log.w(TAG, "Unable to load series $seriesId", error)
null
}
?: return cachedSeries ?: throw RuntimeException("Series not found")
val updatedSeries = seriesItem.toSeries(serverUrl(), seriesItem.parentId!!)
mediaRepository.upsertSeries(listOf(updatedSeries))
return updatedSeries
}
suspend fun loadSuggestions() {
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error ->
Log.w(TAG, "Unable to load suggestions", error)
return
}
val items = suggestionsItems.mapNotNull { item ->
suggestionsState.value = suggestionsItems.mapNotNull { item ->
when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
BaseItemKind.EPISODE -> Media.EpisodeMedia(
episodeId = item.id,
seriesId = item.seriesId!!
)
BaseItemKind.EPISODE -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
}
}
_suggestions.value = items
//Load episodes, Movies are already loaded at this point
suggestionsItems.forEach { item ->
when (item.type) {
BaseItemKind.EPISODE -> {
val episode = item.toEpisode(serverUrl())
mediaRepository.upsertEpisodes(listOf(episode))
}
else -> { /* Do nothing */ }
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
}
@@ -272,26 +231,17 @@ class InMemoryAppContentRepository @Inject constructor(
Log.w(TAG, "Unable to load continue watching", error)
return
}
val items = continueWatchingItems.mapNotNull { item ->
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
BaseItemKind.EPISODE -> Media.EpisodeMedia(
episodeId = item.id,
seriesId = item.seriesId!!
)
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())
mediaRepository.upsertEpisodes(listOf(episode))
}
else -> { /* Do nothing */ }
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
}
@@ -302,90 +252,56 @@ class InMemoryAppContentRepository @Inject constructor(
Log.w(TAG, "Unable to load next up", error)
return
}
val items = nextUpItems.map { item ->
Media.EpisodeMedia(
episodeId = item.id,
seriesId = item.seriesId!!
)
nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
}
_nextUp.value = items
// Load episodes
nextUpItems.forEach { item ->
val episode = item.toEpisode(serverUrl())
mediaRepository.upsertEpisodes(listOf(episode))
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
suspend fun loadLatestLibraryContent() {
// TODO Make libraries accessible in a field or something that is not this ugly.
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load latest library content", error)
return
}
val filterLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val latestLibraryContents = filterLibraries.associate { library ->
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error ->
Log.w(TAG, "Unable to load latest items for library ${library.id}", error)
emptyList()
}
library.id to when (library.collectionType) {
CollectionType.MOVIES -> {
latestFromLibrary.map {
val movie = it.toMovie(serverUrl(), library.id)
Media.MovieMedia(movieId = movie.id)
}
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}")
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()
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.
}
override suspend fun refreshHomeData() {
if(loadJob?.isActive == true) {
loadJob?.join()
return
}
val job = scope.launch {
runCatching {
val isOnline = networkMonitor.isOnline.first()
if (!isOnline) return@runCatching
loadLibraries()
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
}
loadJob = job
job.join()
latestLibraryContentState.value = latestLibraryContents
}
private suspend fun serverUrl(): String {
@@ -393,94 +309,75 @@ class InMemoryAppContentRepository @Inject constructor(
}
private fun BaseItemDto.toLibrary(serverUrl: String): Library {
return when (this.collectionType) {
return when (collectionType) {
CollectionType.MOVIES -> Library(
id = this.id,
name = this.name!!,
posterUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = this.id,
type = ImageType.PRIMARY
),
type = CollectionType.MOVIES,
movies = emptyList()
id = id,
name = name!!,
posterUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = id, type = ImageType.PRIMARY),
type = LibraryKind.MOVIES,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
id = this.id,
name = this.name!!,
posterUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = this.id,
type = ImageType.PRIMARY
),
type = CollectionType.TVSHOWS,
series = emptyList()
id = id,
name = name!!,
posterUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = id, type = ImageType.PRIMARY),
type = LibraryKind.SERIES,
series = emptyList(),
)
else -> throw UnsupportedOperationException("Unsupported library type: ${this.collectionType}")
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
}
}
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID): Movie {
return Movie(
id = this.id,
id = 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",
title = name ?: "Unknown title",
progress = userData!!.playedPercentage,
watched = userData!!.played,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
synopsis = overview ?: "No synopsis available",
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = this.id,
),
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = id),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList()
cast = emptyList(),
)
}
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
return Series(
id = this.id,
id = id,
libraryId = libraryId,
name = this.name ?: "Unknown",
synopsis = this.overview ?: "No synopsis available",
year = this.productionYear?.toString()
?: this.premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = this.id
),
unwatchedEpisodeCount = this.userData!!.unplayedItemCount!!,
seasonCount = this.childCount!!,
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = id),
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
seasonCount = childCount!!,
seasons = emptyList(),
cast = emptyList()
cast = emptyList(),
)
}
private fun BaseItemDto.toSeason(serverUrl: String): Season {
private fun BaseItemDto.toSeason(): 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()
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = itemId
)
JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
@@ -496,7 +393,7 @@ class InMemoryAppContentRepository @Inject constructor(
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList()
cast = emptyList(),
)
}
@@ -513,11 +410,7 @@ class InMemoryAppContentRepository @Inject constructor(
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"
}
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
companion object {

View File

@@ -7,6 +7,13 @@ import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -19,74 +26,41 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
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(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
) : MediaRepository {
) : MediaCatalogReader, MediaProgressWriter {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val ready: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = moviesState.asStateFlow()
internal val _movies: MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()
private val seriesState = MutableStateFlow<Map<UUID, Series>>(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = seriesState.asStateFlow()
internal val _series: MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series.asStateFlow()
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
internal val _episodes: MutableStateFlow<Map<UUID, Episode>> = MutableStateFlow(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = _episodes.asStateFlow()
override fun upsertMovies(movies: List<Movie>) {
_movies.update { current -> current + movies.associateBy { it.id } }
fun upsertMovies(movies: List<Movie>) {
moviesState.update { current -> current + movies.associateBy { it.id } }
}
override fun upsertSeries(series: List<Series>) {
_series.update { current -> current + series.associateBy { it.id } }
fun upsertSeries(series: List<Series>) {
seriesState.update { current -> current + series.associateBy { it.id } }
}
override fun upsertEpisodes(episodes: List<Episode>) {
_episodes.update { current -> current + episodes.associateBy { it.id } }
fun upsertEpisodes(episodes: List<Episode>) {
episodesState.update { current -> current + episodes.associateBy { it.id } }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
ensureSeriesContentLoaded(seriesId)
}
return _series.map { it[seriesId] }
}
override fun setReady() {
ready.value = true
}
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
_series.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = _series.value[seriesId] ?: throw RuntimeException("Series not found")
val serverUrl = serverUrl()
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)
_series.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
_episodes.update { current -> current + allEpisodes.associateBy { it.id } }
return seriesState.map { it[seriesId] }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
@@ -94,32 +68,49 @@ class InMemoryMediaRepository @Inject constructor(
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
if (_movies.value.containsKey(mediaId)) {
_movies.update { current ->
if (moviesState.value.containsKey(mediaId)) {
moviesState.update { current ->
val movie = current[mediaId] ?: return@update current
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
}
return
}
if (_episodes.value.containsKey(mediaId)) {
_episodes.update { current ->
if (episodesState.value.containsKey(mediaId)) {
episodesState.update { current ->
val episode = current[mediaId] ?: return@update current
current + (mediaId to episode.copy(progress = progressPercent, watched = watched))
}
}
}
private suspend fun serverUrl(): String = userSessionRepository.serverUrl.first()
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
private fun BaseItemDto.toSeason(serverUrl: String): Season {
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
val serverUrl = userSessionRepository.serverUrl.first()
val emptySeasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
val filledSeasons = emptySeasons.map { season ->
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id).map { it.toEpisode(serverUrl) }
season.copy(episodes = episodes)
}
val updatedSeries = series.copy(seasons = filledSeasons)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
episodesState.update { current -> current + allEpisodes.associateBy { it.id } }
}
private fun BaseItemDto.toSeason(): 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()
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
@@ -142,7 +133,7 @@ class InMemoryMediaRepository @Inject constructor(
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList()
cast = emptyList(),
)
}

View File

@@ -0,0 +1,29 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinPlaybackProgressReporter @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
) : PlaybackProgressReporter {
override suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) {
jellyfinApiClient.reportPlaybackStart(itemId, positionTicks, reportContext)
}
override suspend fun reportPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
reportContext: PlaybackReportContext,
) {
jellyfinApiClient.reportPlaybackProgress(itemId, positionTicks, isPaused, reportContext)
}
override suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) {
jellyfinApiClient.reportPlaybackStopped(itemId, positionTicks, reportContext)
}
}

View File

@@ -3,23 +3,13 @@ package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface MediaRepository {
val ready: StateFlow<Boolean>
interface MediaCatalogReader {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
fun upsertMovies(movies: List<Movie>) {
}
fun upsertSeries(series: List<Series>) {
}
fun upsertEpisodes(episodes: List<Episode>) {
}
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
fun setReady() {
}
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
}
}

Some files were not shown because too many files have changed in this diff Show More