feat: introduce core data models and interfaces for playback and downloads

This commit is contained in:
2026-04-15 20:02:21 +02:00
parent b67c8076f1
commit ad930e899f
180 changed files with 1363 additions and 960 deletions

View File

@@ -52,9 +52,19 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(project(":core:image"))
implementation(project(":core:data"))
implementation(project(":core:navigation"))
implementation(project(":core:download"))
implementation(project(":core:player"))
implementation(project(":feature:shared"))
implementation(project(":data:jellyfin"))
implementation(project(":data:offline"))
implementation(project(":data:catalog"))
implementation(project(":feature:browse"))
implementation(project(":feature:search"))
implementation(project(":feature:content"))
implementation(project(":feature:downloads"))
implementation(project(":feature:login"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
@@ -83,8 +93,6 @@ dependencies {
implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -18,10 +18,10 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
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.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind

View File

@@ -4,7 +4,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals

View File

@@ -20,13 +20,13 @@ import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
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.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.tv.navigation.LocalNavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.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
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind
@Composable
fun TvEpisodeScreen(
@@ -87,7 +87,7 @@ internal fun TvEpisodeScreenContent(
) {
item {
TvMediaDetailBodyBox(
backgroundImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
modifier = it
) {
TvEpisodeHeroSection(

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.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.feature.shared.navigation.MovieDto
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
@Composable
fun TvMovieScreen(

View File

@@ -59,13 +59,13 @@ 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.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.image.ArtworkKind
internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
@@ -296,7 +296,7 @@ private fun TvEpisodeCard(
)
) {
PurefinAsyncImage(
model = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
model = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@@ -22,10 +22,10 @@ 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.feature.shared.navigation.SeriesDto
import hu.bbara.purefin.core.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 hu.bbara.purefin.feature.content.series.SeriesViewModel
import java.util.UUID
@Composable

View File

@@ -32,8 +32,8 @@ import androidx.compose.ui.unit.sp
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.FocusableItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind

View File

@@ -32,8 +32,8 @@ 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.MediaSynopsis
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.image.ArtworkKind
internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp
@@ -151,8 +151,8 @@ internal fun TvMediaDetailBodyBox(
}
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
val primaryImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
return backdropImageUrl.ifBlank { primaryImageUrl }
}

View File

@@ -41,7 +41,7 @@ import hu.bbara.purefin.common.ui.PurefinComplexTextField
import hu.bbara.purefin.common.ui.PurefinPasswordField
import hu.bbara.purefin.common.ui.PurefinTextButton
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.feature.shared.login.LoginViewModel
import hu.bbara.purefin.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable

View File

@@ -37,13 +37,13 @@ import coil3.request.crossfade
import coil3.util.DebugLogger
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.SessionBootstrapper
import hu.bbara.purefin.data.jellyfin.client.JellyfinAuthInterceptor
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.core.navigation.NavigationCommand
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.tv.navigation.LocalNavigationBackStack
import hu.bbara.purefin.tv.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.theme.AppTheme
@@ -66,7 +66,7 @@ class TvActivity : ComponentActivity() {
lateinit var navigationManager: NavigationManager
@Inject
lateinit var jellyfinApiClient: JellyfinApiClient
lateinit var sessionBootstrapper: SessionBootstrapper
@Inject
lateinit var authInterceptor: JellyfinAuthInterceptor
@@ -94,7 +94,7 @@ class TvActivity : ComponentActivity() {
}
private suspend fun init() {
jellyfinApiClient.updateApiClient()
sessionBootstrapper.initialize()
}
private fun configureImageLoader() {

View File

@@ -15,7 +15,7 @@ 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.feature.shared.home.AppViewModel
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.tv.home.ui.TvDrawerDestination
import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem

View File

@@ -1,56 +0,0 @@
package hu.bbara.purefin.tv.di
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import java.util.UUID
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object TvPlaybackModule {
@Provides
@Singleton
fun providePlaybackDataSourceFactory(
okHttpDataSourceFactory: OkHttpDataSource.Factory
): DataSource.Factory {
return okHttpDataSourceFactory
}
@Provides
@Singleton
fun provideMediaDownloadController(): MediaDownloadController {
return TvNoOpMediaDownloadController
}
}
internal object TvNoOpMediaDownloadController : MediaDownloadController {
private val notDownloaded = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flowOf(emptyMap())
override fun observeDownloadState(contentId: String) = notDownloaded
override suspend fun downloadMovie(movieId: UUID) = Unit
override suspend fun cancelDownload(movieId: UUID) = Unit
override suspend fun downloadEpisode(episodeId: UUID) = Unit
override suspend fun downloadEpisodes(episodeIds: List<UUID>) = Unit
override suspend fun cancelEpisodeDownload(episodeId: UUID) = Unit
override suspend fun enableSmartDownload(seriesId: UUID) = Unit
override suspend fun syncSmartDownloads() = Unit
}

View File

@@ -4,7 +4,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
import hu.bbara.purefin.core.data.PlaybackProfileFamily
@Module
@InstallIn(SingletonComponent::class)

View File

@@ -9,10 +9,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.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

View File

@@ -1,16 +1,16 @@
package hu.bbara.purefin.tv.home.ui
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
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 hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
import kotlin.math.roundToInt
internal data class TvFocusedHeroModel(
@@ -42,7 +42,7 @@ internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
}
internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String {
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
return backdropImageUrl.ifBlank { fallbackImageUrl }
}
@@ -51,7 +51,7 @@ private fun Movie.toTvFocusedHeroModel(): TvFocusedHeroModel {
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
),
title = title,
metadata = listOf(year, rating, runtime, format).compactMetadata(),
@@ -63,7 +63,7 @@ private fun Episode.toTvFocusedHeroModel(): TvFocusedHeroModel {
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
),
title = title,
metadata = listOf(
@@ -87,7 +87,7 @@ private fun Series.toTvFocusedHeroModel(): TvFocusedHeroModel {
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
),
title = name,
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),

View File

@@ -24,11 +24,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
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 hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"

View File

@@ -5,11 +5,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
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 hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
internal data class TvHomeItemRegistry(

View File

@@ -47,14 +47,14 @@ import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
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 hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
private val TvHomeSectionsHorizontalPadding = 32.dp
@@ -93,14 +93,14 @@ fun TvContinueWatchingSection(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = when (item.type) {
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
else -> null

View File

@@ -44,7 +44,7 @@ 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.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.core.model.LibraryKind
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"

View File

@@ -24,9 +24,9 @@ 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.feature.shared.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.browse.library.LibraryViewModel
import java.util.UUID
@Composable

View File

@@ -3,8 +3,8 @@ 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
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }

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

View File

@@ -4,7 +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.feature.shared.navigation.Route
import hu.bbara.purefin.core.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

@@ -1,19 +0,0 @@
package hu.bbara.purefin.tv.di
import hu.bbara.purefin.core.data.download.DownloadState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
class TvNoOpMediaDownloadControllerTest {
@Test
fun `tv no-op download controller always reports no downloads`() = runBlocking {
assertEquals(emptyMap<String, Float>(), TvNoOpMediaDownloadController.observeActiveDownloads().first())
assertEquals(
DownloadState.NotDownloaded,
TvNoOpMediaDownloadController.observeDownloadState("movie-1").value
)
}
}

View File

@@ -1,14 +1,14 @@
package hu.bbara.purefin.tv.home.ui
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -33,7 +33,7 @@ class TvFocusedItemHeroTest {
assertEquals("42%", hero.progressLabel)
assertNull(hero.watchedText)
assertEquals(
JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.BACKDROP),
ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.BACKDROP),
hero.backdropImageUrl
)
}

View File

@@ -2,10 +2,10 @@ package hu.bbara.purefin.tv.home.ui
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
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.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals

View File

@@ -51,10 +51,19 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(project(":core:image"))
implementation(project(":core:data"))
implementation(project(":feature:download"))
implementation(project(":core:navigation"))
implementation(project(":core:download"))
implementation(project(":core:player"))
implementation(project(":feature:shared"))
implementation(project(":data:jellyfin"))
implementation(project(":data:offline"))
implementation(project(":data:catalog"))
implementation(project(":feature:browse"))
implementation(project(":feature:search"))
implementation(project(":feature:content"))
implementation(project(":feature:downloads"))
implementation(project(":feature:login"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
@@ -83,8 +92,6 @@ dependencies {
implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -33,7 +33,7 @@
android:exported="false"
android:theme="@style/Theme.Purefin" />
<service
android:name="hu.bbara.purefin.feature.download.PurefinDownloadService"
android:name="hu.bbara.purefin.core.download.PurefinDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>

View File

@@ -36,15 +36,15 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint
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.SessionBootstrapper
import hu.bbara.purefin.data.jellyfin.client.JellyfinAuthInterceptor
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.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.core.navigation.NavigationCommand
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark
@@ -65,7 +65,7 @@ class PurefinActivity : ComponentActivity() {
lateinit var navigationManager: NavigationManager
@Inject
lateinit var jellyfinApiClient: JellyfinApiClient
lateinit var sessionBootstrapper: SessionBootstrapper
@Inject
lateinit var authInterceptor: JellyfinAuthInterceptor
@@ -93,7 +93,7 @@ class PurefinActivity : ComponentActivity() {
}
private suspend fun init() {
jellyfinApiClient.updateApiClient()
sessionBootstrapper.initialize()
}
private fun configureImageLoader() {

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.feature.shared.navigation.Route
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.appRouteEntryBuilder
@Module

View File

@@ -4,7 +4,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
import hu.bbara.purefin.core.data.PlaybackProfileFamily
@Module
@InstallIn(SingletonComponent::class)

View File

@@ -3,8 +3,8 @@ 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
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.navigation
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.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

View File

@@ -27,7 +27,7 @@ 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.feature.shared.search.SearchViewModel
import hu.bbara.purefin.feature.search.SearchViewModel
@Composable
fun DefaultTopBar(

View File

@@ -23,7 +23,7 @@ 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.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeIndicator
import hu.bbara.purefin.ui.common.badge.WatchStateIndicator
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage

View File

@@ -10,7 +10,7 @@ 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.feature.shared.home.AppViewModel
import hu.bbara.purefin.feature.browse.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

View File

@@ -27,7 +27,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.download.ActiveDownloadItem
import hu.bbara.purefin.feature.downloads.ActiveDownloadItem
@Composable
internal fun DownloadingItemRow(

View File

@@ -24,7 +24,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.feature.shared.download.DownloadsViewModel
import hu.bbara.purefin.feature.downloads.DownloadsViewModel
@Composable
fun DownloadsContent(

View File

@@ -37,19 +37,19 @@ import androidx.hilt.navigation.compose.hiltViewModel
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.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.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
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
import java.util.UUID
@Composable
@@ -167,7 +167,7 @@ private fun EpisodeHeroSection(
.height(sectionHeight)
) {
MediaHero(
imageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
backgroundColor = scheme.background,
modifier = Modifier.fillMaxSize()
)

View File

@@ -40,7 +40,7 @@ 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.download.DownloadState
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.player.PlayerActivity

View File

@@ -16,11 +16,11 @@ import androidx.compose.ui.Modifier
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 hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.browse.home.SuggestedItem
import hu.bbara.purefin.ui.screen.AppBottomBar
import java.util.UUID

View File

@@ -22,11 +22,11 @@ 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.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 hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.browse.home.SuggestedItem
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

View File

@@ -25,11 +25,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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 hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
@Composable
internal fun ContinueWatchingCard(
@@ -53,13 +53,13 @@ internal fun ContinueWatchingCard(
else -> ""
}
val imageUrl = when (item.type) {
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
else -> null
}

View File

@@ -10,7 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import java.util.UUID
@Composable

View File

@@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.SuggestedItem
import hu.bbara.purefin.feature.browse.home.SuggestedItem
@Composable
internal fun SuggestionCard(

View File

@@ -22,7 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.SuggestedItem
import hu.bbara.purefin.feature.browse.home.SuggestedItem
@Composable
fun SuggestionsSection(

View File

@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
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 hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind

View File

@@ -9,8 +9,8 @@ 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.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.common.header.SectionHeader
import java.util.UUID

View File

@@ -23,10 +23,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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 hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.feature.browse.home.NextUpItem
import java.util.UUID
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
@Composable
internal fun NextUpCard(
@@ -57,7 +57,7 @@ internal fun NextUpCard(
.background(scheme.surface)
) {
PurefinAsyncImage(
model = JellyfinImageHelper.finishImageUrl(item.episode.imageUrlPrefix, ImageType.PRIMARY),
model = ImageUrlBuilder.finishImageUrl(item.episode.imageUrlPrefix, ArtworkKind.PRIMARY),
contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@@ -10,7 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import java.util.UUID
@Composable

View File

@@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier
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 hu.bbara.purefin.feature.search.SearchViewModel
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind

View File

@@ -18,7 +18,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.shared.search.SearchResult
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable

View File

@@ -23,9 +23,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.browse.library.LibraryViewModel
@Composable
fun LibraryScreen(

View File

@@ -41,7 +41,7 @@ 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 hu.bbara.purefin.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable

View File

@@ -35,16 +35,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
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.feature.shared.navigation.MovieDto
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.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
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
import java.util.UUID
@Composable
@@ -139,7 +139,7 @@ fun MediaHeroSection(
.height(sectionHeight)
) {
MediaHero(
imageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
backgroundColor = MaterialTheme.colorScheme.background,
modifier = Modifier.fillMaxSize()
)

View File

@@ -35,7 +35,7 @@ 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.download.DownloadState
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.player.PlayerActivity

View File

@@ -33,14 +33,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
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.feature.shared.navigation.SeriesDto
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import hu.bbara.purefin.feature.content.series.SeriesViewModel
import hu.bbara.purefin.ui.screen.series.components.CastRow
import hu.bbara.purefin.ui.screen.series.components.EpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
@@ -49,7 +49,7 @@ import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadOption
import hu.bbara.purefin.ui.screen.series.components.SeriesMetaChips
import hu.bbara.purefin.ui.screen.series.components.SeriesTopBar
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
import java.util.UUID
@Composable
@@ -207,7 +207,7 @@ private fun SeriesHeroSection(
.height(sectionHeight)
) {
MediaHero(
imageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
imageUrl = ImageUrlBuilder.finishImageUrl(series.imageUrlPrefix, ArtworkKind.PRIMARY),
backgroundColor = scheme.background,
modifier = Modifier.fillMaxSize()
)

View File

@@ -65,18 +65,18 @@ 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.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import hu.bbara.purefin.feature.content.series.SeriesViewModel
import hu.bbara.purefin.player.PlayerActivity
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
@Composable
internal fun SeriesTopBar(
@@ -349,7 +349,7 @@ private fun EpisodeCard(
.border(1.dp, scheme.outlineVariant, RoundedCornerShape(12.dp))
) {
PurefinAsyncImage(
model = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
model = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@@ -3,9 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
@@ -30,15 +27,7 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(libs.jellyfin.core)
implementation(libs.media3.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.datastore)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,5 @@
package hu.bbara.purefin.core.data
interface AuthenticationRepository {
suspend fun login(url: String, username: String, password: String): Boolean
}

View File

@@ -0,0 +1,32 @@
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.Season
import hu.bbara.purefin.core.model.Series
import java.util.UUID
interface DownloadMediaSourceResolver {
suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource?
suspend fun resolveEpisodeDownload(episodeId: UUID): EpisodeDownloadSource?
suspend fun isEpisodeWatched(episodeId: UUID): Boolean
suspend fun getUnwatchedEpisodeIds(
seriesId: UUID,
excludedEpisodeIds: Set<UUID>,
limit: Int,
): List<UUID>
}
data class MovieDownloadSource(
val movie: Movie,
val playbackUrl: String,
val customCacheKey: String?,
)
data class EpisodeDownloadSource(
val episode: Episode,
val series: Series,
val season: Season,
val playbackUrl: String,
val customCacheKey: String?,
)

View File

@@ -1,16 +0,0 @@
package hu.bbara.purefin.core.data
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class NetworkModule {
@Binds
@Singleton
abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor
}

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.data
enum class PlaybackMethod {
DIRECT_PLAY,
DIRECT_STREAM,
TRANSCODE,
}

View File

@@ -0,0 +1,6 @@
package hu.bbara.purefin.core.data
enum class PlaybackProfileFamily {
MOBILE,
TV,
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import java.util.UUID
interface PlaybackProgressReporter {

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.core.data
data class PlaybackReportContext(
val playMethod: PlaybackMethod,
val mediaSourceId: String?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val liveStreamId: String?,
val playSessionId: String?,
)

View File

@@ -0,0 +1,5 @@
package hu.bbara.purefin.core.data
interface SessionBootstrapper {
suspend fun initialize()
}

View File

@@ -1,19 +0,0 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
data class PlaybackDecision(
val url: String,
val mediaSource: MediaSourceInfo,
val reportContext: PlaybackReportContext
)
data class PlaybackReportContext(
val playMethod: PlayMethod,
val mediaSourceId: String?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val liveStreamId: String?,
val playSessionId: String?,
)

View File

@@ -1,12 +0,0 @@
package hu.bbara.purefin.core.data.domain.usecase
import hu.bbara.purefin.core.data.HomeRepository
import javax.inject.Inject
class RefreshHomeDataUseCase @Inject constructor(
private val repository: HomeRepository
) {
suspend operator fun invoke() {
repository.refreshHomeData()
}
}

View File

@@ -1,45 +0,0 @@
package hu.bbara.purefin.core.data.image
import java.util.UUID
import org.jellyfin.sdk.model.api.ImageType
class JellyfinImageHelper {
companion object {
fun toImageUrl(url: String, itemId: UUID, type: ImageType): String {
if (url.isEmpty()) {
return ""
}
return StringBuilder()
.append(url)
.append("/Items/")
.append(itemId)
.append("/Images/")
.append(type.serialName)
.toString()
}
fun toPrefixImageUrl(url: String, itemId: UUID): String {
if (url.isEmpty()) {
return ""
}
return StringBuilder()
.append(url)
.append("/Items/")
.append(itemId)
.append("/Images/")
.toString()
}
fun finishImageUrl(prefixImageUrl: String?, imageType: ImageType): String {
if (prefixImageUrl.isNullOrEmpty()) {
return ""
}
return StringBuilder()
.append(prefixImageUrl)
.append(imageType.serialName)
.toString()
}
}
}

View File

@@ -1,36 +0,0 @@
package hu.bbara.purefin.core.data.room.offline
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import hu.bbara.purefin.core.data.room.UuidConverters
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
import hu.bbara.purefin.core.data.room.entity.MovieEntity
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
@Database(
entities = [
MovieEntity::class,
SeriesEntity::class,
SeasonEntity::class,
EpisodeEntity::class,
SmartDownloadEntity::class,
],
version = 9,
exportSchema = false
)
@TypeConverters(UuidConverters::class)
abstract class OfflineMediaDatabase : RoomDatabase() {
abstract fun movieDao(): MovieDao
abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao
abstract fun smartDownloadDao(): SmartDownloadDao
}

View File

@@ -1,54 +1,21 @@
package hu.bbara.purefin.core.data.session
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
class UserSessionRepository @Inject constructor(
private val userSessionDataStore: DataStore<UserSession>
) {
val session: Flow<UserSession> = userSessionDataStore.data
interface UserSessionRepository {
val serverUrl: Flow<String>
suspend fun setServerUrl(serverUrl: String)
val serverUrl: Flow<String> = session
.map { it.url }
val accessToken: Flow<String>
suspend fun setAccessToken(accessToken: String)
suspend fun setServerUrl(serverUrl: String) {
userSessionDataStore.updateData {
it.copy(url = serverUrl)
}
}
val userId: Flow<UUID?>
suspend fun setUserId(userId: UUID?)
suspend fun getUserId(): UUID?
val accessToken: Flow<String> = session
.map { it.accessToken }
val isLoggedIn: Flow<Boolean>
suspend fun setLoggedIn(isLoggedIn: Boolean)
suspend fun setAccessToken(accessToken: String) {
userSessionDataStore.updateData {
it.copy(accessToken = accessToken)
}
}
val userId: Flow<UUID?> = session
.map { it.userId }
suspend fun setUserId(userId: UUID?) {
userSessionDataStore.updateData {
it.copy(userId = userId)
}
}
suspend fun getUserId(): UUID? = userId.first()
val isLoggedIn: Flow<Boolean> = session.map { it.loggedIn }.distinctUntilChanged()
suspend fun setLoggedIn(isLoggedIn: Boolean) {
userSessionDataStore.updateData {
it.copy(loggedIn = isLoggedIn)
}
}
val isOfflineMode: Flow<Boolean> = session.map { it.isOfflineMode }.distinctUntilChanged()
val isOfflineMode: Flow<Boolean>
}

View File

@@ -8,7 +8,7 @@ plugins {
}
android {
namespace = "hu.bbara.purefin.feature.download"
namespace = "hu.bbara.purefin.core.download"
compileSdk = 36
defaultConfig {
@@ -30,11 +30,11 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.medi3.exoplayer)
implementation(libs.media3.datasource.okhttp)
implementation(libs.okhttp)
implementation(libs.jellyfin.core)
testImplementation(libs.junit)
}

View File

@@ -1,10 +1,10 @@
package hu.bbara.purefin.feature.download
package hu.bbara.purefin.core.download
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.download.MediaDownloadController
import javax.inject.Singleton
@Module

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.download
package hu.bbara.purefin.core.download
import android.content.Context
import androidx.annotation.OptIn

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.download
package hu.bbara.purefin.core.download
sealed class DownloadState {
data object NotDownloaded : DownloadState()

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.core.data.download
package hu.bbara.purefin.core.download
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.download
package hu.bbara.purefin.core.download
import android.content.Context
import android.util.Log
@@ -9,18 +9,9 @@ import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
import hu.bbara.purefin.core.data.OfflineCatalogStore
import hu.bbara.purefin.core.data.SmartDownloadStore
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
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.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -34,10 +25,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@@ -47,10 +34,9 @@ import javax.inject.Singleton
class MediaDownloadManager @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadManager: DownloadManager,
private val jellyfinApiClient: JellyfinApiClient,
private val downloadMediaSourceResolver: DownloadMediaSourceResolver,
private val offlineCatalogStore: OfflineCatalogStore,
private val smartDownloadStore: SmartDownloadStore,
private val userSessionRepository: UserSessionRepository,
) : MediaDownloadController {
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
@@ -127,53 +113,19 @@ class MediaDownloadManager @Inject constructor(
override suspend fun downloadMovie(movieId: UUID) {
withContext(Dispatchers.IO) {
try {
val sources = jellyfinApiClient.getMediaSources(movieId)
val source = sources.firstOrNull() ?: run {
Log.e(TAG, "No media sources for $movieId")
return@withContext
}
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
Log.e(TAG, "No playback URL for $movieId")
val source = downloadMediaSourceResolver.resolveMovieDownload(movieId) ?: run {
Log.e(TAG, "No downloadable movie source for $movieId")
return@withContext
}
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: run {
Log.e(TAG, "No item info for $movieId")
return@withContext
}
offlineCatalogStore.saveMovies(listOf(source.movie))
val serverUrl = userSessionRepository.serverUrl.first().trim()
val movie = Movie(
id = itemInfo.id,
libraryId = itemInfo.parentId ?: UUID.randomUUID(),
title = itemInfo.name ?: "Unknown title",
progress = itemInfo.userData?.playedPercentage,
watched = itemInfo.userData?.played ?: false,
year = itemInfo.productionYear?.toString()
?: itemInfo.premiereDate?.year?.toString().orEmpty(),
rating = itemInfo.officialRating ?: "NR",
runtime = formatRuntime(itemInfo.runTimeTicks),
synopsis = itemInfo.overview ?: "No synopsis available",
format = itemInfo.container?.uppercase() ?: "VIDEO",
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = itemInfo.id
),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList()
)
offlineCatalogStore.saveMovies(listOf(movie))
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
Log.d(TAG, "Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
val request = buildDownloadRequest(
mediaId = movieId,
playbackUrl = url,
title = movie.title,
isDirectPlay = !shouldTranscode
playbackUrl = source.playbackUrl,
title = source.movie.title,
customCacheKey = source.customCacheKey,
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for $movieId")
@@ -198,50 +150,27 @@ class MediaDownloadManager @Inject constructor(
override suspend fun downloadEpisode(episodeId: UUID) {
withContext(Dispatchers.IO) {
try {
val serverUrl = userSessionRepository.serverUrl.first().trim()
val sources = jellyfinApiClient.getMediaSources(episodeId)
val source = sources.firstOrNull() ?: run {
Log.e(TAG, "No media sources for episode $episodeId")
return@withContext
}
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(episodeId, source) ?: run {
Log.e(TAG, "No playback URL for episode $episodeId")
val source = downloadMediaSourceResolver.resolveEpisodeDownload(episodeId) ?: run {
Log.e(TAG, "No downloadable episode source for $episodeId")
return@withContext
}
val episode = jellyfinApiClient.getItemInfo(episodeId)?.toEpisode(serverUrl) ?: run {
Log.e(TAG, "Episode not found $episodeId")
return@withContext
if (offlineCatalogStore.getSeriesBasic(source.series.id) == null) {
offlineCatalogStore.saveSeries(listOf(source.series))
}
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: run {
Log.e(TAG, "Series not found ${episode.seriesId}")
return@withContext
if (offlineCatalogStore.getSeason(source.season.id) == null) {
offlineCatalogStore.saveSeason(source.season)
}
val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason(series.id) ?: run {
Log.e(TAG, "Season not found ${episode.seasonId}")
return@withContext
}
offlineCatalogStore.saveEpisode(source.episode)
if (offlineCatalogStore.getSeriesBasic(series.id) == null) {
offlineCatalogStore.saveSeries(listOf(series))
}
if (offlineCatalogStore.getSeason(season.id) == null) {
offlineCatalogStore.saveSeason(season)
}
offlineCatalogStore.saveEpisode(episode)
Log.d(TAG, "Starting download for episode '${episode.title}' from: $url")
Log.d(TAG, "Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
val request = buildDownloadRequest(
mediaId = episodeId,
playbackUrl = url,
title = episode.title,
isDirectPlay = !shouldTranscode
playbackUrl = source.playbackUrl,
title = source.episode.title,
customCacheKey = source.customCacheKey,
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for episode $episodeId")
@@ -299,17 +228,13 @@ class MediaDownloadManager @Inject constructor(
private suspend fun syncSmartDownloadsForSeries(seriesId: UUID) {
withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first().trim()
// 1. Get currently downloaded episodes for this series
val downloadedEpisodes = offlineCatalogStore.getEpisodesBySeries(seriesId)
// 2. Check watched status from server and delete watched downloads
val unwatchedDownloaded = mutableListOf<UUID>()
for (episode in downloadedEpisodes) {
val itemInfo = jellyfinApiClient.getItemInfo(episode.id)
val isWatched = itemInfo?.userData?.played ?: false
if (isWatched) {
if (downloadMediaSourceResolver.isEpisodeWatched(episode.id)) {
Log.d(TAG, "Smart download: removing watched episode ${episode.title}")
cancelEpisodeDownload(episode.id)
} else {
@@ -317,22 +242,15 @@ class MediaDownloadManager @Inject constructor(
}
}
// 3. Get all episodes of the series from the server in order
val seasons = jellyfinApiClient.getSeasons(seriesId)
val allEpisodes = mutableListOf<BaseItemDto>()
for (season in seasons) {
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
allEpisodes.addAll(episodes)
}
// 4. Find unwatched episodes not already downloaded
// 3. Find unwatched episodes not already downloaded
val needed = SMART_DOWNLOAD_COUNT - unwatchedDownloaded.size
if (needed <= 0) return@withContext
val toDownload = allEpisodes
.filter { ep -> ep.userData?.played != true && ep.id !in unwatchedDownloaded }
.take(needed)
.map { it.id }
val toDownload = downloadMediaSourceResolver.getUnwatchedEpisodeIds(
seriesId = seriesId,
excludedEpisodeIds = unwatchedDownloaded.toSet(),
limit = needed,
)
if (toDownload.isNotEmpty()) {
Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId")
@@ -349,18 +267,12 @@ class MediaDownloadManager @Inject constructor(
mediaId: UUID,
playbackUrl: String,
title: String,
isDirectPlay: Boolean
customCacheKey: String?,
): DownloadRequest {
val builder = DownloadRequest.Builder(mediaId.toString(), playbackUrl.toUri())
.setData(title.toByteArray(Charsets.UTF_8))
if (isDirectPlay) {
playbackCustomCacheKey(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
playMethod = PlayMethod.DIRECT_PLAY
)?.let(builder::setCustomCacheKey)
}
customCacheKey?.let(builder::setCustomCacheKey)
return builder.build()
}
@@ -375,73 +287,8 @@ class MediaDownloadManager @Inject constructor(
else -> DownloadState.NotDownloaded
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = java.util.concurrent.TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = java.util.concurrent.TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber ?: 0,
releaseDate = productionYear?.toString() ?: "",
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = id
),
cast = emptyList()
)
}
private fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId ?: java.util.UUID.randomUUID(),
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 ?: 0,
seasonCount = childCount ?: 0,
seasons = emptyList(),
cast = emptyList()
)
}
private fun BaseItemDto.toSeason(seriesId: UUID): Season {
return Season(
id = id,
seriesId = this.seriesId ?: seriesId,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
episodeCount = childCount ?: 0,
episodes = emptyList()
)
}
companion object {
private const val TAG = "MediaDownloadManager"
private const val SMART_DOWNLOAD_COUNT = 5
}
}
private fun MediaSourceInfo.shouldUseTranscodingUrl(): Boolean {
return supportsTranscoding == true &&
(supportsDirectPlay == false || transcodingUrl != null)
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.download
package hu.bbara.purefin.core.download
import android.app.Notification
import android.content.Context
@@ -50,7 +50,7 @@ class PurefinDownloadService : DownloadService(
if (activeDownloads.isEmpty()) {
return entryPoint.downloadNotificationHelper().buildProgressNotification(
this,
R.drawable.ic_launcher_foreground,
android.R.drawable.stat_sys_download,
null,
null,
downloads,
@@ -95,7 +95,7 @@ class PurefinDownloadService : DownloadService(
}
return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle(title)
.setContentText(contentText)
.setProgress(100, percent.toInt(), false)

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="download_channel_name">Downloads</string>
<string name="download_channel_description">Media download progress</string>
<string name="download_channel_description">Purefin offline downloads</string>
</resources>

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.download
package hu.bbara.purefin.core.download
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource

View File

@@ -0,0 +1,16 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
jvmToolchain(11)
}
dependencies {
implementation(project(":core:model"))
}

View File

@@ -0,0 +1,8 @@
package hu.bbara.purefin.core.image
enum class ArtworkKind(
internal val pathSegment: String,
) {
PRIMARY("Primary"),
BACKDROP("Backdrop"),
}

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.core.image
import java.util.UUID
object ImageUrlBuilder {
fun toImageUrl(url: String, itemId: UUID, artworkKind: ArtworkKind): String {
if (url.isEmpty()) {
return ""
}
return StringBuilder()
.append(url)
.append("/Items/")
.append(itemId)
.append("/Images/")
.append(artworkKind.pathSegment)
.toString()
}
fun toPrefixImageUrl(url: String, itemId: UUID): String {
if (url.isEmpty()) {
return ""
}
return StringBuilder()
.append(url)
.append("/Items/")
.append(itemId)
.append("/Images/")
.toString()
}
fun finishImageUrl(prefixImageUrl: String?, artworkKind: ArtworkKind): String {
if (prefixImageUrl.isNullOrEmpty()) {
return ""
}
return StringBuilder()
.append(prefixImageUrl)
.append(artworkKind.pathSegment)
.toString()
}
}

View File

@@ -0,0 +1,38 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "hu.bbara.purefin.core.navigation"
compileSdk = 36
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.core.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import hu.bbara.purefin.core.navigation.UuidSerializer
@Serializable
data class EpisodeDto(
@Serializable(with = UuidSerializer::class)
val id: UUID,
@Serializable(with = UuidSerializer::class)
val seasonId: UUID,
@Serializable(with = UuidSerializer::class)
val seriesId: UUID,
)

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.core.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import hu.bbara.purefin.core.navigation.UuidSerializer
@Serializable
data class LibraryDto(
@Serializable(with = UuidSerializer::class)
val id: UUID,
val name: String,
)

View File

@@ -0,0 +1,11 @@
package hu.bbara.purefin.core.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import hu.bbara.purefin.core.navigation.UuidSerializer
@Serializable
data class MovieDto(
@Serializable(with = UuidSerializer::class)
val id: UUID,
)

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.shared.navigation
package hu.bbara.purefin.core.navigation
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.shared.navigation
package hu.bbara.purefin.core.navigation
import dagger.Module
import dagger.Provides

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.shared.navigation
package hu.bbara.purefin.core.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

View File

@@ -0,0 +1,11 @@
package hu.bbara.purefin.core.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import hu.bbara.purefin.core.navigation.UuidSerializer
@Serializable
data class SeriesDto(
@Serializable(with = UuidSerializer::class)
val id: UUID,
)

View File

@@ -0,0 +1,20 @@
package hu.bbara.purefin.core.navigation
import java.util.UUID
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.shared.navigation
package hu.bbara.purefin.core.navigation
import java.util.UUID
import kotlinx.serialization.decodeFromString

View File

@@ -31,6 +31,7 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:image"))
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.androidx.lifecycle.viewmodel.ktx)

View File

@@ -9,7 +9,7 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType

View File

@@ -4,7 +4,7 @@ import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.PlaybackReportContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job

View File

@@ -0,0 +1,41 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "hu.bbara.purefin.data.catalog"
compileSdk = 36
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:image"))
implementation(project(":data:jellyfin"))
implementation(project(":data:offline"))
implementation(libs.jellyfin.core)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.datastore)
testImplementation(libs.junit)
}

View File

@@ -1,5 +1,8 @@
package hu.bbara.purefin.core.data
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series

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