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 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,
|
||||||
|
|||||||
@@ -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)
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
sealed class DownloadState {
|
||||||
data object NotDownloaded : 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
|
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(
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user