Compare commits

...

2 Commits

28 changed files with 447 additions and 62 deletions

View File

@@ -56,6 +56,7 @@ import org.junit.Test
private const val TvPlayerRootTag = "tv_player_root"
private const val 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,

View File

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

View File

@@ -150,6 +150,10 @@ fun TvPlayerScreen(
viewModel.seekBy(deltaMs)
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
)

View File

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

View File

@@ -43,8 +43,8 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.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 {

View File

@@ -37,13 +37,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.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

View File

@@ -35,8 +35,8 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.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

View File

@@ -35,11 +35,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.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

View File

@@ -65,6 +65,7 @@ import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.common.ui.components.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

View File

@@ -33,13 +33,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaSynopsis
import hu.bbara.purefin.common.ui.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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,16 @@
package hu.bbara.purefin.core.player.data
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(

View File

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

View File

@@ -6,7 +6,7 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.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)

View File

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

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.feature.download
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.download.MediaDownloadController
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class DownloadControllerModule {
@Binds
@Singleton
abstract fun bindMediaDownloadController(
impl: MediaDownloadManager
): MediaDownloadController
}

View File

@@ -4,7 +4,9 @@ import android.content.Context
import androidx.annotation.OptIn
import androidx.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)
}
}

View File

@@ -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)
}

View File

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

View File

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

View File

@@ -7,9 +7,9 @@ import hu.bbara.purefin.core.data.AppContentRepository
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.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)

View File

@@ -6,9 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.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)

View File

@@ -7,10 +7,10 @@ import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.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)

View File

@@ -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) {

View File

@@ -12,8 +12,8 @@ import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.navigation.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)