Compare commits

..

2 Commits

28 changed files with 447 additions and 62 deletions

View File

@@ -56,6 +56,7 @@ import org.junit.Test
private const val TvPlayerRootTag = "tv_player_root" private const val TvPlayerRootTag = "tv_player_root"
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count" private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count" private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
class TvPlayerControlsOverlayTest { class TvPlayerControlsOverlayTest {
@@ -492,6 +493,66 @@ class TvPlayerControlsOverlayTest {
) )
} }
@Test
fun hiddenControls_leftSeeksWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionLeft)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"-10000"
)
)
}
@Test
fun hiddenControls_rightSeeksWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"10000"
)
)
}
@Test @Test
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() { fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
composeRule.setContent { composeRule.setContent {
@@ -656,6 +717,7 @@ private fun TrackPanelHost(
var stopFeedbackRequestId by remember { mutableStateOf(0) } var stopFeedbackRequestId by remember { mutableStateOf(0) }
var pauseWithoutControlsCount by remember { mutableStateOf(0) } var pauseWithoutControlsCount by remember { mutableStateOf(0) }
var resumeWithoutControlsCount by remember { mutableStateOf(0) } var resumeWithoutControlsCount by remember { mutableStateOf(0) }
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
val rootFocusRequester = remember { FocusRequester() } val rootFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() } val controlsFocusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() } val qualityButtonFocusRequester = remember { FocusRequester() }
@@ -677,6 +739,10 @@ private fun TrackPanelHost(
resumeWithoutControlsCount += 1 resumeWithoutControlsCount += 1
stopFeedbackVisible = false stopFeedbackVisible = false
} }
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
lastHiddenSeekDeltaMs = deltaMs
stopFeedbackVisible = false
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) { LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
@@ -721,7 +787,7 @@ private fun TrackPanelHost(
onHideControls = { controlsVisible = false }, onHideControls = { controlsVisible = false },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls, onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls, onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = { _ -> controlsVisible = true }, onSeekRelative = seekWithoutShowingControls,
onShowControls = { controlsVisible = true }, onShowControls = { controlsVisible = true },
onTogglePlayPause = { controlsVisible = true } onTogglePlayPause = { controlsVisible = true }
) )
@@ -781,6 +847,12 @@ private fun TrackPanelHost(
.semantics { stateDescription = resumeWithoutControlsCount.toString() } .semantics { stateDescription = resumeWithoutControlsCount.toString() }
) )
Box(
modifier = Modifier
.testTag(TvPlayerHiddenSeekDeltaTag)
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
)
trackPanelType?.let { panelType -> trackPanelType?.let { panelType ->
TvTrackSelectionPanel( TvTrackSelectionPanel(
panelType = panelType, panelType = panelType,

View File

@@ -0,0 +1,56 @@
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 org.jellyfin.sdk.model.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

@@ -150,6 +150,10 @@ fun TvPlayerScreen(
viewModel.seekBy(deltaMs) viewModel.seekBy(deltaMs)
showTvControls() showTvControls()
} }
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
viewModel.seekBy(deltaMs)
stopFeedbackVisible = false
}
val seekToLiveEdgeAndShowControls: () -> Unit = { val seekToLiveEdgeAndShowControls: () -> Unit = {
viewModel.seekToLiveEdge() viewModel.seekToLiveEdge()
showTvControls() showTvControls()
@@ -226,7 +230,7 @@ fun TvPlayerScreen(
onHideControls = { viewModel.toggleControlsVisibility() }, onHideControls = { viewModel.toggleControlsVisibility() },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls, onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls, onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = seekByAndShowControls, onSeekRelative = seekByWithoutShowingControls,
onShowControls = showTvControls, onShowControls = showTvControls,
onTogglePlayPause = togglePlayPauseAndShowControls onTogglePlayPause = togglePlayPauseAndShowControls
) )

View File

@@ -0,0 +1,19 @@
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

@@ -43,8 +43,8 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
internal sealed interface EpisodeTopBarShortcut { internal sealed interface EpisodeTopBarShortcut {

View File

@@ -37,13 +37,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType

View File

@@ -35,8 +35,8 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
@Composable @Composable

View File

@@ -35,11 +35,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.MovieDto import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType

View File

@@ -65,6 +65,7 @@ import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.WatchStateIndicator import hu.bbara.purefin.common.ui.components.WatchStateIndicator
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
@@ -73,7 +74,6 @@ import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType

View File

@@ -33,13 +33,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.MediaSynopsis
import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType

View File

@@ -0,0 +1,22 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.PlayMethod
fun playbackCustomCacheKey(
mediaId: String,
playbackUrl: String,
playMethod: PlayMethod
): String? {
val normalizedUrl = playbackUrl.substringBefore('?').substringBefore('#').lowercase()
val isAdaptiveManifest =
normalizedUrl.endsWith(".m3u8") ||
normalizedUrl.endsWith(".mpd") ||
normalizedUrl.endsWith(".ism") ||
normalizedUrl.contains(".ism/")
return if (playMethod == PlayMethod.DIRECT_PLAY && !isAdaptiveManifest) {
mediaId
} else {
null
}
}

View File

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

View File

@@ -0,0 +1,17 @@
package hu.bbara.purefin.core.data.download
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.sdk.model.UUID
interface MediaDownloadController {
fun observeActiveDownloads(): Flow<Map<String, Float>>
fun observeDownloadState(contentId: String): StateFlow<DownloadState>
suspend fun downloadMovie(movieId: UUID)
suspend fun cancelDownload(movieId: UUID)
suspend fun downloadEpisode(episodeId: UUID)
suspend fun downloadEpisodes(episodeIds: List<UUID>)
suspend fun cancelEpisodeDownload(episodeId: UUID)
suspend fun enableSmartDownload(seriesId: UUID)
suspend fun syncSmartDownloads()
}

View File

@@ -0,0 +1,43 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.PlayMethod
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PlaybackCacheKeysTest {
@Test
fun `progressive direct play gets stable cache key`() {
assertEquals(
"movie-1",
playbackCustomCacheKey(
mediaId = "movie-1",
playbackUrl = "https://example.com/Videos/movie-1.mp4",
playMethod = PlayMethod.DIRECT_PLAY
)
)
}
@Test
fun `adaptive direct stream skips stable cache key`() {
assertNull(
playbackCustomCacheKey(
mediaId = "episode-1",
playbackUrl = "https://example.com/Videos/episode-1/master.m3u8",
playMethod = PlayMethod.DIRECT_STREAM
)
)
}
@Test
fun `manifest-looking direct play still skips stable cache key`() {
assertNull(
playbackCustomCacheKey(
mediaId = "episode-2",
playbackUrl = "https://example.com/Videos/episode-2/master.m3u8",
playMethod = PlayMethod.DIRECT_PLAY
)
)
}
}

View File

@@ -1,12 +1,16 @@
package hu.bbara.purefin.core.player.data package hu.bbara.purefin.core.player.data
import android.util.Log import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient 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.PlaybackReportContext
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -35,7 +39,7 @@ class PlayerMediaRepository @Inject constructor(
val mediaItem = createMediaItem( val mediaItem = createMediaItem(
mediaId = mediaId.toString(), mediaId = mediaId.toString(),
playbackUrl = playbackDecision.url, playbackDecision = playbackDecision,
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null, title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
subtitle = seasonEpisodeLabel(baseItem), subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
@@ -59,7 +63,7 @@ class PlayerMediaRepository @Inject constructor(
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
createMediaItem( createMediaItem(
mediaId = stringId, mediaId = stringId,
playbackUrl = playbackDecision.url, playbackDecision = playbackDecision,
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null, title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
subtitle = seasonEpisodeLabel(episode), subtitle = seasonEpisodeLabel(episode),
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
@@ -72,9 +76,10 @@ class PlayerMediaRepository @Inject constructor(
} }
} }
@OptIn(UnstableApi::class)
private fun createMediaItem( private fun createMediaItem(
mediaId: String, mediaId: String,
playbackUrl: String, playbackDecision: PlaybackDecision,
title: String, title: String,
subtitle: String?, subtitle: String?,
artworkUrl: String, artworkUrl: String,
@@ -85,12 +90,19 @@ class PlayerMediaRepository @Inject constructor(
.setSubtitle(subtitle) .setSubtitle(subtitle)
.setArtworkUri(artworkUrl.toUri()) .setArtworkUri(artworkUrl.toUri())
.build() .build()
return MediaItem.Builder() val builder = MediaItem.Builder()
.setUri(playbackUrl.toUri()) .setUri(playbackDecision.url.toUri())
.setMediaId(mediaId) .setMediaId(mediaId)
.setMediaMetadata(metadata) .setMediaMetadata(metadata)
.setTag(playbackReportContext) .setTag(playbackReportContext)
.build()
playbackCustomCacheKey(
mediaId = mediaId,
playbackUrl = playbackDecision.url,
playMethod = playbackDecision.reportContext.playMethod
)?.let(builder::setCustomCacheKey)
return builder.build()
} }
private fun calculateResumePosition( private fun calculateResumePosition(

View File

@@ -0,0 +1,23 @@
package hu.bbara.purefin.core.player.module
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import javax.inject.Singleton
@OptIn(UnstableApi::class)
@Module
@InstallIn(SingletonComponent::class)
object PlaybackNetworkModule {
@Provides
@Singleton
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
return OkHttpDataSource.Factory(okHttpClient)
}
}

View File

@@ -6,7 +6,7 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@@ -26,7 +26,10 @@ object VideoPlayerModule {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Provides @Provides
@ViewModelScoped @ViewModelScoped
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player { fun provideVideoPlayer(
application: Application,
playbackDataSourceFactory: DataSource.Factory
): Player {
val trackSelector = DefaultTrackSelector(application) val trackSelector = DefaultTrackSelector(application)
val audioAttributes = val audioAttributes =
AudioAttributes.Builder() AudioAttributes.Builder()
@@ -53,7 +56,7 @@ object VideoPlayerModule {
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
.setEnableDecoderFallback(true) .setEnableDecoderFallback(true)
val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory) val mediaSourceFactory = DefaultMediaSourceFactory(playbackDataSourceFactory)
return ExoPlayer.Builder(application, renderersFactory) return ExoPlayer.Builder(application, renderersFactory)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)

View File

@@ -36,4 +36,5 @@ dependencies {
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.jellyfin.core) implementation(libs.jellyfin.core)
testImplementation(libs.junit)
} }

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.feature.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 javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class DownloadControllerModule {
@Binds
@Singleton
abstract fun bindMediaDownloadController(
impl: MediaDownloadManager
): MediaDownloadController
}

View File

@@ -4,7 +4,9 @@ import android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheEvictor
import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -15,11 +17,25 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import java.io.File import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
import javax.inject.Singleton import javax.inject.Singleton
@OptIn(UnstableApi::class)
internal object DownloadModuleFactories {
fun createDownloadCacheEvictor(): CacheEvictor {
return NoOpCacheEvictor()
}
fun newPhonePlaybackDataSourceFactory(
upstreamDataSourceFactory: DataSource.Factory
): CacheDataSource.Factory {
return CacheDataSource.Factory()
.setUpstreamDataSourceFactory(upstreamDataSourceFactory)
.setCacheWriteDataSinkFactory(null)
}
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -31,13 +47,11 @@ object DownloadModule {
@Singleton @Singleton
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache { fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
val downloadDir = File(context.getExternalFilesDir(null), "downloads") val downloadDir = File(context.getExternalFilesDir(null), "downloads")
return SimpleCache(downloadDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(context)) return SimpleCache(
} downloadDir,
DownloadModuleFactories.createDownloadCacheEvictor(),
@Provides StandaloneDatabaseProvider(context)
@Singleton )
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
return OkHttpDataSource.Factory(okHttpClient)
} }
@Provides @Provides
@@ -64,12 +78,12 @@ object DownloadModule {
@Provides @Provides
@Singleton @Singleton
fun provideCacheDataSourceFactory( fun providePlaybackDataSourceFactory(
cache: SimpleCache, cache: SimpleCache,
okHttpDataSourceFactory: OkHttpDataSource.Factory okHttpDataSourceFactory: OkHttpDataSource.Factory
): CacheDataSource.Factory { ): DataSource.Factory {
return CacheDataSource.Factory() return DownloadModuleFactories
.newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory)
.setCache(cache) .setCache(cache)
.setUpstreamDataSourceFactory(okHttpDataSourceFactory)
} }
} }

View File

@@ -10,8 +10,11 @@ import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadRequest
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.InMemoryMediaRepository import hu.bbara.purefin.core.data.InMemoryMediaRepository
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.client.JellyfinApiClient import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper 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.room.dao.MovieDao import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
@@ -33,9 +36,11 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -51,7 +56,7 @@ class MediaDownloadManager @Inject constructor(
private val smartDownloadDao: SmartDownloadDao, private val smartDownloadDao: SmartDownloadDao,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val inMemoryMediaRepository: InMemoryMediaRepository, private val inMemoryMediaRepository: InMemoryMediaRepository,
) { ) : MediaDownloadController {
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>() private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
@@ -87,7 +92,7 @@ class MediaDownloadManager @Inject constructor(
* Uses the download index directly so it always reflects the true download state, * Uses the download index directly so it always reflects the true download state,
* regardless of listener callback timing. * regardless of listener callback timing.
*/ */
fun observeActiveDownloads(): Flow<Map<String, Float>> = flow { override fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
while (true) { while (true) {
try { try {
val result = buildMap<String, Float> { val result = buildMap<String, Float> {
@@ -112,7 +117,7 @@ class MediaDownloadManager @Inject constructor(
} }
}.flowOn(Dispatchers.IO).distinctUntilChanged() }.flowOn(Dispatchers.IO).distinctUntilChanged()
fun observeDownloadState(contentId: String): StateFlow<DownloadState> { override fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
val flow = getOrCreateStateFlow(contentId) val flow = getOrCreateStateFlow(contentId)
// Initialize from current download index // Initialize from current download index
val download = downloadManager.downloadIndex.getDownload(contentId) val download = downloadManager.downloadIndex.getDownload(contentId)
@@ -124,7 +129,7 @@ class MediaDownloadManager @Inject constructor(
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
} }
suspend fun downloadMovie(movieId: UUID) { override suspend fun downloadMovie(movieId: UUID) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val sources = jellyfinApiClient.getMediaSources(movieId) val sources = jellyfinApiClient.getMediaSources(movieId)
@@ -132,6 +137,7 @@ class MediaDownloadManager @Inject constructor(
Log.e(TAG, "No media sources for $movieId") Log.e(TAG, "No media sources for $movieId")
return@withContext return@withContext
} }
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run { val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
Log.e(TAG, "No playback URL for $movieId") Log.e(TAG, "No playback URL for $movieId")
@@ -168,9 +174,12 @@ class MediaDownloadManager @Inject constructor(
offlineDataSource.saveMovies(listOf(movie)) offlineDataSource.saveMovies(listOf(movie))
Log.d(TAG, "Starting download for '${movie.title}' from: $url") Log.d(TAG, "Starting download for '${movie.title}' from: $url")
val request = DownloadRequest.Builder(movieId.toString(), url.toUri()) val request = buildDownloadRequest(
.setData(movie.title.toByteArray(Charsets.UTF_8)) mediaId = movieId,
.build() playbackUrl = url,
title = movie.title,
isDirectPlay = !shouldTranscode
)
PurefinDownloadService.sendAddDownload(context, request) PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for $movieId") Log.d(TAG, "Download request sent for $movieId")
} catch (e: Exception) { } catch (e: Exception) {
@@ -180,7 +189,7 @@ class MediaDownloadManager @Inject constructor(
} }
} }
suspend fun cancelDownload(movieId: UUID) { override suspend fun cancelDownload(movieId: UUID) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, movieId.toString()) PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
try { try {
@@ -191,7 +200,7 @@ class MediaDownloadManager @Inject constructor(
} }
} }
suspend fun downloadEpisode(episodeId: UUID) { override suspend fun downloadEpisode(episodeId: UUID) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val serverUrl = userSessionRepository.serverUrl.first().trim() val serverUrl = userSessionRepository.serverUrl.first().trim()
@@ -200,6 +209,7 @@ class MediaDownloadManager @Inject constructor(
Log.e(TAG, "No media sources for episode $episodeId") Log.e(TAG, "No media sources for episode $episodeId")
return@withContext return@withContext
} }
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(episodeId, source) ?: run { val url = jellyfinApiClient.getMediaPlaybackUrl(episodeId, source) ?: run {
Log.e(TAG, "No playback URL for episode $episodeId") Log.e(TAG, "No playback URL for episode $episodeId")
@@ -232,9 +242,12 @@ class MediaDownloadManager @Inject constructor(
offlineDataSource.saveEpisode(episode) offlineDataSource.saveEpisode(episode)
Log.d(TAG, "Starting download for episode '${episode.title}' from: $url") Log.d(TAG, "Starting download for episode '${episode.title}' from: $url")
val request = DownloadRequest.Builder(episodeId.toString(), url.toUri()) val request = buildDownloadRequest(
.setData(episode.title.toByteArray(Charsets.UTF_8)) mediaId = episodeId,
.build() playbackUrl = url,
title = episode.title,
isDirectPlay = !shouldTranscode
)
PurefinDownloadService.sendAddDownload(context, request) PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for episode $episodeId") Log.d(TAG, "Download request sent for episode $episodeId")
} catch (e: Exception) { } catch (e: Exception) {
@@ -244,7 +257,7 @@ class MediaDownloadManager @Inject constructor(
} }
} }
suspend fun downloadEpisodes(episodeIds: List<UUID>) { override suspend fun downloadEpisodes(episodeIds: List<UUID>) {
coroutineScope { coroutineScope {
for (episodeId in episodeIds) { for (episodeId in episodeIds) {
launch { downloadEpisode(episodeId) } launch { downloadEpisode(episodeId) }
@@ -252,7 +265,7 @@ class MediaDownloadManager @Inject constructor(
} }
} }
suspend fun cancelEpisodeDownload(episodeId: UUID) { override suspend fun cancelEpisodeDownload(episodeId: UUID) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString()) PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
try { try {
@@ -265,7 +278,7 @@ class MediaDownloadManager @Inject constructor(
// ── Smart Download ────────────────────────────────────────────────── // ── Smart Download ──────────────────────────────────────────────────
suspend fun enableSmartDownload(seriesId: UUID) { override suspend fun enableSmartDownload(seriesId: UUID) {
smartDownloadDao.insert(SmartDownloadEntity(seriesId)) smartDownloadDao.insert(SmartDownloadEntity(seriesId))
syncSmartDownloadsForSeries(seriesId) syncSmartDownloadsForSeries(seriesId)
} }
@@ -276,7 +289,7 @@ class MediaDownloadManager @Inject constructor(
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadDao.observe(seriesId) fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadDao.observe(seriesId)
suspend fun syncSmartDownloads() { override suspend fun syncSmartDownloads() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val enabled = smartDownloadDao.getAll() val enabled = smartDownloadDao.getAll()
for (entry in enabled) { for (entry in enabled) {
@@ -337,6 +350,26 @@ class MediaDownloadManager @Inject constructor(
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) } return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
} }
private fun buildDownloadRequest(
mediaId: UUID,
playbackUrl: String,
title: String,
isDirectPlay: Boolean
): 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)
}
return builder.build()
}
private fun Download.toDownloadState(): DownloadState = when (state) { private fun Download.toDownloadState(): DownloadState = when (state) {
Download.STATE_COMPLETED -> DownloadState.Downloaded Download.STATE_COMPLETED -> DownloadState.Downloaded
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded) Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
@@ -380,7 +413,7 @@ class MediaDownloadManager @Inject constructor(
private fun BaseItemDto.toSeries(serverUrl: String): Series { private fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series( return Series(
id = id, id = id,
libraryId = parentId ?: UUID.randomUUID(), libraryId = parentId ?: java.util.UUID.randomUUID(),
name = name ?: "Unknown", name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available", synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
@@ -412,3 +445,8 @@ class MediaDownloadManager @Inject constructor(
private const val SMART_DOWNLOAD_COUNT = 5 private const val SMART_DOWNLOAD_COUNT = 5
} }
} }
private fun MediaSourceInfo.shouldUseTranscodingUrl(): Boolean {
return supportsTranscoding == true &&
(supportsDirectPlay == false || transcodingUrl != null)
}

View File

@@ -0,0 +1,43 @@
package hu.bbara.purefin.feature.download
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class DownloadModuleTest {
@Test
fun `download cache keeps no-op evictor`() {
assertTrue(DownloadModuleFactories.createDownloadCacheEvictor() is NoOpCacheEvictor)
}
@Test
fun `phone playback data source reads download cache and writes upstream only`() {
val upstreamFactory = FakeDataSourceFactory()
val factory = DownloadModuleFactories.newPhonePlaybackDataSourceFactory(upstreamFactory)
assertTrue(readPrivateBoolean(factory, "cacheIsReadOnly"))
assertNull(readPrivateField(factory, "cacheWriteDataSinkFactory"))
assertSame(upstreamFactory, readPrivateField(factory, "upstreamDataSourceFactory"))
}
private fun readPrivateBoolean(instance: CacheDataSource.Factory, fieldName: String): Boolean {
return readPrivateField(instance, fieldName) as Boolean
}
private fun readPrivateField(instance: CacheDataSource.Factory, fieldName: String): Any? {
val field = instance.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
return field.get(instance)
}
}
private class FakeDataSourceFactory : DataSource.Factory {
override fun createDataSource(): DataSource {
throw UnsupportedOperationException("Unused in unit tests")
}
}

View File

@@ -30,7 +30,6 @@ kotlin {
dependencies { dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":feature:download"))
implementation(libs.hilt) implementation(libs.hilt)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.jellyfin.core) implementation(libs.jellyfin.core)

View File

@@ -7,9 +7,9 @@ import hu.bbara.purefin.core.data.AppContentRepository
import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -24,7 +24,7 @@ import javax.inject.Inject
class EpisodeScreenViewModel @Inject constructor( class EpisodeScreenViewModel @Inject constructor(
private val appContentRepository: AppContentRepository, private val appContentRepository: AppContentRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager, private val mediaDownloadManager: MediaDownloadController,
): ViewModel() { ): ViewModel() {
private val _episodeId = MutableStateFlow<UUID?>(null) private val _episodeId = MutableStateFlow<UUID?>(null)

View File

@@ -6,9 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -23,7 +23,7 @@ import javax.inject.Inject
class MovieScreenViewModel @Inject constructor( class MovieScreenViewModel @Inject constructor(
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager private val mediaDownloadManager: MediaDownloadController
): ViewModel() { ): ViewModel() {
private val _movieId = MutableStateFlow<UUID?>(null) private val _movieId = MutableStateFlow<UUID?>(null)

View File

@@ -7,10 +7,10 @@ import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -27,7 +27,7 @@ import javax.inject.Inject
class SeriesViewModel @Inject constructor( class SeriesViewModel @Inject constructor(
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager, private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() { ) : ViewModel() {
private val _seriesId = MutableStateFlow<UUID?>(null) private val _seriesId = MutableStateFlow<UUID?>(null)

View File

@@ -4,12 +4,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.OfflineMediaRepository import hu.bbara.purefin.core.data.OfflineMediaRepository
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.MovieDto import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.feature.download.MediaDownloadManager
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -24,7 +24,7 @@ import javax.inject.Inject
class DownloadsViewModel @Inject constructor( class DownloadsViewModel @Inject constructor(
private val offlineMediaRepository: OfflineMediaRepository, private val offlineMediaRepository: OfflineMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val downloadManager: MediaDownloadManager private val downloadManager: MediaDownloadController
) : ViewModel() { ) : ViewModel() {
fun onMovieSelected(movieId: UUID) { fun onMovieSelected(movieId: UUID) {

View File

@@ -12,8 +12,8 @@ import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.model.Media import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -33,7 +33,7 @@ class AppViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val refreshHomeDataUseCase: RefreshHomeDataUseCase, private val refreshHomeDataUseCase: RefreshHomeDataUseCase,
private val mediaDownloadManager: MediaDownloadManager, private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() { ) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)