mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Separate mobile downloads from TV playback
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package hu.bbara.purefin.core.data.download
|
||||
|
||||
sealed class DownloadState {
|
||||
data object NotDownloaded : DownloadState()
|
||||
data class Downloading(val progressPercent: Float) : DownloadState()
|
||||
data object Downloaded : DownloadState()
|
||||
data object Failed : DownloadState()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package hu.bbara.purefin.core.data.download
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
interface MediaDownloadController {
|
||||
fun observeActiveDownloads(): Flow<Map<String, Float>>
|
||||
fun observeDownloadState(contentId: String): StateFlow<DownloadState>
|
||||
suspend fun downloadMovie(movieId: UUID)
|
||||
suspend fun cancelDownload(movieId: UUID)
|
||||
suspend fun downloadEpisode(episodeId: UUID)
|
||||
suspend fun downloadEpisodes(episodeIds: List<UUID>)
|
||||
suspend fun cancelEpisodeDownload(episodeId: UUID)
|
||||
suspend fun enableSmartDownload(seriesId: UUID)
|
||||
suspend fun syncSmartDownloads()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackCacheKeysTest {
|
||||
|
||||
@Test
|
||||
fun `progressive direct play gets stable cache key`() {
|
||||
assertEquals(
|
||||
"movie-1",
|
||||
playbackCustomCacheKey(
|
||||
mediaId = "movie-1",
|
||||
playbackUrl = "https://example.com/Videos/movie-1.mp4",
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adaptive direct stream skips stable cache key`() {
|
||||
assertNull(
|
||||
playbackCustomCacheKey(
|
||||
mediaId = "episode-1",
|
||||
playbackUrl = "https://example.com/Videos/episode-1/master.m3u8",
|
||||
playMethod = PlayMethod.DIRECT_STREAM
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manifest-looking direct play still skips stable cache key`() {
|
||||
assertNull(
|
||||
playbackCustomCacheKey(
|
||||
mediaId = "episode-2",
|
||||
playbackUrl = "https://example.com/Videos/episode-2/master.m3u8",
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package hu.bbara.purefin.core.player.data
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.PlaybackDecision
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -35,7 +39,7 @@ class PlayerMediaRepository @Inject constructor(
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = playbackDecision.url,
|
||||
playbackDecision = playbackDecision,
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
|
||||
subtitle = seasonEpisodeLabel(baseItem),
|
||||
artworkUrl = artworkUrl,
|
||||
@@ -59,7 +63,7 @@ class PlayerMediaRepository @Inject constructor(
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||
createMediaItem(
|
||||
mediaId = stringId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
playbackDecision = playbackDecision,
|
||||
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
|
||||
subtitle = seasonEpisodeLabel(episode),
|
||||
artworkUrl = artworkUrl,
|
||||
@@ -72,9 +76,10 @@ class PlayerMediaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createMediaItem(
|
||||
mediaId: String,
|
||||
playbackUrl: String,
|
||||
playbackDecision: PlaybackDecision,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
@@ -85,12 +90,19 @@ class PlayerMediaRepository @Inject constructor(
|
||||
.setSubtitle(subtitle)
|
||||
.setArtworkUri(artworkUrl.toUri())
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(playbackUrl.toUri())
|
||||
val builder = MediaItem.Builder()
|
||||
.setUri(playbackDecision.url.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setTag(playbackReportContext)
|
||||
.build()
|
||||
|
||||
playbackCustomCacheKey(
|
||||
mediaId = mediaId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
playMethod = playbackDecision.reportContext.playMethod
|
||||
)?.let(builder::setCustomCacheKey)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package hu.bbara.purefin.core.player.module
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlaybackNetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
|
||||
return OkHttpDataSource.Factory(okHttpClient)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -26,7 +26,10 @@ object VideoPlayerModule {
|
||||
@OptIn(UnstableApi::class)
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
|
||||
fun provideVideoPlayer(
|
||||
application: Application,
|
||||
playbackDataSourceFactory: DataSource.Factory
|
||||
): Player {
|
||||
val trackSelector = DefaultTrackSelector(application)
|
||||
val audioAttributes =
|
||||
AudioAttributes.Builder()
|
||||
@@ -53,7 +56,7 @@ object VideoPlayerModule {
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
.setEnableDecoderFallback(true)
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(playbackDataSourceFactory)
|
||||
|
||||
return ExoPlayer.Builder(application, renderersFactory)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
|
||||
Reference in New Issue
Block a user