mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
2 Commits
a0af187e8f
...
3509a09064
| Author | SHA1 | Date | |
|---|---|---|---|
| 3509a09064 | |||
| 9a130e9738 |
@@ -56,6 +56,7 @@ import org.junit.Test
|
||||
private const val TvPlayerRootTag = "tv_player_root"
|
||||
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
|
||||
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
|
||||
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
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
|
||||
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
||||
composeRule.setContent {
|
||||
@@ -656,6 +717,7 @@ private fun TrackPanelHost(
|
||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
|
||||
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
|
||||
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
|
||||
val rootFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
@@ -677,6 +739,10 @@ private fun TrackPanelHost(
|
||||
resumeWithoutControlsCount += 1
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
lastHiddenSeekDeltaMs = deltaMs
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
@@ -721,7 +787,7 @@ private fun TrackPanelHost(
|
||||
onHideControls = { controlsVisible = false },
|
||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||
onSeekRelative = { _ -> controlsVisible = true },
|
||||
onSeekRelative = seekWithoutShowingControls,
|
||||
onShowControls = { controlsVisible = true },
|
||||
onTogglePlayPause = { controlsVisible = true }
|
||||
)
|
||||
@@ -781,6 +847,12 @@ private fun TrackPanelHost(
|
||||
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
|
||||
)
|
||||
|
||||
trackPanelType?.let { panelType ->
|
||||
TvTrackSelectionPanel(
|
||||
panelType = panelType,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -150,6 +150,10 @@ fun TvPlayerScreen(
|
||||
viewModel.seekBy(deltaMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekToLiveEdgeAndShowControls: () -> Unit = {
|
||||
viewModel.seekToLiveEdge()
|
||||
showTvControls()
|
||||
@@ -226,7 +230,7 @@ fun TvPlayerScreen(
|
||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
onSeekRelative = seekByWithoutShowingControls,
|
||||
onShowControls = showTvControls,
|
||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.MediaPlaybackSettings
|
||||
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.feature.download.DownloadState
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
|
||||
internal sealed interface EpisodeTopBarShortcut {
|
||||
|
||||
@@ -37,13 +37,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
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.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@@ -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.MediaPlaybackSettings
|
||||
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.feature.download.DownloadState
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -35,11 +35,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
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.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@@ -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.PurefinAsyncImage
|
||||
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.navigation.EpisodeDto
|
||||
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.Season
|
||||
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.player.PlayerActivity
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@@ -33,13 +33,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.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.download.DownloadState
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
package hu.bbara.purefin.core.data.download
|
||||
|
||||
sealed class DownloadState {
|
||||
data object NotDownloaded : DownloadState()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package hu.bbara.purefin.core.player.data
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.PlaybackDecision
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -35,7 +39,7 @@ class PlayerMediaRepository @Inject constructor(
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = playbackDecision.url,
|
||||
playbackDecision = playbackDecision,
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
|
||||
subtitle = seasonEpisodeLabel(baseItem),
|
||||
artworkUrl = artworkUrl,
|
||||
@@ -59,7 +63,7 @@ class PlayerMediaRepository @Inject constructor(
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||
createMediaItem(
|
||||
mediaId = stringId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
playbackDecision = playbackDecision,
|
||||
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
|
||||
subtitle = seasonEpisodeLabel(episode),
|
||||
artworkUrl = artworkUrl,
|
||||
@@ -72,9 +76,10 @@ class PlayerMediaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createMediaItem(
|
||||
mediaId: String,
|
||||
playbackUrl: String,
|
||||
playbackDecision: PlaybackDecision,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
@@ -85,12 +90,19 @@ class PlayerMediaRepository @Inject constructor(
|
||||
.setSubtitle(subtitle)
|
||||
.setArtworkUri(artworkUrl.toUri())
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(playbackUrl.toUri())
|
||||
val builder = MediaItem.Builder()
|
||||
.setUri(playbackDecision.url.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setTag(playbackReportContext)
|
||||
.build()
|
||||
|
||||
playbackCustomCacheKey(
|
||||
mediaId = mediaId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
playMethod = playbackDecision.reportContext.playMethod
|
||||
)?.let(builder::setCustomCacheKey)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -26,7 +26,10 @@ object VideoPlayerModule {
|
||||
@OptIn(UnstableApi::class)
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
|
||||
fun provideVideoPlayer(
|
||||
application: Application,
|
||||
playbackDataSourceFactory: DataSource.Factory
|
||||
): Player {
|
||||
val trackSelector = DefaultTrackSelector(application)
|
||||
val audioAttributes =
|
||||
AudioAttributes.Builder()
|
||||
@@ -53,7 +56,7 @@ object VideoPlayerModule {
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
.setEnableDecoderFallback(true)
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(playbackDataSourceFactory)
|
||||
|
||||
return ExoPlayer.Builder(application, renderersFactory)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
|
||||
@@ -36,4 +36,5 @@ dependencies {
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.jellyfin.core)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.CacheEvictor
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
@@ -15,11 +17,25 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
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)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -31,13 +47,11 @@ object DownloadModule {
|
||||
@Singleton
|
||||
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
|
||||
val downloadDir = File(context.getExternalFilesDir(null), "downloads")
|
||||
return SimpleCache(downloadDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(context))
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
|
||||
return OkHttpDataSource.Factory(okHttpClient)
|
||||
return SimpleCache(
|
||||
downloadDir,
|
||||
DownloadModuleFactories.createDownloadCacheEvictor(),
|
||||
StandaloneDatabaseProvider(context)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@@ -64,12 +78,12 @@ object DownloadModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCacheDataSourceFactory(
|
||||
fun providePlaybackDataSourceFactory(
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): CacheDataSource.Factory {
|
||||
return CacheDataSource.Factory()
|
||||
): DataSource.Factory {
|
||||
return DownloadModuleFactories
|
||||
.newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory)
|
||||
.setCache(cache)
|
||||
.setUpstreamDataSourceFactory(okHttpDataSourceFactory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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.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.SmartDownloadDao
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
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 javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -51,7 +56,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
private val smartDownloadDao: SmartDownloadDao,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val inMemoryMediaRepository: InMemoryMediaRepository,
|
||||
) {
|
||||
) : MediaDownloadController {
|
||||
|
||||
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,
|
||||
* regardless of listener callback timing.
|
||||
*/
|
||||
fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
|
||||
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
|
||||
while (true) {
|
||||
try {
|
||||
val result = buildMap<String, Float> {
|
||||
@@ -112,7 +117,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).distinctUntilChanged()
|
||||
|
||||
fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||
override fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||
val flow = getOrCreateStateFlow(contentId)
|
||||
// Initialize from current download index
|
||||
val download = downloadManager.downloadIndex.getDownload(contentId)
|
||||
@@ -124,7 +129,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
|
||||
}
|
||||
|
||||
suspend fun downloadMovie(movieId: UUID) {
|
||||
override suspend fun downloadMovie(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sources = jellyfinApiClient.getMediaSources(movieId)
|
||||
@@ -132,6 +137,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
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")
|
||||
@@ -168,9 +174,12 @@ class MediaDownloadManager @Inject constructor(
|
||||
offlineDataSource.saveMovies(listOf(movie))
|
||||
|
||||
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
|
||||
val request = DownloadRequest.Builder(movieId.toString(), url.toUri())
|
||||
.setData(movie.title.toByteArray(Charsets.UTF_8))
|
||||
.build()
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = movieId,
|
||||
playbackUrl = url,
|
||||
title = movie.title,
|
||||
isDirectPlay = !shouldTranscode
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for $movieId")
|
||||
} 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) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
|
||||
try {
|
||||
@@ -191,7 +200,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadEpisode(episodeId: UUID) {
|
||||
override suspend fun downloadEpisode(episodeId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
@@ -200,6 +209,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
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")
|
||||
@@ -232,9 +242,12 @@ class MediaDownloadManager @Inject constructor(
|
||||
offlineDataSource.saveEpisode(episode)
|
||||
|
||||
Log.d(TAG, "Starting download for episode '${episode.title}' from: $url")
|
||||
val request = DownloadRequest.Builder(episodeId.toString(), url.toUri())
|
||||
.setData(episode.title.toByteArray(Charsets.UTF_8))
|
||||
.build()
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = episodeId,
|
||||
playbackUrl = url,
|
||||
title = episode.title,
|
||||
isDirectPlay = !shouldTranscode
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for episode $episodeId")
|
||||
} 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 {
|
||||
for (episodeId in episodeIds) {
|
||||
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) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
|
||||
try {
|
||||
@@ -265,7 +278,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
|
||||
// ── Smart Download ──────────────────────────────────────────────────
|
||||
|
||||
suspend fun enableSmartDownload(seriesId: UUID) {
|
||||
override suspend fun enableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadDao.insert(SmartDownloadEntity(seriesId))
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
}
|
||||
@@ -276,7 +289,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
|
||||
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadDao.observe(seriesId)
|
||||
|
||||
suspend fun syncSmartDownloads() {
|
||||
override suspend fun syncSmartDownloads() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val enabled = smartDownloadDao.getAll()
|
||||
for (entry in enabled) {
|
||||
@@ -337,6 +350,26 @@ class MediaDownloadManager @Inject constructor(
|
||||
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) {
|
||||
Download.STATE_COMPLETED -> DownloadState.Downloaded
|
||||
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
|
||||
@@ -380,7 +413,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
private fun BaseItemDto.toSeries(serverUrl: String): Series {
|
||||
return Series(
|
||||
id = id,
|
||||
libraryId = parentId ?: UUID.randomUUID(),
|
||||
libraryId = parentId ?: java.util.UUID.randomUUID(),
|
||||
name = name ?: "Unknown",
|
||||
synopsis = overview ?: "No synopsis available",
|
||||
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
|
||||
@@ -412,3 +445,8 @@ class MediaDownloadManager @Inject constructor(
|
||||
private const val SMART_DOWNLOAD_COUNT = 5
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSourceInfo.shouldUseTranscodingUrl(): Boolean {
|
||||
return supportsTranscoding == true &&
|
||||
(supportsDirectPlay == false || transcodingUrl != null)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":feature:download"))
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.jellyfin.core)
|
||||
|
||||
@@ -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.Route
|
||||
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.feature.download.DownloadState
|
||||
import hu.bbara.purefin.feature.download.MediaDownloadManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -24,7 +24,7 @@ import javax.inject.Inject
|
||||
class EpisodeScreenViewModel @Inject constructor(
|
||||
private val appContentRepository: AppContentRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
): ViewModel() {
|
||||
|
||||
private val _episodeId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
@@ -6,9 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaRepository
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.data.download.DownloadState
|
||||
import hu.bbara.purefin.core.data.download.MediaDownloadController
|
||||
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.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -23,7 +23,7 @@ import javax.inject.Inject
|
||||
class MovieScreenViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadManager
|
||||
private val mediaDownloadManager: MediaDownloadController
|
||||
): ViewModel() {
|
||||
|
||||
private val _movieId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
@@ -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.NavigationManager
|
||||
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.Series
|
||||
import hu.bbara.purefin.feature.download.DownloadState
|
||||
import hu.bbara.purefin.feature.download.MediaDownloadManager
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -27,7 +27,7 @@ import javax.inject.Inject
|
||||
class SeriesViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _seriesId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
@@ -4,12 +4,12 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
||||
import hu.bbara.purefin.feature.download.MediaDownloadManager
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -24,7 +24,7 @@ import javax.inject.Inject
|
||||
class DownloadsViewModel @Inject constructor(
|
||||
private val offlineMediaRepository: OfflineMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val downloadManager: MediaDownloadManager
|
||||
private val downloadManager: MediaDownloadController
|
||||
) : ViewModel() {
|
||||
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
|
||||
@@ -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.SeriesDto
|
||||
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.feature.download.MediaDownloadManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -33,7 +33,7 @@ class AppViewModel @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val refreshHomeDataUseCase: RefreshHomeDataUseCase,
|
||||
private val mediaDownloadManager: MediaDownloadManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
|
||||
Reference in New Issue
Block a user