mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
5 Commits
b47ebda8e6
...
b41a9c24a2
| Author | SHA1 | Date | |
|---|---|---|---|
| b41a9c24a2 | |||
| 98c936aea6 | |||
| cf08db5034 | |||
| 1a17c52326 | |||
| 674e0881d7 |
@@ -0,0 +1,15 @@
|
||||
package hu.bbara.purefin.tv.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object TvPlaybackProfileFamilyModule {
|
||||
|
||||
@Provides
|
||||
fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.TV
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import hu.bbara.purefin.tv.player.components.TvPlayerQueuePanel
|
||||
import hu.bbara.purefin.tv.player.components.TvTrackPanelType
|
||||
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
|
||||
|
||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun TvPlayerScreen(
|
||||
@@ -77,17 +79,41 @@ fun TvPlayerScreen(
|
||||
}
|
||||
|
||||
BackHandler(enabled = controlsVisible) {
|
||||
viewModel.hideControls()
|
||||
viewModel.toggleControlsVisibility()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
if (uiState.isPlaying) {
|
||||
viewModel.hideControls()
|
||||
if (uiState.isPlaying && controlsVisible) {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val hiddenControlFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val showTvControls: () -> Unit = {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val togglePlayPauseAndShowControls: () -> Unit = {
|
||||
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekToLiveEdgeAndShowControls: () -> Unit = {
|
||||
viewModel.seekToLiveEdge()
|
||||
showTvControls()
|
||||
}
|
||||
val nextAndShowControls: () -> Unit = {
|
||||
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val previousAndShowControls: () -> Unit = {
|
||||
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible) {
|
||||
if (controlsVisible) {
|
||||
@@ -106,23 +132,22 @@ fun TvPlayerScreen(
|
||||
if (!controlsVisible && event.type == KeyEventType.KeyDown) {
|
||||
when (event.key) {
|
||||
Key.DirectionLeft -> {
|
||||
viewModel.seekBy(-10_000)
|
||||
seekByAndShowControls(-10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
viewModel.seekBy(10_000)
|
||||
seekByAndShowControls(10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
viewModel.showControls()
|
||||
showTvControls()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
viewModel.togglePlayPause()
|
||||
viewModel.showControls()
|
||||
togglePlayPauseAndShowControls()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -157,12 +182,12 @@ fun TvPlayerScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
onSeek = { viewModel.seekTo(it) },
|
||||
onSeekRelative = { viewModel.seekBy(it) },
|
||||
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onPlayPause = togglePlayPauseAndShowControls,
|
||||
onSeek = seekAndShowControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
||||
onNext = nextAndShowControls,
|
||||
onPrevious = previousAndShowControls,
|
||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||
@@ -174,10 +199,10 @@ fun TvPlayerScreen(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
uiState = uiState,
|
||||
onRetry = { viewModel.retry() },
|
||||
onNext = { viewModel.next() },
|
||||
onNext = nextAndShowControls,
|
||||
onReplay = {
|
||||
viewModel.seekTo(0L)
|
||||
viewModel.togglePlayPause()
|
||||
togglePlayPauseAndShowControls()
|
||||
},
|
||||
onDismissError = { viewModel.clearError() }
|
||||
)
|
||||
@@ -209,7 +234,7 @@ fun TvPlayerScreen(
|
||||
TvPlayerQueuePanel(
|
||||
uiState = uiState,
|
||||
onSelect = { id ->
|
||||
viewModel.playQueueItem(id)
|
||||
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
|
||||
showQueuePanel = false
|
||||
},
|
||||
onClose = { showQueuePanel = false },
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
@@ -48,20 +47,11 @@ internal fun TvPlayerLoadingErrorEndCard(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error?.summary ?: "Playback error",
|
||||
text = uiState.error ?: "Playback error",
|
||||
color = scheme.onBackground,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
uiState.error?.detailText?.let { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
color = scheme.onBackground.copy(alpha = 0.82f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onRetry) {
|
||||
Text("Retry")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package hu.bbara.purefin.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlaybackProfileFamilyModule {
|
||||
|
||||
@Provides
|
||||
fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.MOBILE
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
@@ -47,19 +46,10 @@ fun PlayerLoadingErrorEndCard(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error?.summary ?: "Playback error",
|
||||
text = uiState.error ?: "Playback error",
|
||||
color = scheme.onBackground,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
uiState.error?.detailText?.let { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
color = scheme.onBackground.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onRetry) {
|
||||
Text("Retry")
|
||||
|
||||
@@ -36,6 +36,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.media.MediaFormat
|
||||
|
||||
internal data class AndroidCodecCatalog(
|
||||
val supportedVideoCodecs: Set<String>,
|
||||
val supportedAudioCodecs: Set<String>,
|
||||
val videoCodecProfiles: Map<String, Set<String>>,
|
||||
) {
|
||||
companion object {
|
||||
fun create(codecInfos: Array<MediaCodecInfo>): AndroidCodecCatalog {
|
||||
val supportedVideoCodecs = linkedSetOf<String>()
|
||||
val supportedAudioCodecs = linkedSetOf<String>()
|
||||
val videoCodecProfiles = linkedMapOf<String, MutableSet<String>>()
|
||||
|
||||
for (codecInfo in codecInfos) {
|
||||
if (codecInfo.isEncoder) continue
|
||||
|
||||
for (mimeType in codecInfo.supportedTypes) {
|
||||
val capabilities = try {
|
||||
codecInfo.getCapabilitiesForType(mimeType)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
continue
|
||||
}
|
||||
|
||||
androidVideoCodecName(mimeType)?.let { codec ->
|
||||
supportedVideoCodecs += codec
|
||||
val profiles = videoCodecProfiles.getOrPut(codec) { linkedSetOf() }
|
||||
capabilities.profileLevels
|
||||
.mapNotNull { androidVideoProfileName(codec, it.profile) }
|
||||
.forEach(profiles::add)
|
||||
}
|
||||
|
||||
androidAudioCodecName(mimeType)?.let(supportedAudioCodecs::add)
|
||||
}
|
||||
}
|
||||
|
||||
return AndroidCodecCatalog(
|
||||
supportedVideoCodecs = supportedVideoCodecs,
|
||||
supportedAudioCodecs = supportedAudioCodecs,
|
||||
videoCodecProfiles = videoCodecProfiles.mapValues { it.value.toSet() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidVideoCodecName(mimeType: String): String? = when (mimeType) {
|
||||
MediaFormat.MIMETYPE_VIDEO_MPEG2 -> "mpeg2video"
|
||||
MediaFormat.MIMETYPE_VIDEO_H263 -> "h263"
|
||||
MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION -> "hevc"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
|
||||
MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun androidAudioCodecName(mimeType: String): String? = when (mimeType) {
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
|
||||
MediaFormat.MIMETYPE_AUDIO_AC3 -> "ac3"
|
||||
MediaFormat.MIMETYPE_AUDIO_AMR_WB, MediaFormat.MIMETYPE_AUDIO_AMR_NB -> "3gpp"
|
||||
MediaFormat.MIMETYPE_AUDIO_EAC3 -> "eac3"
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
|
||||
MediaFormat.MIMETYPE_AUDIO_MPEG -> "mp3"
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
|
||||
MediaFormat.MIMETYPE_AUDIO_RAW -> "raw"
|
||||
MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
|
||||
MediaFormat.MIMETYPE_AUDIO_QCELP,
|
||||
MediaFormat.MIMETYPE_AUDIO_MSGSM,
|
||||
MediaFormat.MIMETYPE_AUDIO_G711_MLAW,
|
||||
MediaFormat.MIMETYPE_AUDIO_G711_ALAW,
|
||||
-> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun androidVideoProfileName(codec: String, profile: Int): String? = when (codec) {
|
||||
"mpeg2video" -> mpeg2ProfileName(profile)
|
||||
"h263" -> h263ProfileName(profile)
|
||||
"mpeg4" -> mpeg4ProfileName(profile)
|
||||
"h264" -> avcProfileName(profile)
|
||||
"hevc" -> hevcProfileName(profile)
|
||||
"vp8" -> vp8ProfileName(profile)
|
||||
"vp9" -> vp9ProfileName(profile)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mpeg2ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.MPEG2ProfileSimple -> "simple profile"
|
||||
CodecProfileLevel.MPEG2ProfileMain -> "main profile"
|
||||
CodecProfileLevel.MPEG2Profile422 -> "422 profile"
|
||||
CodecProfileLevel.MPEG2ProfileSNR -> "snr profile"
|
||||
CodecProfileLevel.MPEG2ProfileSpatial -> "spatial profile"
|
||||
CodecProfileLevel.MPEG2ProfileHigh -> "high profile"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun h263ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.H263ProfileBaseline -> "baseline"
|
||||
CodecProfileLevel.H263ProfileH320Coding -> "h320 coding"
|
||||
CodecProfileLevel.H263ProfileBackwardCompatible -> "backward compatible"
|
||||
CodecProfileLevel.H263ProfileISWV2 -> "isw v2"
|
||||
CodecProfileLevel.H263ProfileISWV3 -> "isw v3"
|
||||
CodecProfileLevel.H263ProfileHighCompression -> "high compression"
|
||||
CodecProfileLevel.H263ProfileInternet -> "internet"
|
||||
CodecProfileLevel.H263ProfileInterlace -> "interlace"
|
||||
CodecProfileLevel.H263ProfileHighLatency -> "high latency"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mpeg4ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedCoding -> "advanced coding profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedCore -> "advanced core profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedRealTime -> "advanced realtime profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedSimple -> "advanced simple profile"
|
||||
CodecProfileLevel.MPEG4ProfileBasicAnimated -> "basic animated profile"
|
||||
CodecProfileLevel.MPEG4ProfileCore -> "core profile"
|
||||
CodecProfileLevel.MPEG4ProfileCoreScalable -> "core scalable profile"
|
||||
CodecProfileLevel.MPEG4ProfileHybrid -> "hybrid profile"
|
||||
CodecProfileLevel.MPEG4ProfileNbit -> "nbit profile"
|
||||
CodecProfileLevel.MPEG4ProfileScalableTexture -> "scalable texture profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimple -> "simple profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleFBA -> "simple fba profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleFace -> "simple face profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleScalable -> "simple scalable profile"
|
||||
CodecProfileLevel.MPEG4ProfileMain -> "main profile"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun avcProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.AVCProfileBaseline -> "baseline"
|
||||
CodecProfileLevel.AVCProfileMain -> "main"
|
||||
CodecProfileLevel.AVCProfileExtended -> "extended"
|
||||
CodecProfileLevel.AVCProfileHigh -> "high"
|
||||
CodecProfileLevel.AVCProfileHigh10 -> "high 10"
|
||||
CodecProfileLevel.AVCProfileHigh422 -> "high 422"
|
||||
CodecProfileLevel.AVCProfileHigh444 -> "high 444"
|
||||
CodecProfileLevel.AVCProfileConstrainedBaseline -> "constrained baseline"
|
||||
CodecProfileLevel.AVCProfileConstrainedHigh -> "constrained high"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun hevcProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.HEVCProfileMain -> "Main"
|
||||
CodecProfileLevel.HEVCProfileMain10 -> "Main 10"
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10 -> "Main 10 HDR 10"
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10Plus -> "Main 10 HDR 10 Plus"
|
||||
CodecProfileLevel.HEVCProfileMainStill -> "Main Still"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun vp8ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.VP8ProfileMain -> "main"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun vp9ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.VP9Profile0 -> "Profile 0"
|
||||
CodecProfileLevel.VP9Profile1 -> "Profile 1"
|
||||
CodecProfileLevel.VP9Profile2,
|
||||
CodecProfileLevel.VP9Profile2HDR,
|
||||
-> "Profile 2"
|
||||
CodecProfileLevel.VP9Profile3,
|
||||
CodecProfileLevel.VP9Profile3HDR,
|
||||
-> "Profile 3"
|
||||
else -> null
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes as PlatformAudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaCodecList
|
||||
import android.util.Log
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||
|
||||
/**
|
||||
* Creates a DeviceProfile for Android devices with proper codec support detection.
|
||||
* This prevents playback failures by requesting transcoding for unsupported formats like DTS-HD.
|
||||
*/
|
||||
object AndroidDeviceProfile {
|
||||
|
||||
private const val TAG = "AndroidDeviceProfile"
|
||||
private const val DEFAULT_MAX_AUDIO_CHANNELS = 2
|
||||
private const val PROBE_SAMPLE_RATE = 48_000
|
||||
private const val PROBE_ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
||||
|
||||
@Volatile
|
||||
private var cachedSnapshot: CapabilitySnapshot? = null
|
||||
|
||||
data class CapabilitySnapshot(
|
||||
val deviceProfile: DeviceProfile,
|
||||
val maxAudioChannels: Int
|
||||
)
|
||||
|
||||
fun getSnapshot(context: Context): CapabilitySnapshot {
|
||||
cachedSnapshot?.let { return it }
|
||||
|
||||
return synchronized(this) {
|
||||
cachedSnapshot?.let { return@synchronized it }
|
||||
|
||||
val applicationContext = context.applicationContext
|
||||
|
||||
// Debug logging is noisy and expensive, so keep it to debug builds/devices only when needed.
|
||||
val audioCodecs = getAudioCodecs()
|
||||
val videoCodecs = getVideoCodecs()
|
||||
val maxAudioChannels = resolveMaxAudioChannels(applicationContext)
|
||||
|
||||
Log.d(TAG, "Supported audio codecs: ${audioCodecs.joinToString()}")
|
||||
Log.d(TAG, "Supported video codecs: ${videoCodecs.joinToString()}")
|
||||
Log.d(TAG, "Max audio channels: $maxAudioChannels")
|
||||
|
||||
val snapshot = CapabilitySnapshot(
|
||||
deviceProfile = createDeviceProfile(audioCodecs, videoCodecs, maxAudioChannels),
|
||||
maxAudioChannels = maxAudioChannels
|
||||
)
|
||||
cachedSnapshot = snapshot
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
|
||||
|
||||
internal fun createDeviceProfile(
|
||||
audioCodecs: List<String>,
|
||||
videoCodecs: List<String>,
|
||||
maxAudioChannels: Int
|
||||
): DeviceProfile {
|
||||
val hlsOptions = HlsPlaybackStability.conservativeHlsOptions
|
||||
|
||||
return DeviceProfile(
|
||||
name = "Android Media3",
|
||||
maxStaticBitrate = 100_000_000,
|
||||
maxStreamingBitrate = 100_000_000,
|
||||
|
||||
// Direct play profiles - what we can play natively
|
||||
directPlayProfiles = listOf(
|
||||
DirectPlayProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "mp4,m4v,mkv,webm,ts,mov",
|
||||
videoCodec = videoCodecs.joinToString(","),
|
||||
audioCodec = audioCodecs.joinToString(",")
|
||||
)
|
||||
),
|
||||
|
||||
// Explicit video transcoding support keeps Jellyfin 10.10/10.11 from
|
||||
// returning sources that exist but are not marked usable for playback.
|
||||
transcodingProfiles = listOf(
|
||||
TranscodingProfile(
|
||||
container = "ts",
|
||||
type = DlnaProfileType.VIDEO,
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
protocol = MediaStreamProtocol.HLS,
|
||||
context = EncodingContext.STREAMING,
|
||||
maxAudioChannels = maxAudioChannels.toString(),
|
||||
minSegments = hlsOptions.minSegments,
|
||||
segmentLength = hlsOptions.segmentLengthSeconds,
|
||||
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
|
||||
copyTimestamps = hlsOptions.copyTimestamps,
|
||||
conditions = emptyList()
|
||||
)
|
||||
),
|
||||
|
||||
codecProfiles = emptyList(),
|
||||
|
||||
subtitleProfiles = listOf(
|
||||
// Prefer EMBED so subtitles stay in the container — this gives
|
||||
// correct cues after seeking (Media3 parses them at extraction time).
|
||||
SubtitleProfile("srt", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("ass", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("ssa", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("subrip", SubtitleDeliveryMethod.EMBED),
|
||||
SubtitleProfile("sub", SubtitleDeliveryMethod.EMBED),
|
||||
// EXTERNAL fallback for when embedding isn't possible
|
||||
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
|
||||
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL)
|
||||
),
|
||||
|
||||
containerProfiles = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported audio codecs on this device.
|
||||
* Excludes unsupported formats like DTS, DTS-HD, TrueHD which commonly cause playback failures.
|
||||
*/
|
||||
private fun getAudioCodecs(): List<String> {
|
||||
val supportedCodecs = mutableListOf<String>()
|
||||
|
||||
val commonCodecs = listOf(
|
||||
"aac" to "audio/mp4a-latm",
|
||||
"mp3" to "audio/mpeg",
|
||||
"ac3" to "audio/ac3",
|
||||
"eac3" to "audio/eac3",
|
||||
"dts" to "audio/vnd.dts",
|
||||
"dtshd_ma" to "audio/vnd.dts.hd",
|
||||
"truehd" to "audio/true-hd",
|
||||
"flac" to "audio/flac",
|
||||
"vorbis" to "audio/vorbis",
|
||||
"opus" to "audio/opus",
|
||||
"alac" to "audio/alac"
|
||||
)
|
||||
|
||||
for ((codecName, mimeType) in commonCodecs) {
|
||||
if (isCodecSupported(mimeType)) {
|
||||
supportedCodecs.add(codecName)
|
||||
}
|
||||
}
|
||||
|
||||
// AAC is mandatory on Android - ensure it's always included
|
||||
if (!supportedCodecs.contains("aac")) {
|
||||
supportedCodecs.add("aac")
|
||||
}
|
||||
|
||||
return supportedCodecs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported video codecs on this device.
|
||||
*/
|
||||
private fun getVideoCodecs(): List<String> {
|
||||
val supportedCodecs = mutableListOf<String>()
|
||||
|
||||
val commonCodecs = listOf(
|
||||
"h264" to "video/avc",
|
||||
"hevc" to "video/hevc",
|
||||
"vp9" to "video/x-vnd.on2.vp9",
|
||||
"vp8" to "video/x-vnd.on2.vp8",
|
||||
"mpeg4" to "video/mp4v-es",
|
||||
"av1" to "video/av01"
|
||||
)
|
||||
|
||||
for ((codecName, mimeType) in commonCodecs) {
|
||||
if (isCodecSupported(mimeType)) {
|
||||
supportedCodecs.add(codecName)
|
||||
}
|
||||
}
|
||||
|
||||
// H.264 is mandatory on Android - ensure it's always included
|
||||
if (!supportedCodecs.contains("h264")) {
|
||||
supportedCodecs.add("h264")
|
||||
}
|
||||
|
||||
return supportedCodecs
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific decoder (not encoder) is supported on this device.
|
||||
*/
|
||||
private fun isCodecSupported(mimeType: String): Boolean {
|
||||
val platformSupport = try {
|
||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
codecList.codecInfos.any { codecInfo ->
|
||||
!codecInfo.isEncoder &&
|
||||
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
if (platformSupport) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isCodecSupportedByFfmpeg(mimeType)
|
||||
}
|
||||
|
||||
private fun isCodecSupportedByFfmpeg(mimeType: String): Boolean {
|
||||
return runCatching {
|
||||
val ffmpegLibraryClass = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary")
|
||||
val supportsFormat = ffmpegLibraryClass.getMethod("supportsFormat", String::class.java)
|
||||
supportsFormat.invoke(null, mimeType) as? Boolean ?: false
|
||||
}.getOrElse { false }
|
||||
}
|
||||
|
||||
private fun resolveMaxAudioChannels(context: Context): Int {
|
||||
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
|
||||
return runCatching {
|
||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
val reportedMaxChannels = devices
|
||||
.flatMap { device ->
|
||||
buildList {
|
||||
addAll(device.channelCounts.toList())
|
||||
addAll(device.channelMasks.map(::channelMaskToCount))
|
||||
addAll(device.channelIndexMasks.map { Integer.bitCount(it) })
|
||||
}
|
||||
}
|
||||
.maxOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: DEFAULT_MAX_AUDIO_CHANNELS
|
||||
|
||||
val verifiedMaxChannels = verifyOutputChannelSupport(reportedMaxChannels)
|
||||
Log.d(TAG, "Reported max audio channels: $reportedMaxChannels, verified: $verifiedMaxChannels")
|
||||
verifiedMaxChannels
|
||||
}.getOrElse {
|
||||
DEFAULT_MAX_AUDIO_CHANNELS
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyOutputChannelSupport(reportedMaxChannels: Int): Int {
|
||||
val candidates = buildList {
|
||||
if (reportedMaxChannels >= 8) add(8)
|
||||
if (reportedMaxChannels >= 6) add(6)
|
||||
add(2)
|
||||
}.distinct()
|
||||
|
||||
return candidates.firstOrNull(::canCreateAudioTrackForChannelCount) ?: DEFAULT_MAX_AUDIO_CHANNELS
|
||||
}
|
||||
|
||||
private fun canCreateAudioTrackForChannelCount(channelCount: Int): Boolean {
|
||||
val channelMask = when (channelCount) {
|
||||
8 -> AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
|
||||
6 -> AudioFormat.CHANNEL_OUT_5POINT1
|
||||
2 -> AudioFormat.CHANNEL_OUT_STEREO
|
||||
else -> return false
|
||||
}
|
||||
|
||||
val minBufferSize = AudioTrack.getMinBufferSize(PROBE_SAMPLE_RATE, channelMask, PROBE_ENCODING)
|
||||
if (minBufferSize <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val audioTrack = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
PlatformAudioAttributes.Builder()
|
||||
.setUsage(PlatformAudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(PlatformAudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(PROBE_SAMPLE_RATE)
|
||||
.setEncoding(PROBE_ENCODING)
|
||||
.setChannelMask(channelMask)
|
||||
.build()
|
||||
)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(minBufferSize * 2)
|
||||
.build()
|
||||
|
||||
try {
|
||||
audioTrack.state == AudioTrack.STATE_INITIALIZED
|
||||
} finally {
|
||||
audioTrack.release()
|
||||
}
|
||||
}.getOrElse { false }
|
||||
}
|
||||
|
||||
private fun channelMaskToCount(mask: Int): Int {
|
||||
return if (mask == 0) {
|
||||
0
|
||||
} else {
|
||||
Integer.bitCount(mask)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
import androidx.media3.common.MimeTypes
|
||||
|
||||
data class ProfileResolution(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
interface DeviceProfileCapabilities {
|
||||
fun supportsAv1(): Boolean
|
||||
fun supportsAv1Main10(): Boolean
|
||||
fun supportsAv1DolbyVision(): Boolean
|
||||
fun supportsAv1Hdr10(): Boolean
|
||||
fun supportsAv1Hdr10Plus(): Boolean
|
||||
fun supportsAvc(): Boolean
|
||||
fun supportsAvcHigh10(): Boolean
|
||||
fun avcMainLevel(): Int
|
||||
fun avcHigh10Level(): Int
|
||||
fun supportsHevc(): Boolean
|
||||
fun supportsHevcMain10(): Boolean
|
||||
fun supportsHevcDolbyVision(): Boolean
|
||||
fun supportsHevcDolbyVisionEl(): Boolean
|
||||
fun supportsHevcHdr10(): Boolean
|
||||
fun supportsHevcHdr10Plus(): Boolean
|
||||
fun hevcMainLevel(): Int
|
||||
fun hevcMain10Level(): Int
|
||||
fun supportsVc1(): Boolean
|
||||
fun maxResolution(mimeType: String): ProfileResolution
|
||||
fun supportsVideoCodec(codec: String): Boolean
|
||||
fun supportsAudioCodec(codec: String): Boolean
|
||||
fun videoCodecProfiles(codec: String): Set<String>
|
||||
val hevcDoviHdr10PlusBug: Boolean
|
||||
}
|
||||
|
||||
internal class AndroidDeviceProfileCapabilities : DeviceProfileCapabilities {
|
||||
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
|
||||
private val codecCatalog by lazy { AndroidCodecCatalog.create(mediaCodecList.codecInfos) }
|
||||
|
||||
private object DolbyVisionProfiles {
|
||||
val profile7: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
CodecProfileLevel.DolbyVisionProfileDvheDtb
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object Av1ProfileLevels {
|
||||
val profileMain10: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1ProfileMain10
|
||||
} else {
|
||||
0x2
|
||||
}
|
||||
}
|
||||
val profileMain10Hdr10: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1ProfileMain10HDR10
|
||||
} else {
|
||||
0x1000
|
||||
}
|
||||
}
|
||||
val profileMain10Hdr10Plus: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1ProfileMain10HDR10Plus
|
||||
} else {
|
||||
0x2000
|
||||
}
|
||||
}
|
||||
val dolbyVisionProfile10: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
CodecProfileLevel.DolbyVisionProfileDvav110
|
||||
} else {
|
||||
0x400
|
||||
}
|
||||
}
|
||||
val level5: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1Level5
|
||||
} else {
|
||||
0x1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val avcLevels = listOf(
|
||||
CodecProfileLevel.AVCLevel1b to 9,
|
||||
CodecProfileLevel.AVCLevel1 to 10,
|
||||
CodecProfileLevel.AVCLevel11 to 11,
|
||||
CodecProfileLevel.AVCLevel12 to 12,
|
||||
CodecProfileLevel.AVCLevel13 to 13,
|
||||
CodecProfileLevel.AVCLevel2 to 20,
|
||||
CodecProfileLevel.AVCLevel21 to 21,
|
||||
CodecProfileLevel.AVCLevel22 to 22,
|
||||
CodecProfileLevel.AVCLevel3 to 30,
|
||||
CodecProfileLevel.AVCLevel31 to 31,
|
||||
CodecProfileLevel.AVCLevel32 to 32,
|
||||
CodecProfileLevel.AVCLevel4 to 40,
|
||||
CodecProfileLevel.AVCLevel41 to 41,
|
||||
CodecProfileLevel.AVCLevel42 to 42,
|
||||
CodecProfileLevel.AVCLevel5 to 50,
|
||||
CodecProfileLevel.AVCLevel51 to 51,
|
||||
CodecProfileLevel.AVCLevel52 to 52,
|
||||
)
|
||||
|
||||
private val hevcLevels = listOf(
|
||||
CodecProfileLevel.HEVCMainTierLevel1 to 30,
|
||||
CodecProfileLevel.HEVCMainTierLevel2 to 60,
|
||||
CodecProfileLevel.HEVCMainTierLevel21 to 63,
|
||||
CodecProfileLevel.HEVCMainTierLevel3 to 90,
|
||||
CodecProfileLevel.HEVCMainTierLevel31 to 93,
|
||||
CodecProfileLevel.HEVCMainTierLevel4 to 120,
|
||||
CodecProfileLevel.HEVCMainTierLevel41 to 123,
|
||||
CodecProfileLevel.HEVCMainTierLevel5 to 150,
|
||||
CodecProfileLevel.HEVCMainTierLevel51 to 153,
|
||||
CodecProfileLevel.HEVCMainTierLevel52 to 156,
|
||||
CodecProfileLevel.HEVCMainTierLevel6 to 180,
|
||||
CodecProfileLevel.HEVCMainTierLevel61 to 183,
|
||||
CodecProfileLevel.HEVCMainTierLevel62 to 186,
|
||||
)
|
||||
|
||||
override fun supportsAv1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1)
|
||||
|
||||
override fun supportsAv1Main10(): Boolean = hasDecoder(
|
||||
MimeTypes.VIDEO_AV1,
|
||||
Av1ProfileLevels.profileMain10,
|
||||
Av1ProfileLevels.level5,
|
||||
)
|
||||
|
||||
override fun supportsAv1DolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasDecoder(
|
||||
MimeTypes.VIDEO_DOLBY_VISION,
|
||||
Av1ProfileLevels.dolbyVisionProfile10,
|
||||
CodecProfileLevel.DolbyVisionLevelHd24,
|
||||
)
|
||||
|
||||
override fun supportsAv1Hdr10(): Boolean = hasDecoder(
|
||||
MimeTypes.VIDEO_AV1,
|
||||
Av1ProfileLevels.profileMain10Hdr10,
|
||||
Av1ProfileLevels.level5,
|
||||
)
|
||||
|
||||
override fun supportsAv1Hdr10Plus(): Boolean = hasDecoder(
|
||||
MimeTypes.VIDEO_AV1,
|
||||
Av1ProfileLevels.profileMain10Hdr10Plus,
|
||||
Av1ProfileLevels.level5,
|
||||
)
|
||||
|
||||
override fun supportsAvc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||
|
||||
override fun supportsAvcHigh10(): Boolean = hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
CodecProfileLevel.AVCProfileHigh10,
|
||||
CodecProfileLevel.AVCLevel4,
|
||||
)
|
||||
|
||||
override fun avcMainLevel(): Int = avcLevel(CodecProfileLevel.AVCProfileMain)
|
||||
|
||||
override fun avcHigh10Level(): Int = avcLevel(CodecProfileLevel.AVCProfileHigh10)
|
||||
|
||||
override fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC)
|
||||
|
||||
override fun supportsHevcMain10(): Boolean = hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
CodecProfileLevel.HEVCProfileMain10,
|
||||
CodecProfileLevel.HEVCMainTierLevel4,
|
||||
)
|
||||
|
||||
override fun supportsHevcDolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
|
||||
|
||||
override fun supportsHevcDolbyVisionEl(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
|
||||
DolbyVisionProfiles.profile7,
|
||||
CodecProfileLevel.DolbyVisionLevelHd24,
|
||||
) &&
|
||||
supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC)
|
||||
|
||||
override fun supportsHevcHdr10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10,
|
||||
CodecProfileLevel.HEVCMainTierLevel4,
|
||||
)
|
||||
|
||||
override fun supportsHevcHdr10Plus(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10Plus,
|
||||
CodecProfileLevel.HEVCMainTierLevel4,
|
||||
)
|
||||
|
||||
override fun hevcMainLevel(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain)
|
||||
|
||||
override fun hevcMain10Level(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain10)
|
||||
|
||||
override fun supportsVc1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_VC1)
|
||||
|
||||
override fun maxResolution(mimeType: String): ProfileResolution {
|
||||
var maxWidth = 0
|
||||
var maxHeight = 0
|
||||
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
val videoCapabilities = info.getCapabilitiesForType(mimeType).videoCapabilities ?: continue
|
||||
val supportedWidth = videoCapabilities.supportedWidths?.upper ?: continue
|
||||
val supportedHeight = videoCapabilities.supportedHeights?.upper ?: continue
|
||||
|
||||
maxWidth = maxOf(maxWidth, supportedWidth)
|
||||
maxHeight = maxOf(maxHeight, supportedHeight)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
|
||||
return ProfileResolution(maxWidth, maxHeight)
|
||||
}
|
||||
|
||||
override fun supportsVideoCodec(codec: String): Boolean = codecCatalog.supportedVideoCodecs.contains(codec)
|
||||
|
||||
override fun supportsAudioCodec(codec: String): Boolean = codecCatalog.supportedAudioCodecs.contains(codec)
|
||||
|
||||
override fun videoCodecProfiles(codec: String): Set<String> = codecCatalog.videoCodecProfiles[codec].orEmpty()
|
||||
|
||||
override val hevcDoviHdr10PlusBug: Boolean = Build.MODEL in modelsWithDoviHdr10PlusBug
|
||||
|
||||
private fun avcLevel(profile: Int): Int {
|
||||
val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile)
|
||||
return avcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0
|
||||
}
|
||||
|
||||
private fun hevcLevel(profile: Int): Int {
|
||||
val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile)
|
||||
return hevcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0
|
||||
}
|
||||
|
||||
private fun decoderLevel(mimeType: String, profile: Int): Int {
|
||||
var maxLevel = 0
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
val capabilities = info.getCapabilitiesForType(mimeType)
|
||||
for (profileLevel in capabilities.profileLevels) {
|
||||
if (profileLevel.profile == profile) {
|
||||
maxLevel = maxOf(maxLevel, profileLevel.level)
|
||||
}
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
return maxLevel
|
||||
}
|
||||
|
||||
private fun hasDecoder(mimeType: String, profile: Int, level: Int): Boolean {
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
val capabilities = info.getCapabilitiesForType(mimeType)
|
||||
for (profileLevel in capabilities.profileLevels) {
|
||||
if (profileLevel.profile != profile) continue
|
||||
if (profileLevel.level >= level) return true
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun hasCodecForMime(mimeType: String): Boolean {
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
if (info.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun supportsMultiInstance(mimeType: String): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
|
||||
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
if (!info.supportedTypes.contains(mimeType)) continue
|
||||
if (info.getCapabilitiesForType(mimeType).maxSupportedInstances > 1) {
|
||||
return true
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private val modelsWithDoviHdr10PlusBug = setOf(
|
||||
"AFTKRT",
|
||||
"AFTKA",
|
||||
"AFTKM",
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
internal data class HlsStabilityOptions(
|
||||
val segmentLengthSeconds: Int,
|
||||
val minSegments: Int,
|
||||
val breakOnNonKeyFrames: Boolean,
|
||||
val copyTimestamps: Boolean
|
||||
)
|
||||
|
||||
internal object HlsPlaybackStability {
|
||||
val conservativeHlsOptions = HlsStabilityOptions(
|
||||
segmentLengthSeconds = 6,
|
||||
minSegments = 3,
|
||||
breakOnNonKeyFrames = false,
|
||||
copyTimestamps = true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.CodecProfile
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.ContainerProfile
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileCondition
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionType
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||
|
||||
internal data class JellyfinAndroidMobileProfileConfig(
|
||||
val assDirectPlay: Boolean = false,
|
||||
)
|
||||
|
||||
internal object JellyfinAndroidMobileDeviceProfile {
|
||||
val fixedConfig = JellyfinAndroidMobileProfileConfig()
|
||||
|
||||
private const val profileName = "Purefin"
|
||||
private const val maxStreamingBitrate = 120_000_000
|
||||
private const val maxStaticBitrate = 100_000_000
|
||||
private const val maxMusicTranscodingBitrate = 384_000
|
||||
|
||||
private val supportedContainerFormats = arrayOf(
|
||||
"mp4", "fmp4", "webm", "mkv", "mp3", "ogg", "wav", "mpegts", "flv", "aac", "flac", "3gp",
|
||||
)
|
||||
|
||||
private val availableVideoCodecs = arrayOf(
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
|
||||
arrayOf("vp8", "vp9", "av1"),
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp8", "vp9"),
|
||||
emptyArray(),
|
||||
emptyArray(),
|
||||
emptyArray(),
|
||||
arrayOf("mpeg1video", "mpeg2video", "mpeg4", "h264", "hevc"),
|
||||
arrayOf("mpeg4", "h264"),
|
||||
emptyArray(),
|
||||
emptyArray(),
|
||||
arrayOf("h263", "mpeg4", "h264", "hevc"),
|
||||
)
|
||||
|
||||
private val pcmCodecs = arrayOf(
|
||||
"pcm_s8",
|
||||
"pcm_s16be",
|
||||
"pcm_s16le",
|
||||
"pcm_s24le",
|
||||
"pcm_s32le",
|
||||
"pcm_f32le",
|
||||
"pcm_alaw",
|
||||
"pcm_mulaw",
|
||||
)
|
||||
|
||||
private val availableAudioCodecs = arrayOf(
|
||||
arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3", "opus"),
|
||||
arrayOf("mp3", "aac", "ac3", "eac3"),
|
||||
arrayOf("vorbis", "opus"),
|
||||
arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts", "mlp", "truehd"),
|
||||
arrayOf("mp3"),
|
||||
arrayOf("vorbis", "opus", "flac"),
|
||||
pcmCodecs,
|
||||
arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "ac3", "eac3", "dts", "mlp", "truehd"),
|
||||
arrayOf("mp3", "aac"),
|
||||
arrayOf("aac"),
|
||||
arrayOf("flac"),
|
||||
arrayOf("3gpp", "aac", "flac"),
|
||||
)
|
||||
|
||||
private val forcedAudioCodecs = setOf(*pcmCodecs, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd")
|
||||
|
||||
private val exoEmbeddedSubtitles = arrayOf("dvbsub", "pgssub", "srt", "subrip", "ttml")
|
||||
private val exoExternalSubtitles = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt")
|
||||
private val ssaSubtitles = arrayOf("ssa", "ass")
|
||||
|
||||
private val transcodingProfiles = listOf(
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "ts",
|
||||
videoCodec = "h264",
|
||||
audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd",
|
||||
protocol = MediaStreamProtocol.HLS,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "mkv",
|
||||
videoCodec = "h264",
|
||||
audioCodec = availableAudioCodecs[supportedContainerFormats.indexOf("mkv")].joinToString(","),
|
||||
protocol = MediaStreamProtocol.HLS,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = "mp3",
|
||||
videoCodec = "",
|
||||
audioCodec = "mp3",
|
||||
protocol = MediaStreamProtocol.HTTP,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
fun create(
|
||||
capabilities: DeviceProfileCapabilities,
|
||||
config: JellyfinAndroidMobileProfileConfig = fixedConfig,
|
||||
): DeviceProfile {
|
||||
val containerProfiles = mutableListOf<ContainerProfile>()
|
||||
val directPlayProfiles = mutableListOf<DirectPlayProfile>()
|
||||
val codecProfiles = mutableListOf<CodecProfile>()
|
||||
|
||||
for (i in supportedContainerFormats.indices) {
|
||||
val container = supportedContainerFormats[i]
|
||||
val supportedVideoCodecs = availableVideoCodecs[i]
|
||||
.filter(capabilities::supportsVideoCodec)
|
||||
.toTypedArray()
|
||||
val supportedAudioCodecs = availableAudioCodecs[i]
|
||||
.filter { capabilities.supportsAudioCodec(it) || it in forcedAudioCodecs }
|
||||
.toTypedArray()
|
||||
|
||||
if (supportedVideoCodecs.isNotEmpty()) {
|
||||
containerProfiles += ContainerProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = container,
|
||||
conditions = emptyList(),
|
||||
)
|
||||
directPlayProfiles += DirectPlayProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = container,
|
||||
videoCodec = supportedVideoCodecs.joinToString(","),
|
||||
audioCodec = supportedAudioCodecs.joinToString(","),
|
||||
)
|
||||
supportedVideoCodecs.mapNotNullTo(codecProfiles) { videoCodec ->
|
||||
generateCodecProfile(container, videoCodec, capabilities.videoCodecProfiles(videoCodec))
|
||||
}
|
||||
}
|
||||
|
||||
if (supportedAudioCodecs.isNotEmpty()) {
|
||||
containerProfiles += ContainerProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = container,
|
||||
conditions = emptyList(),
|
||||
)
|
||||
directPlayProfiles += DirectPlayProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = container,
|
||||
audioCodec = supportedAudioCodecs.joinToString(","),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val subtitleProfiles = when {
|
||||
config.assDirectPlay -> subtitleProfiles(
|
||||
embedded = exoEmbeddedSubtitles + ssaSubtitles,
|
||||
external = exoExternalSubtitles + ssaSubtitles,
|
||||
)
|
||||
else -> subtitleProfiles(
|
||||
embedded = exoEmbeddedSubtitles,
|
||||
external = exoExternalSubtitles,
|
||||
)
|
||||
}
|
||||
|
||||
return DeviceProfile(
|
||||
name = profileName,
|
||||
directPlayProfiles = directPlayProfiles,
|
||||
transcodingProfiles = transcodingProfiles,
|
||||
containerProfiles = containerProfiles,
|
||||
codecProfiles = codecProfiles,
|
||||
subtitleProfiles = subtitleProfiles,
|
||||
maxStreamingBitrate = maxStreamingBitrate,
|
||||
maxStaticBitrate = maxStaticBitrate,
|
||||
musicStreamingTranscodingBitrate = maxMusicTranscodingBitrate,
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateCodecProfile(
|
||||
container: String,
|
||||
videoCodec: String,
|
||||
profiles: Set<String>,
|
||||
): CodecProfile? {
|
||||
if (profiles.isEmpty()) return null
|
||||
|
||||
return CodecProfile(
|
||||
type = CodecType.VIDEO,
|
||||
container = container,
|
||||
codec = videoCodec,
|
||||
applyConditions = emptyList(),
|
||||
conditions = listOf(
|
||||
ProfileCondition(
|
||||
condition = ProfileConditionType.EQUALS_ANY,
|
||||
property = ProfileConditionValue.VIDEO_PROFILE,
|
||||
value = profiles.joinToString("|"),
|
||||
isRequired = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun subtitleProfiles(
|
||||
embedded: Array<String>,
|
||||
external: Array<String>,
|
||||
): List<SubtitleProfile> = buildList {
|
||||
embedded.forEach { format ->
|
||||
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED))
|
||||
}
|
||||
external.forEach { format ->
|
||||
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder
|
||||
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
|
||||
|
||||
internal data class JellyfinAndroidTvProfileConfig(
|
||||
val maxBitrate: Int = 100_000_000,
|
||||
val isAc3Enabled: Boolean = true,
|
||||
val downMixAudio: Boolean = false,
|
||||
val assDirectPlay: Boolean = false,
|
||||
val pgsDirectPlay: Boolean = true,
|
||||
)
|
||||
|
||||
internal object JellyfinAndroidTvDeviceProfile {
|
||||
val fixedConfig = JellyfinAndroidTvProfileConfig()
|
||||
private val extendedRangeTypesServerVersion = ServerVersion(10, 11, 0)
|
||||
|
||||
private val downmixSupportedAudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.MP2,
|
||||
Codec.Audio.MP3,
|
||||
)
|
||||
|
||||
private val supportedAudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.AAC_LATM,
|
||||
Codec.Audio.AC3,
|
||||
Codec.Audio.ALAC,
|
||||
Codec.Audio.DCA,
|
||||
Codec.Audio.DTS,
|
||||
Codec.Audio.EAC3,
|
||||
Codec.Audio.FLAC,
|
||||
Codec.Audio.MLP,
|
||||
Codec.Audio.MP2,
|
||||
Codec.Audio.MP3,
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.PCM_ALAW,
|
||||
Codec.Audio.PCM_MULAW,
|
||||
Codec.Audio.PCM_S16LE,
|
||||
Codec.Audio.PCM_S20LE,
|
||||
Codec.Audio.PCM_S24LE,
|
||||
Codec.Audio.TRUEHD,
|
||||
Codec.Audio.VORBIS,
|
||||
)
|
||||
|
||||
private val hlsMpegTsAudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.AC3,
|
||||
Codec.Audio.EAC3,
|
||||
Codec.Audio.MP3,
|
||||
)
|
||||
|
||||
private val hlsFmp4AudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.AC3,
|
||||
Codec.Audio.EAC3,
|
||||
Codec.Audio.MP3,
|
||||
Codec.Audio.ALAC,
|
||||
Codec.Audio.FLAC,
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.DTS,
|
||||
Codec.Audio.TRUEHD,
|
||||
)
|
||||
|
||||
fun create(
|
||||
capabilities: DeviceProfileCapabilities,
|
||||
serverVersion: ServerVersion,
|
||||
config: JellyfinAndroidTvProfileConfig = fixedConfig,
|
||||
): DeviceProfile = buildDeviceProfile {
|
||||
val allowedAudioCodecs = when {
|
||||
config.downMixAudio -> downmixSupportedAudioCodecs
|
||||
!config.isAc3Enabled -> supportedAudioCodecs
|
||||
.filterNot { it == Codec.Audio.AC3 || it == Codec.Audio.EAC3 }
|
||||
.toTypedArray()
|
||||
else -> supportedAudioCodecs
|
||||
}
|
||||
|
||||
val supportsHevc = capabilities.supportsHevc()
|
||||
val supportsHevcMain10 = capabilities.supportsHevcMain10()
|
||||
val hevcMainLevel = capabilities.hevcMainLevel()
|
||||
val hevcMain10Level = capabilities.hevcMain10Level()
|
||||
val supportsAvc = capabilities.supportsAvc()
|
||||
val supportsAvcHigh10 = capabilities.supportsAvcHigh10()
|
||||
val avcMainLevel = capabilities.avcMainLevel()
|
||||
val avcHigh10Level = capabilities.avcHigh10Level()
|
||||
val supportsAv1 = capabilities.supportsAv1()
|
||||
val supportsAv1Main10 = capabilities.supportsAv1Main10()
|
||||
val supportsVc1 = capabilities.supportsVc1()
|
||||
val maxResolutionAvc = capabilities.maxResolution(MimeTypes.VIDEO_H264)
|
||||
val maxResolutionHevc = capabilities.maxResolution(MimeTypes.VIDEO_H265)
|
||||
val maxResolutionAv1 = capabilities.maxResolution(MimeTypes.VIDEO_AV1)
|
||||
val maxResolutionVc1 = capabilities.maxResolution(MimeTypes.VIDEO_VC1)
|
||||
|
||||
val supportsAv1DolbyVision = capabilities.supportsAv1DolbyVision()
|
||||
val supportsAv1Hdr10 = capabilities.supportsAv1Hdr10()
|
||||
val supportsAv1Hdr10Plus = capabilities.supportsAv1Hdr10Plus()
|
||||
val supportsHevcDolbyVision = capabilities.supportsHevcDolbyVision()
|
||||
val supportsHevcDolbyVisionEl = capabilities.supportsHevcDolbyVisionEl()
|
||||
val supportsHevcHdr10 = capabilities.supportsHevcHdr10()
|
||||
val supportsHevcHdr10Plus = capabilities.supportsHevcHdr10Plus()
|
||||
val supportsExtendedRangeTypes = serverVersion >= extendedRangeTypesServerVersion
|
||||
|
||||
name = "AndroidTV-Default"
|
||||
maxStaticBitrate = config.maxBitrate
|
||||
maxStreamingBitrate = config.maxBitrate
|
||||
|
||||
val hlsVideoCodecs = listOfNotNull(
|
||||
if (supportsHevc) Codec.Video.HEVC else null,
|
||||
Codec.Video.H264,
|
||||
).toTypedArray()
|
||||
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
container = Codec.Container.TS
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
videoCodec(*hlsVideoCodecs)
|
||||
audioCodec(*hlsMpegTsAudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray())
|
||||
copyTimestamps = false
|
||||
enableSubtitlesInManifest = true
|
||||
}
|
||||
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
container = Codec.Container.MP4
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
videoCodec(*hlsVideoCodecs)
|
||||
audioCodec(*hlsFmp4AudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray())
|
||||
copyTimestamps = false
|
||||
enableSubtitlesInManifest = true
|
||||
}
|
||||
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.AUDIO
|
||||
context = EncodingContext.STREAMING
|
||||
container = Codec.Container.TS
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
audioCodec(Codec.Audio.AAC)
|
||||
}
|
||||
|
||||
directPlayProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
container(
|
||||
Codec.Container.ASF,
|
||||
Codec.Container.HLS,
|
||||
Codec.Container.M4V,
|
||||
Codec.Container.MKV,
|
||||
Codec.Container.MOV,
|
||||
Codec.Container.MP4,
|
||||
Codec.Container.OGM,
|
||||
Codec.Container.OGV,
|
||||
Codec.Container.TS,
|
||||
Codec.Container.VOB,
|
||||
Codec.Container.WEBM,
|
||||
Codec.Container.WMV,
|
||||
Codec.Container.XVID,
|
||||
)
|
||||
videoCodec(
|
||||
Codec.Video.AV1,
|
||||
Codec.Video.H264,
|
||||
Codec.Video.HEVC,
|
||||
Codec.Video.MPEG,
|
||||
Codec.Video.MPEG2VIDEO,
|
||||
Codec.Video.VC1,
|
||||
Codec.Video.VP8,
|
||||
Codec.Video.VP9,
|
||||
)
|
||||
audioCodec(*allowedAudioCodecs)
|
||||
}
|
||||
|
||||
directPlayProfile {
|
||||
type = DlnaProfileType.AUDIO
|
||||
audioCodec(*allowedAudioCodecs)
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
when {
|
||||
!supportsAvc -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline",
|
||||
if (supportsAvcHigh10) "high 10" else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsAvc) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcMainLevel
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE inCollection listOf(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsAvcHigh10) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcHigh10Level
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE equals "high 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 12
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.WIDTH greaterThanOrEquals 1200
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 4
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.WIDTH greaterThanOrEquals 1900
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
when {
|
||||
!supportsHevc -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull(
|
||||
"main",
|
||||
if (supportsHevcMain10) "main 10" else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsHevc) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMainLevel
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE equals "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsHevcMain10) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMain10Level
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE equals "main 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.AV1
|
||||
conditions {
|
||||
when {
|
||||
!supportsAv1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
!supportsAv1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.VC1
|
||||
conditions {
|
||||
when {
|
||||
!supportsVc1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAvc.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAvc.height
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionHevc.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionHevc.height
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.AV1
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAv1.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAv1.height
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.VC1
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionVc1.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionVc1.height
|
||||
}
|
||||
}
|
||||
|
||||
val unsupportedRangeTypesAv1 = buildSet {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes)
|
||||
if (!supportsAv1DolbyVision) {
|
||||
add(VideoRangeType.DOVI)
|
||||
if (!supportsAv1Hdr10) add(VideoRangeType.DOVI_WITH_HDR10)
|
||||
if (!supportsAv1Hdr10Plus) addRangeTypeIfSupported(
|
||||
VideoRangeType.DOVI_WITH_HDR10_PLUS,
|
||||
supportsExtendedRangeTypes,
|
||||
)
|
||||
}
|
||||
if (!supportsAv1Hdr10Plus) {
|
||||
add(VideoRangeType.HDR10_PLUS)
|
||||
if (!supportsAv1Hdr10) add(VideoRangeType.HDR10)
|
||||
}
|
||||
}
|
||||
|
||||
val unsupportedRangeTypesHevc = buildSet {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes)
|
||||
if (!supportsHevcDolbyVisionEl) {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_EL, supportsExtendedRangeTypes)
|
||||
if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) {
|
||||
addRangeTypeIfSupported(
|
||||
VideoRangeType.DOVI_WITH_ELHDR10_PLUS,
|
||||
supportsExtendedRangeTypes,
|
||||
)
|
||||
}
|
||||
|
||||
if (!supportsHevcDolbyVision) {
|
||||
add(VideoRangeType.DOVI)
|
||||
if (!supportsHevcHdr10) add(VideoRangeType.DOVI_WITH_HDR10)
|
||||
if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) {
|
||||
addRangeTypeIfSupported(
|
||||
VideoRangeType.DOVI_WITH_HDR10_PLUS,
|
||||
supportsExtendedRangeTypes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!supportsHevcHdr10Plus) {
|
||||
add(VideoRangeType.HDR10_PLUS)
|
||||
if (!supportsHevcHdr10) add(VideoRangeType.HDR10)
|
||||
}
|
||||
|
||||
if (capabilities.hevcDoviHdr10PlusBug) {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_HDR10_PLUS, supportsExtendedRangeTypes)
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_ELHDR10_PLUS, supportsExtendedRangeTypes)
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedRangeTypesAv1.isNotEmpty()) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.AV1
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesAv1.joinToString("|") { it.serialName }
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesAv1.map { it.serialName }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedRangeTypesHevc.isNotEmpty()) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesHevc.joinToString("|") { it.serialName }
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesHevc.map { it.serialName }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO_AUDIO
|
||||
conditions {
|
||||
ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (config.downMixAudio) 2 else 8
|
||||
}
|
||||
}
|
||||
|
||||
subtitleProfile(Codec.Subtitle.VTT, embedded = true, hls = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.WEBVTT, embedded = true, hls = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.SRT, embedded = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.SUBRIP, embedded = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.TTML, embedded = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.DVBSUB, embedded = true, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.DVDSUB, embedded = true, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.IDX, embedded = true, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.PGS, embedded = config.pgsDirectPlay, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.PGSSUB, embedded = config.pgsDirectPlay, encode = true)
|
||||
subtitleProfile(
|
||||
Codec.Subtitle.ASS,
|
||||
encode = true,
|
||||
embedded = config.assDirectPlay,
|
||||
external = config.assDirectPlay,
|
||||
)
|
||||
subtitleProfile(
|
||||
Codec.Subtitle.SSA,
|
||||
encode = true,
|
||||
embedded = config.assDirectPlay,
|
||||
external = config.assDirectPlay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableSet<VideoRangeType>.addRangeTypeIfSupported(
|
||||
type: VideoRangeType,
|
||||
supportedByServer: Boolean,
|
||||
) {
|
||||
if (supportedByServer) add(type)
|
||||
}
|
||||
|
||||
private fun DeviceProfileBuilder.subtitleProfile(
|
||||
format: String,
|
||||
embedded: Boolean = false,
|
||||
external: Boolean = false,
|
||||
hls: Boolean = false,
|
||||
encode: Boolean = false,
|
||||
) {
|
||||
if (embedded) subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
|
||||
if (external) subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
|
||||
if (hls) subtitleProfile(format, SubtitleDeliveryMethod.HLS)
|
||||
if (encode) subtitleProfile(format, SubtitleDeliveryMethod.ENCODE)
|
||||
}
|
||||
|
||||
private object Codec {
|
||||
object Container {
|
||||
const val ASF = "asf"
|
||||
const val HLS = "hls"
|
||||
const val M4V = "m4v"
|
||||
const val MKV = "mkv"
|
||||
const val MOV = "mov"
|
||||
const val MP4 = "mp4"
|
||||
const val OGM = "ogm"
|
||||
const val OGV = "ogv"
|
||||
const val TS = "ts"
|
||||
const val VOB = "vob"
|
||||
const val WEBM = "webm"
|
||||
const val WMV = "wmv"
|
||||
const val XVID = "xvid"
|
||||
}
|
||||
|
||||
object Audio {
|
||||
const val AAC = "aac"
|
||||
const val AAC_LATM = "aac_latm"
|
||||
const val AC3 = "ac3"
|
||||
const val ALAC = "alac"
|
||||
const val DCA = "dca"
|
||||
const val DTS = "dts"
|
||||
const val EAC3 = "eac3"
|
||||
const val FLAC = "flac"
|
||||
const val MLP = "mlp"
|
||||
const val MP2 = "mp2"
|
||||
const val MP3 = "mp3"
|
||||
const val OPUS = "opus"
|
||||
const val PCM_ALAW = "pcm_alaw"
|
||||
const val PCM_MULAW = "pcm_mulaw"
|
||||
const val PCM_S16LE = "pcm_s16le"
|
||||
const val PCM_S20LE = "pcm_s20le"
|
||||
const val PCM_S24LE = "pcm_s24le"
|
||||
const val TRUEHD = "truehd"
|
||||
const val VORBIS = "vorbis"
|
||||
}
|
||||
|
||||
object Video {
|
||||
const val AV1 = "av1"
|
||||
const val H264 = "h264"
|
||||
const val HEVC = "hevc"
|
||||
const val MPEG = "mpeg"
|
||||
const val MPEG2VIDEO = "mpeg2video"
|
||||
const val VC1 = "vc1"
|
||||
const val VP8 = "vp8"
|
||||
const val VP9 = "vp9"
|
||||
}
|
||||
|
||||
object Subtitle {
|
||||
const val ASS = "ass"
|
||||
const val DVBSUB = "dvbsub"
|
||||
const val DVDSUB = "dvdsub"
|
||||
const val IDX = "idx"
|
||||
const val PGS = "pgs"
|
||||
const val PGSSUB = "pgssub"
|
||||
const val SRT = "srt"
|
||||
const val SSA = "ssa"
|
||||
const val SUBRIP = "subrip"
|
||||
const val TTML = "ttml"
|
||||
const val VTT = "vtt"
|
||||
const val WEBVTT = "webvtt"
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,10 @@ import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.api.operations.SystemApi
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
@@ -27,9 +29,7 @@ import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
@@ -39,6 +39,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -46,26 +47,15 @@ import javax.inject.Singleton
|
||||
class JellyfinApiClient @Inject constructor(
|
||||
@ApplicationContext private val applicationContext: Context,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val playbackProfilePolicy: PlaybackProfilePolicy,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "JellyfinApiClient"
|
||||
private const val MAX_STREAMING_BITRATE = 100_000_000
|
||||
|
||||
internal fun streamingHlsOptionsFor(
|
||||
urlStrategy: PlaybackSourcePlanner.UrlStrategy
|
||||
): HlsStabilityOptions? = when (urlStrategy) {
|
||||
PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> null
|
||||
PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM,
|
||||
PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> HlsPlaybackStability.conservativeHlsOptions
|
||||
}
|
||||
}
|
||||
|
||||
private val jellyfin = createJellyfin {
|
||||
context = applicationContext
|
||||
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
||||
}
|
||||
|
||||
private val api = jellyfin.createApi()
|
||||
private val serverVersionCache = ConcurrentHashMap<String, ServerVersion>()
|
||||
|
||||
private suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
|
||||
|
||||
@@ -283,43 +273,74 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
||||
requestPlaybackInfo(mediaId)?.mediaSources ?: emptyList()
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val result = api.mediaInfoApi
|
||||
.getPostedPlaybackInfo(
|
||||
mediaId,
|
||||
PlaybackInfoDto(
|
||||
userId = getUserId(),
|
||||
deviceProfile = null,
|
||||
maxStreamingBitrate = 100_000_000,
|
||||
),
|
||||
)
|
||||
Log.d("getMediaSources", result.toString())
|
||||
result.content.mediaSources
|
||||
}
|
||||
|
||||
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
|
||||
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
|
||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
||||
val playbackPlan = PlaybackSourcePlanner.plan(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
|
||||
val selectedMediaSource = playbackPlan.mediaSource
|
||||
suspend fun getPlaybackDecision(mediaId: UUID): PlaybackDecision? = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val url = resolvePlaybackUrl(
|
||||
mediaId = mediaId,
|
||||
mediaSource = selectedMediaSource,
|
||||
urlStrategy = playbackPlan.urlStrategy,
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
val serverVersion = getServerVersion(serverUrl)
|
||||
val deviceProfile = playbackProfilePolicy.create(serverVersion)
|
||||
|
||||
val response = api.mediaInfoApi.getPostedPlaybackInfo(
|
||||
mediaId,
|
||||
PlaybackInfoDto(
|
||||
userId = getUserId(),
|
||||
deviceProfile = deviceProfile,
|
||||
enableDirectPlay = true,
|
||||
enableDirectStream = true,
|
||||
enableTranscoding = true,
|
||||
allowVideoStreamCopy = true,
|
||||
allowAudioStreamCopy = true,
|
||||
autoOpenLiveStream = false,
|
||||
),
|
||||
)
|
||||
|
||||
val playbackInfo = response.content
|
||||
if (playbackInfo.errorCode != null) {
|
||||
Log.w(TAG, "Playback info failed for $mediaId with ${playbackInfo.errorCode}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val decision = PlaybackDecisionResolver.resolve(
|
||||
mediaSources = playbackInfo.mediaSources,
|
||||
playSessionId = playbackInfo.playSessionId,
|
||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||
) ?: return@withContext null
|
||||
|
||||
val reportContext = PlaybackReportContext(
|
||||
playMethod = playbackPlan.playMethod,
|
||||
mediaSourceId = selectedMediaSource.id,
|
||||
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
|
||||
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
|
||||
liveStreamId = selectedMediaSource.liveStreamId,
|
||||
serverUrl = serverUrl,
|
||||
directPlayUrl = { mediaSource ->
|
||||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = mediaId,
|
||||
container = mediaSource.container,
|
||||
mediaSourceId = mediaSource.id,
|
||||
static = true,
|
||||
tag = mediaSource.eTag,
|
||||
playSessionId = playbackInfo.playSessionId,
|
||||
canRetryWithTranscoding = playbackPlan.canRetryWithTranscoding
|
||||
liveStreamId = mediaSource.liveStreamId,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Playback decision for $mediaId: ${playbackPlan.playMethod} using ${selectedMediaSource.id}" +
|
||||
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
|
||||
)
|
||||
PlaybackDecision(
|
||||
url = url,
|
||||
mediaSource = selectedMediaSource,
|
||||
reportContext = reportContext
|
||||
)
|
||||
if (decision == null) {
|
||||
Log.w(TAG, "No compatible playback path for $mediaId")
|
||||
} else {
|
||||
Log.d(TAG, "Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}")
|
||||
}
|
||||
decision
|
||||
}
|
||||
|
||||
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
|
||||
@@ -327,26 +348,29 @@ class JellyfinApiClient @Inject constructor(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
||||
val shouldTranscode = mediaSource.supportsTranscoding == true &&
|
||||
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
|
||||
|
||||
val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null
|
||||
val url = resolvePlaybackUrl(
|
||||
mediaId = mediaId,
|
||||
mediaSource = playbackPlan.mediaSource,
|
||||
urlStrategy = playbackPlan.urlStrategy,
|
||||
playSessionId = null,
|
||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||
val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) {
|
||||
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
|
||||
"$baseUrl${mediaSource.transcodingUrl}"
|
||||
} else {
|
||||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = mediaId,
|
||||
static = true,
|
||||
mediaSourceId = mediaSource.id,
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" +
|
||||
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
|
||||
)
|
||||
Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url")
|
||||
url
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
|
||||
suspend fun reportPlaybackStart(
|
||||
itemId: UUID,
|
||||
positionTicks: Long = 0L,
|
||||
reportContext: PlaybackReportContext,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
@@ -362,7 +386,7 @@ class JellyfinApiClient @Inject constructor(
|
||||
playSessionId = reportContext.playSessionId,
|
||||
playMethod = reportContext.playMethod,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -371,7 +395,7 @@ class JellyfinApiClient @Inject constructor(
|
||||
itemId: UUID,
|
||||
positionTicks: Long,
|
||||
isPaused: Boolean,
|
||||
reportContext: PlaybackReportContext
|
||||
reportContext: PlaybackReportContext,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
@@ -388,12 +412,16 @@ class JellyfinApiClient @Inject constructor(
|
||||
playSessionId = reportContext.playSessionId,
|
||||
playMethod = reportContext.playMethod,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
|
||||
suspend fun reportPlaybackStopped(
|
||||
itemId: UUID,
|
||||
positionTicks: Long,
|
||||
reportContext: PlaybackReportContext,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
@@ -402,176 +430,27 @@ class JellyfinApiClient @Inject constructor(
|
||||
mediaSourceId = reportContext.mediaSourceId,
|
||||
liveStreamId = reportContext.liveStreamId,
|
||||
playSessionId = reportContext.playSessionId,
|
||||
failed = false
|
||||
failed = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun requestPlaybackInfo(mediaId: UUID, forceTranscode: Boolean = false): PlaybackInfoResponse? {
|
||||
if (!ensureConfigured()) {
|
||||
return null
|
||||
private suspend fun getServerVersion(serverUrl: String): ServerVersion {
|
||||
serverVersionCache[serverUrl]?.let { return it }
|
||||
|
||||
val parsedVersion = runCatching {
|
||||
val versionString = SystemApi(api).getPublicSystemInfo().content.version
|
||||
versionString?.let(ServerVersion::fromString)
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "Unable to fetch server version for $serverUrl", error)
|
||||
}.getOrNull()
|
||||
|
||||
val resolvedVersion = parsedVersion ?: PlaybackProfileDefaults.fallbackServerVersion
|
||||
serverVersionCache[serverUrl] = resolvedVersion
|
||||
return resolvedVersion
|
||||
}
|
||||
|
||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
||||
Log.d(
|
||||
TAG,
|
||||
"Playback request for $mediaId (forceTranscode=$forceTranscode) maxAudioChannels=${capabilitySnapshot.maxAudioChannels}, " +
|
||||
"hlsOptions=${HlsPlaybackStability.conservativeHlsOptions}"
|
||||
)
|
||||
val response = api.mediaInfoApi.getPostedPlaybackInfo(
|
||||
mediaId,
|
||||
PlaybackInfoDto(
|
||||
userId = getUserId(),
|
||||
deviceProfile = capabilitySnapshot.deviceProfile,
|
||||
maxStreamingBitrate = MAX_STREAMING_BITRATE,
|
||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels,
|
||||
enableDirectPlay = !forceTranscode,
|
||||
enableDirectStream = !forceTranscode,
|
||||
enableTranscoding = true,
|
||||
allowVideoStreamCopy = !forceTranscode,
|
||||
allowAudioStreamCopy = !forceTranscode,
|
||||
alwaysBurnInSubtitleWhenTranscoding = false
|
||||
)
|
||||
)
|
||||
|
||||
logPlaybackInfo(
|
||||
mediaId = mediaId,
|
||||
playbackInfo = response.content,
|
||||
forceTranscode = forceTranscode
|
||||
)
|
||||
return response.content
|
||||
}
|
||||
|
||||
private suspend fun resolvePlaybackUrl(
|
||||
mediaId: UUID,
|
||||
mediaSource: MediaSourceInfo,
|
||||
urlStrategy: PlaybackSourcePlanner.UrlStrategy,
|
||||
playSessionId: String?,
|
||||
maxAudioChannels: Int
|
||||
): String? {
|
||||
return when (urlStrategy) {
|
||||
PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> api.videosApi.getVideoStreamUrl(
|
||||
itemId = mediaId,
|
||||
static = true,
|
||||
mediaSourceId = mediaSource.id,
|
||||
playSessionId = playSessionId,
|
||||
liveStreamId = mediaSource.liveStreamId
|
||||
)
|
||||
|
||||
PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM -> resolveDirectStreamUrl(
|
||||
mediaId = mediaId,
|
||||
mediaSource = mediaSource,
|
||||
playSessionId = playSessionId,
|
||||
maxAudioChannels = maxAudioChannels
|
||||
)
|
||||
|
||||
PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> resolveTranscodeUrl(
|
||||
mediaId = mediaId,
|
||||
mediaSource = mediaSource,
|
||||
playSessionId = playSessionId,
|
||||
maxAudioChannels = maxAudioChannels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveDirectStreamUrl(
|
||||
mediaId: UUID,
|
||||
mediaSource: MediaSourceInfo,
|
||||
playSessionId: String?,
|
||||
maxAudioChannels: Int
|
||||
): String {
|
||||
val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM)
|
||||
?: error("Direct stream requests must use HLS stability options.")
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Stream URL request for $mediaId strategy=DIRECT_STREAM container=${mediaSource.transcodingContainer ?: mediaSource.container}, " +
|
||||
"hlsOptions=$hlsOptions"
|
||||
)
|
||||
return api.videosApi.getVideoStreamUrl(
|
||||
itemId = mediaId,
|
||||
static = false,
|
||||
mediaSourceId = mediaSource.id,
|
||||
playSessionId = playSessionId,
|
||||
liveStreamId = mediaSource.liveStreamId,
|
||||
container = mediaSource.transcodingContainer ?: mediaSource.container,
|
||||
segmentLength = hlsOptions.segmentLengthSeconds,
|
||||
minSegments = hlsOptions.minSegments,
|
||||
enableAutoStreamCopy = true,
|
||||
allowVideoStreamCopy = true,
|
||||
allowAudioStreamCopy = true,
|
||||
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
|
||||
copyTimestamps = hlsOptions.copyTimestamps,
|
||||
maxAudioChannels = maxAudioChannels
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun resolveTranscodeUrl(
|
||||
mediaId: UUID,
|
||||
mediaSource: MediaSourceInfo,
|
||||
playSessionId: String?,
|
||||
maxAudioChannels: Int
|
||||
): String? {
|
||||
val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE)
|
||||
?: error("Transcode requests must use HLS stability options.")
|
||||
|
||||
mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl ->
|
||||
Log.d(
|
||||
TAG,
|
||||
"Using server transcodingUrl for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container}, " +
|
||||
"hlsOptions=$hlsOptions"
|
||||
)
|
||||
if (transcodingUrl.startsWith("http", ignoreCase = true)) {
|
||||
return transcodingUrl
|
||||
}
|
||||
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
|
||||
return "$baseUrl$transcodingUrl"
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Stream URL request for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4"}, " +
|
||||
"hlsOptions=$hlsOptions"
|
||||
)
|
||||
return api.videosApi.getVideoStreamUrl(
|
||||
itemId = mediaId,
|
||||
static = false,
|
||||
mediaSourceId = mediaSource.id,
|
||||
playSessionId = playSessionId,
|
||||
liveStreamId = mediaSource.liveStreamId,
|
||||
container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4",
|
||||
segmentLength = hlsOptions.segmentLengthSeconds,
|
||||
minSegments = hlsOptions.minSegments,
|
||||
enableAutoStreamCopy = false,
|
||||
allowVideoStreamCopy = false,
|
||||
allowAudioStreamCopy = false,
|
||||
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
|
||||
copyTimestamps = hlsOptions.copyTimestamps,
|
||||
maxAudioChannels = maxAudioChannels
|
||||
)
|
||||
}
|
||||
|
||||
private fun logPlaybackInfo(
|
||||
mediaId: UUID,
|
||||
playbackInfo: PlaybackInfoResponse,
|
||||
forceTranscode: Boolean
|
||||
) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"PlaybackInfo for $mediaId (forceTranscode=$forceTranscode) -> " +
|
||||
"sources=${playbackInfo.mediaSources.size}, session=${playbackInfo.playSessionId}, error=${playbackInfo.errorCode}"
|
||||
)
|
||||
|
||||
playbackInfo.mediaSources.forEachIndexed { index, mediaSource ->
|
||||
Log.d(
|
||||
TAG,
|
||||
"PlaybackInfo[$index] id=${mediaSource.id}, container=${mediaSource.container}, " +
|
||||
"transcodingContainer=${mediaSource.transcodingContainer}, protocol=${mediaSource.protocol}, " +
|
||||
"supportsDirectPlay=${mediaSource.supportsDirectPlay}, " +
|
||||
"supportsDirectStream=${mediaSource.supportsDirectStream}, " +
|
||||
"supportsTranscoding=${mediaSource.supportsTranscoding}, " +
|
||||
"hasTranscodingUrl=${!mediaSource.transcodingUrl.isNullOrBlank()}"
|
||||
)
|
||||
}
|
||||
private companion object {
|
||||
private const val TAG = "JellyfinApiClient"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,4 @@ data class PlaybackReportContext(
|
||||
val subtitleStreamIndex: Int?,
|
||||
val liveStreamId: String?,
|
||||
val playSessionId: String?,
|
||||
val canRetryWithTranscoding: Boolean
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
internal object PlaybackDecisionResolver {
|
||||
fun resolve(
|
||||
mediaSources: List<MediaSourceInfo>,
|
||||
playSessionId: String?,
|
||||
serverUrl: String,
|
||||
directPlayUrl: (MediaSourceInfo) -> String,
|
||||
): PlaybackDecision? {
|
||||
val mediaSource = mediaSources.firstOrNull { it.protocol == MediaProtocol.FILE && !it.isRemote }
|
||||
?: return null
|
||||
|
||||
val playMethod = when {
|
||||
mediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
|
||||
mediaSource.supportsDirectStream && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.DIRECT_STREAM
|
||||
mediaSource.supportsTranscoding && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.TRANSCODE
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val url = when (playMethod) {
|
||||
PlayMethod.DIRECT_PLAY -> directPlayUrl(mediaSource)
|
||||
PlayMethod.DIRECT_STREAM,
|
||||
PlayMethod.TRANSCODE,
|
||||
-> absolutePlaybackUrl(serverUrl, requireNotNull(mediaSource.transcodingUrl))
|
||||
}
|
||||
|
||||
return PlaybackDecision(
|
||||
url = url,
|
||||
mediaSource = mediaSource,
|
||||
reportContext = PlaybackReportContext(
|
||||
playMethod = playMethod,
|
||||
mediaSourceId = mediaSource.id,
|
||||
audioStreamIndex = mediaSource.defaultAudioStreamIndex,
|
||||
subtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex,
|
||||
liveStreamId = mediaSource.liveStreamId,
|
||||
playSessionId = playSessionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun absolutePlaybackUrl(serverUrl: String, playbackUrl: String): String = when {
|
||||
playbackUrl.startsWith("http://", ignoreCase = true) -> playbackUrl
|
||||
playbackUrl.startsWith("https://", ignoreCase = true) -> playbackUrl
|
||||
playbackUrl.startsWith("/") -> "${serverUrl.trimEnd('/')}$playbackUrl"
|
||||
else -> "${serverUrl.trimEnd('/')}/${playbackUrl.trimStart('/')}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlaybackProfileModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeviceProfileCapabilities(): DeviceProfileCapabilities = AndroidDeviceProfileCapabilities()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaybackProfilePolicy(
|
||||
family: PlaybackProfileFamily,
|
||||
capabilities: DeviceProfileCapabilities,
|
||||
): PlaybackProfilePolicy = when (family) {
|
||||
PlaybackProfileFamily.MOBILE -> MobilePlaybackProfilePolicy(capabilities)
|
||||
PlaybackProfileFamily.TV -> TvPlaybackProfilePolicy(capabilities)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
|
||||
enum class PlaybackProfileFamily {
|
||||
MOBILE,
|
||||
TV,
|
||||
}
|
||||
|
||||
interface PlaybackProfilePolicy {
|
||||
fun create(serverVersion: ServerVersion): DeviceProfile
|
||||
}
|
||||
|
||||
internal object PlaybackProfileDefaults {
|
||||
val fallbackServerVersion = ServerVersion(10, 10, 0)
|
||||
}
|
||||
|
||||
internal class MobilePlaybackProfilePolicy(
|
||||
private val capabilities: DeviceProfileCapabilities,
|
||||
) : PlaybackProfilePolicy {
|
||||
override fun create(serverVersion: ServerVersion): DeviceProfile =
|
||||
JellyfinAndroidMobileDeviceProfile.create(capabilities = capabilities)
|
||||
}
|
||||
|
||||
internal class TvPlaybackProfilePolicy(
|
||||
private val capabilities: DeviceProfileCapabilities,
|
||||
) : PlaybackProfilePolicy {
|
||||
override fun create(serverVersion: ServerVersion): DeviceProfile =
|
||||
JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = capabilities,
|
||||
serverVersion = serverVersion,
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
internal object PlaybackSourcePlanner {
|
||||
data class Plan(
|
||||
val mediaSource: MediaSourceInfo,
|
||||
val urlStrategy: UrlStrategy,
|
||||
val playMethod: PlayMethod,
|
||||
val canRetryWithTranscoding: Boolean,
|
||||
val usedFallback: Boolean
|
||||
)
|
||||
|
||||
enum class UrlStrategy {
|
||||
DIRECT_PLAY,
|
||||
DIRECT_STREAM,
|
||||
TRANSCODE
|
||||
}
|
||||
|
||||
fun plan(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): Plan? {
|
||||
val selected = when {
|
||||
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
|
||||
PlannedSelection(
|
||||
mediaSource = mediaSource,
|
||||
urlStrategy = UrlStrategy.TRANSCODE,
|
||||
playMethod = PlayMethod.TRANSCODE
|
||||
)
|
||||
}
|
||||
|
||||
else -> mediaSources.firstOrNull { it.supportsDirectPlay }?.let { mediaSource ->
|
||||
PlannedSelection(
|
||||
mediaSource = mediaSource,
|
||||
urlStrategy = UrlStrategy.DIRECT_PLAY,
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
)
|
||||
} ?: mediaSources.firstOrNull { it.supportsDirectStream }?.let { mediaSource ->
|
||||
PlannedSelection(
|
||||
mediaSource = mediaSource,
|
||||
urlStrategy = UrlStrategy.DIRECT_STREAM,
|
||||
playMethod = PlayMethod.DIRECT_STREAM
|
||||
)
|
||||
} ?: mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
|
||||
PlannedSelection(
|
||||
mediaSource = mediaSource,
|
||||
urlStrategy = UrlStrategy.TRANSCODE,
|
||||
playMethod = PlayMethod.TRANSCODE
|
||||
)
|
||||
} ?: mediaSources.firstOrNull()?.let { mediaSource ->
|
||||
PlannedSelection(
|
||||
mediaSource = mediaSource,
|
||||
urlStrategy = UrlStrategy.DIRECT_STREAM,
|
||||
playMethod = PlayMethod.DIRECT_STREAM,
|
||||
usedFallback = true
|
||||
)
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return Plan(
|
||||
mediaSource = selected.mediaSource,
|
||||
urlStrategy = selected.urlStrategy,
|
||||
playMethod = selected.playMethod,
|
||||
canRetryWithTranscoding = !forceTranscode &&
|
||||
selected.playMethod != PlayMethod.TRANSCODE &&
|
||||
mediaSources.any { it.supportsTranscoding },
|
||||
usedFallback = selected.usedFallback
|
||||
)
|
||||
}
|
||||
|
||||
fun plan(mediaSource: MediaSourceInfo): Plan? = plan(listOf(mediaSource), forceTranscode = false)
|
||||
|
||||
private data class PlannedSelection(
|
||||
val mediaSource: MediaSourceInfo,
|
||||
val urlStrategy: UrlStrategy,
|
||||
val playMethod: PlayMethod,
|
||||
val usedFallback: Boolean = false
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,371 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileCondition
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionType
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidDeviceProfileTest {
|
||||
class JellyfinAndroidTvDeviceProfileTest {
|
||||
@Test
|
||||
fun `device profile includes explicit video transcoding support`() {
|
||||
val profile = AndroidDeviceProfile.createDeviceProfile(
|
||||
audioCodecs = listOf("aac", "ac3"),
|
||||
videoCodecs = listOf("h264", "hevc"),
|
||||
maxAudioChannels = 6
|
||||
fun `official defaults build expected profile structure`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
val transcodingProfile = profile.transcodingProfiles.single()
|
||||
assertEquals("AndroidTV-Default", profile.name)
|
||||
assertEquals(100_000_000, profile.maxStreamingBitrate)
|
||||
assertEquals(100_000_000, profile.maxStaticBitrate)
|
||||
assertEquals(3, profile.transcodingProfiles.size)
|
||||
|
||||
assertEquals(DlnaProfileType.VIDEO, transcodingProfile.type)
|
||||
assertEquals("ts", transcodingProfile.container)
|
||||
assertEquals("h264", transcodingProfile.videoCodec)
|
||||
assertEquals("aac", transcodingProfile.audioCodec)
|
||||
assertEquals(MediaStreamProtocol.HLS, transcodingProfile.protocol)
|
||||
assertEquals(EncodingContext.STREAMING, transcodingProfile.context)
|
||||
assertEquals("6", transcodingProfile.maxAudioChannels)
|
||||
assertEquals(3, transcodingProfile.minSegments)
|
||||
assertEquals(6, transcodingProfile.segmentLength)
|
||||
assertFalse(transcodingProfile.breakOnNonKeyFrames)
|
||||
assertTrue(transcodingProfile.copyTimestamps)
|
||||
val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO }
|
||||
assertEquals(setOf("mp4", "ts"), videoProfiles.map { it.container }.toSet())
|
||||
assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS })
|
||||
assertTrue(videoProfiles.all { it.enableSubtitlesInManifest })
|
||||
|
||||
val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO }
|
||||
assertEquals("ts", audioProfile.container)
|
||||
assertEquals("aac", audioProfile.audioCodec)
|
||||
|
||||
val videoDirectPlay = profile.directPlayProfiles.single { it.type == DlnaProfileType.VIDEO }
|
||||
assertTrue(videoDirectPlay.videoCodec.orEmpty().split(",").contains("vc1"))
|
||||
assertTrue(videoDirectPlay.audioCodec.orEmpty().split(",").contains("ac3"))
|
||||
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "pgs" && it.method == SubtitleDeliveryMethod.EMBED
|
||||
}
|
||||
)
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "webvtt" && it.method == SubtitleDeliveryMethod.HLS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ac3 disabled removes ac3 and eac3 from advertised codecs`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(isAc3Enabled = false),
|
||||
)
|
||||
|
||||
val directAudioCodecs = profile.directPlayProfiles
|
||||
.single { it.type == DlnaProfileType.AUDIO }
|
||||
.audioCodec
|
||||
.orEmpty()
|
||||
.split(",")
|
||||
|
||||
val transcodingAudioCodecs = profile.transcodingProfiles
|
||||
.filter { it.type == DlnaProfileType.VIDEO }
|
||||
.flatMap { it.audioCodec.orEmpty().split(",") }
|
||||
|
||||
assertFalse(directAudioCodecs.contains("ac3"))
|
||||
assertFalse(directAudioCodecs.contains("eac3"))
|
||||
assertFalse(transcodingAudioCodecs.contains("ac3"))
|
||||
assertFalse(transcodingAudioCodecs.contains("eac3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downmix mode limits audio codecs and channel profile`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(downMixAudio = true),
|
||||
)
|
||||
|
||||
val directAudioCodecs = profile.directPlayProfiles
|
||||
.single { it.type == DlnaProfileType.AUDIO }
|
||||
.audioCodec
|
||||
.orEmpty()
|
||||
.split(",")
|
||||
|
||||
assertEquals(listOf("aac", "mp2", "mp3"), directAudioCodecs)
|
||||
|
||||
val audioChannelProfile = profile.codecProfiles.single { it.type == CodecType.VIDEO_AUDIO }
|
||||
assertEquals("2", audioChannelProfile.conditions.single().value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older servers omit extended dolby vision range types`() {
|
||||
val capabilities = FakeDeviceProfileCapabilities(
|
||||
supportsAv1DolbyVision = false,
|
||||
supportsAv1Hdr10 = false,
|
||||
supportsAv1Hdr10Plus = false,
|
||||
supportsHevcDolbyVision = false,
|
||||
supportsHevcDolbyVisionEl = false,
|
||||
supportsHevcHdr10 = false,
|
||||
supportsHevcHdr10Plus = false,
|
||||
)
|
||||
|
||||
val olderServerProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = capabilities,
|
||||
serverVersion = ServerVersion(10, 10, 0),
|
||||
)
|
||||
val newerServerProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = capabilities,
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
val olderRangeTypes = codecRangeTypes(olderServerProfile.codecProfiles)
|
||||
val newerRangeTypes = codecRangeTypes(newerServerProfile.codecProfiles)
|
||||
|
||||
assertFalse(olderRangeTypes.contains("DOVIWithEL"))
|
||||
assertFalse(olderRangeTypes.contains("DOVIWithHDR10Plus"))
|
||||
assertFalse(olderRangeTypes.contains("DOVIWithELHDR10Plus"))
|
||||
assertFalse(olderRangeTypes.contains("DOVIInvalid"))
|
||||
|
||||
assertTrue(newerRangeTypes.contains("DOVIWithEL"))
|
||||
assertTrue(newerRangeTypes.contains("DOVIWithHDR10Plus"))
|
||||
assertTrue(newerRangeTypes.contains("DOVIWithELHDR10Plus"))
|
||||
assertTrue(newerRangeTypes.contains("DOVIInvalid"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h264 high 10 is advertised as high 10`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(supportsAvcHigh10 = true),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
val h264Values = profile.codecProfiles
|
||||
.filter { it.codec == "h264" }
|
||||
.flatMap { it.conditions + it.applyConditions }
|
||||
.mapNotNull(ProfileCondition::value)
|
||||
|
||||
assertTrue(h264Values.any { "high 10" in it })
|
||||
assertFalse(h264Values.any { "main 10" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vc1 support condition changes with codec support`() {
|
||||
val unsupportedProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(supportsVc1 = false),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
val supportedProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(supportsVc1 = true),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
val unsupportedCondition = vc1SupportCondition(unsupportedProfile.codecProfiles)
|
||||
val supportedCondition = vc1SupportCondition(supportedProfile.codecProfiles)
|
||||
|
||||
assertEquals(ProfileConditionType.EQUALS, unsupportedCondition.condition)
|
||||
assertEquals("none", unsupportedCondition.value)
|
||||
assertEquals(ProfileConditionType.NOT_EQUALS, supportedCondition.condition)
|
||||
assertEquals("none", supportedCondition.value)
|
||||
}
|
||||
}
|
||||
|
||||
class JellyfinAndroidMobileDeviceProfileTest {
|
||||
@Test
|
||||
fun `official mobile defaults build expected profile structure`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
)
|
||||
|
||||
assertEquals("Purefin", profile.name)
|
||||
assertEquals(120_000_000, profile.maxStreamingBitrate)
|
||||
assertEquals(100_000_000, profile.maxStaticBitrate)
|
||||
assertEquals(384_000, profile.musicStreamingTranscodingBitrate)
|
||||
|
||||
val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO }
|
||||
assertEquals(setOf("mkv", "ts"), videoProfiles.map { it.container }.toSet())
|
||||
assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS })
|
||||
|
||||
val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO }
|
||||
assertEquals("mp3", audioProfile.container)
|
||||
assertEquals(MediaStreamProtocol.HTTP, audioProfile.protocol)
|
||||
assertEquals("mp3", audioProfile.audioCodec)
|
||||
|
||||
assertTrue(
|
||||
profile.directPlayProfiles.any {
|
||||
it.type == DlnaProfileType.VIDEO && it.container == "mp4"
|
||||
}
|
||||
)
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "pgssub" && it.method == SubtitleDeliveryMethod.EMBED
|
||||
}
|
||||
)
|
||||
assertFalse(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "ass"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobile profile advertises forced audio codecs even without codec support`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(
|
||||
supportedAudioCodecs = emptySet(),
|
||||
supportedVideoCodecs = setOf("h264"),
|
||||
videoProfiles = mapOf("h264" to setOf("high")),
|
||||
),
|
||||
)
|
||||
|
||||
val mkvAudioProfile = profile.directPlayProfiles.single {
|
||||
it.type == DlnaProfileType.AUDIO && it.container == "mkv"
|
||||
}
|
||||
|
||||
val codecs = mkvAudioProfile.audioCodec.orEmpty().split(",")
|
||||
assertTrue(codecs.contains("aac"))
|
||||
assertTrue(codecs.contains("ac3"))
|
||||
assertTrue(codecs.contains("truehd"))
|
||||
assertTrue(codecs.contains("pcm_s16le"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobile ass direct play opt in adds ass and ssa subtitles`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
config = JellyfinAndroidMobileDeviceProfile.fixedConfig.copy(assDirectPlay = true),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "ass" && it.method == SubtitleDeliveryMethod.EMBED
|
||||
}
|
||||
)
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "ssa" && it.method == SubtitleDeliveryMethod.EXTERNAL
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobile profile omits tv hdr range conditions`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
profile.codecProfiles.any { codecProfile ->
|
||||
(codecProfile.conditions + codecProfile.applyConditions).any {
|
||||
it.property == ProfileConditionValue.VIDEO_RANGE_TYPE
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun codecRangeTypes(codecProfiles: List<org.jellyfin.sdk.model.api.CodecProfile>): Set<String> =
|
||||
codecProfiles
|
||||
.filter { it.propertyValuesContain(ProfileConditionValue.VIDEO_RANGE_TYPE) }
|
||||
.flatMap { (it.conditions + it.applyConditions).mapNotNull(ProfileCondition::value) }
|
||||
.flatMap { it.split("|") }
|
||||
.toSet()
|
||||
|
||||
private fun vc1SupportCondition(codecProfiles: List<org.jellyfin.sdk.model.api.CodecProfile>): ProfileCondition =
|
||||
codecProfiles
|
||||
.single {
|
||||
it.codec == "vc1" && it.conditions.any { condition ->
|
||||
condition.property == ProfileConditionValue.VIDEO_PROFILE
|
||||
}
|
||||
}
|
||||
.conditions
|
||||
.single()
|
||||
|
||||
private fun org.jellyfin.sdk.model.api.CodecProfile.propertyValuesContain(value: ProfileConditionValue): Boolean =
|
||||
(conditions + applyConditions).any { it.property == value }
|
||||
|
||||
private data class FakeDeviceProfileCapabilities(
|
||||
val supportsAv1: Boolean = true,
|
||||
val supportsAv1Main10: Boolean = true,
|
||||
val supportsAv1DolbyVision: Boolean = true,
|
||||
val supportsAv1Hdr10: Boolean = true,
|
||||
val supportsAv1Hdr10Plus: Boolean = true,
|
||||
val supportsAvc: Boolean = true,
|
||||
val supportsAvcHigh10: Boolean = false,
|
||||
val avcMainLevel: Int = 41,
|
||||
val avcHigh10Level: Int = 41,
|
||||
val supportsHevc: Boolean = true,
|
||||
val supportsHevcMain10: Boolean = true,
|
||||
val supportsHevcDolbyVision: Boolean = true,
|
||||
val supportsHevcDolbyVisionEl: Boolean = true,
|
||||
val supportsHevcHdr10: Boolean = true,
|
||||
val supportsHevcHdr10Plus: Boolean = true,
|
||||
val hevcMainLevel: Int = 123,
|
||||
val hevcMain10Level: Int = 123,
|
||||
val supportsVc1: Boolean = true,
|
||||
val maxResolutionAvc: ProfileResolution = ProfileResolution(3840, 2160),
|
||||
val maxResolutionHevc: ProfileResolution = ProfileResolution(3840, 2160),
|
||||
val maxResolutionAv1: ProfileResolution = ProfileResolution(3840, 2160),
|
||||
val maxResolutionVc1: ProfileResolution = ProfileResolution(1920, 1080),
|
||||
val supportedVideoCodecs: Set<String> = setOf(
|
||||
"h263",
|
||||
"mpeg2video",
|
||||
"mpeg4",
|
||||
"h264",
|
||||
"hevc",
|
||||
"vp8",
|
||||
"vp9",
|
||||
"av1",
|
||||
),
|
||||
val supportedAudioCodecs: Set<String> = setOf(
|
||||
"aac",
|
||||
"ac3",
|
||||
"eac3",
|
||||
"flac",
|
||||
"mp3",
|
||||
"opus",
|
||||
"vorbis",
|
||||
"3gpp",
|
||||
),
|
||||
val videoProfiles: Map<String, Set<String>> = mapOf(
|
||||
"h263" to setOf("baseline"),
|
||||
"mpeg2video" to setOf("main profile"),
|
||||
"mpeg4" to setOf("simple profile"),
|
||||
"h264" to setOf("high", "main", "baseline"),
|
||||
"hevc" to setOf("Main", "Main 10"),
|
||||
"vp8" to setOf("main"),
|
||||
"vp9" to setOf("Profile 0"),
|
||||
),
|
||||
override val hevcDoviHdr10PlusBug: Boolean = false,
|
||||
) : DeviceProfileCapabilities {
|
||||
override fun supportsAv1(): Boolean = supportsAv1
|
||||
override fun supportsAv1Main10(): Boolean = supportsAv1Main10
|
||||
override fun supportsAv1DolbyVision(): Boolean = supportsAv1DolbyVision
|
||||
override fun supportsAv1Hdr10(): Boolean = supportsAv1Hdr10
|
||||
override fun supportsAv1Hdr10Plus(): Boolean = supportsAv1Hdr10Plus
|
||||
override fun supportsAvc(): Boolean = supportsAvc
|
||||
override fun supportsAvcHigh10(): Boolean = supportsAvcHigh10
|
||||
override fun avcMainLevel(): Int = avcMainLevel
|
||||
override fun avcHigh10Level(): Int = avcHigh10Level
|
||||
override fun supportsHevc(): Boolean = supportsHevc
|
||||
override fun supportsHevcMain10(): Boolean = supportsHevcMain10
|
||||
override fun supportsHevcDolbyVision(): Boolean = supportsHevcDolbyVision
|
||||
override fun supportsHevcDolbyVisionEl(): Boolean = supportsHevcDolbyVisionEl
|
||||
override fun supportsHevcHdr10(): Boolean = supportsHevcHdr10
|
||||
override fun supportsHevcHdr10Plus(): Boolean = supportsHevcHdr10Plus
|
||||
override fun hevcMainLevel(): Int = hevcMainLevel
|
||||
override fun hevcMain10Level(): Int = hevcMain10Level
|
||||
override fun supportsVc1(): Boolean = supportsVc1
|
||||
|
||||
override fun maxResolution(mimeType: String): ProfileResolution = when (mimeType) {
|
||||
MimeTypes.VIDEO_H264 -> maxResolutionAvc
|
||||
MimeTypes.VIDEO_H265 -> maxResolutionHevc
|
||||
MimeTypes.VIDEO_AV1 -> maxResolutionAv1
|
||||
MimeTypes.VIDEO_VC1 -> maxResolutionVc1
|
||||
else -> ProfileResolution(0, 0)
|
||||
}
|
||||
|
||||
override fun supportsVideoCodec(codec: String): Boolean = supportedVideoCodecs.contains(codec)
|
||||
|
||||
override fun supportsAudioCodec(codec: String): Boolean = supportedAudioCodecs.contains(codec)
|
||||
|
||||
override fun videoCodecProfiles(codec: String): Set<String> = videoProfiles[codec].orEmpty()
|
||||
}
|
||||
|
||||
@@ -1,29 +1,151 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaSourceType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class JellyfinApiClientTest {
|
||||
class PlaybackDecisionResolverTest {
|
||||
@Test
|
||||
fun `direct play requests do not apply HLS stability options`() {
|
||||
val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY)
|
||||
fun `resolve prefers local file-backed source for direct play`() {
|
||||
val remoteSource = mediaSource(
|
||||
id = "remote",
|
||||
protocol = MediaProtocol.HTTP,
|
||||
isRemote = true,
|
||||
supportsDirectPlay = true,
|
||||
)
|
||||
val localSource = mediaSource(
|
||||
id = "local",
|
||||
supportsDirectPlay = true,
|
||||
defaultAudioStreamIndex = 2,
|
||||
defaultSubtitleStreamIndex = 4,
|
||||
liveStreamId = "live-1",
|
||||
)
|
||||
|
||||
assertNull(options)
|
||||
val decision = requireNotNull(
|
||||
PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(remoteSource, localSource),
|
||||
playSessionId = "session-1",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { mediaSource ->
|
||||
"direct://${mediaSource.id}"
|
||||
}
|
||||
)
|
||||
|
||||
assertSame(localSource, decision.mediaSource)
|
||||
assertEquals("direct://local", decision.url)
|
||||
assertEquals(PlayMethod.DIRECT_PLAY, decision.reportContext.playMethod)
|
||||
assertEquals("local", decision.reportContext.mediaSourceId)
|
||||
assertEquals(2, decision.reportContext.audioStreamIndex)
|
||||
assertEquals(4, decision.reportContext.subtitleStreamIndex)
|
||||
assertEquals("live-1", decision.reportContext.liveStreamId)
|
||||
assertEquals("session-1", decision.reportContext.playSessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct stream requests use conservative HLS stability options`() {
|
||||
val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM)
|
||||
fun `resolve uses transcoding url for direct stream`() {
|
||||
val source = mediaSource(
|
||||
id = "direct-stream",
|
||||
supportsDirectStream = true,
|
||||
transcodingUrl = "/Videos/1/master.m3u8",
|
||||
)
|
||||
|
||||
assertEquals(HlsPlaybackStability.conservativeHlsOptions, options)
|
||||
val decision = requireNotNull(
|
||||
PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(source),
|
||||
playSessionId = "session-2",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { error("direct play should not be used") }
|
||||
)
|
||||
|
||||
assertEquals(PlayMethod.DIRECT_STREAM, decision.reportContext.playMethod)
|
||||
assertEquals("https://example.com/jellyfin/Videos/1/master.m3u8", decision.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transcode requests use the same HLS stability options as direct stream`() {
|
||||
val directStreamOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM)
|
||||
val transcodeOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE)
|
||||
fun `resolve uses transcoding when direct stream is unavailable`() {
|
||||
val source = mediaSource(
|
||||
id = "transcode",
|
||||
supportsTranscoding = true,
|
||||
transcodingUrl = "Videos/2/master.m3u8",
|
||||
)
|
||||
|
||||
assertEquals(directStreamOptions, transcodeOptions)
|
||||
val decision = requireNotNull(
|
||||
PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(source),
|
||||
playSessionId = "session-3",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { error("direct play should not be used") }
|
||||
)
|
||||
|
||||
assertEquals(PlayMethod.TRANSCODE, decision.reportContext.playMethod)
|
||||
assertEquals("https://example.com/jellyfin/Videos/2/master.m3u8", decision.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns null when no usable source exists`() {
|
||||
val source = mediaSource(id = "unusable")
|
||||
|
||||
val decision = PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(source),
|
||||
playSessionId = "session-4",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { error("direct play should not be used") }
|
||||
|
||||
assertNull(decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absolute playback url preserves absolute urls`() {
|
||||
val url = PlaybackDecisionResolver.absolutePlaybackUrl(
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
playbackUrl = "https://cdn.example.com/master.m3u8",
|
||||
)
|
||||
|
||||
assertEquals("https://cdn.example.com/master.m3u8", url)
|
||||
}
|
||||
|
||||
private fun mediaSource(
|
||||
id: String,
|
||||
protocol: MediaProtocol = MediaProtocol.FILE,
|
||||
isRemote: Boolean = false,
|
||||
supportsDirectPlay: Boolean = false,
|
||||
supportsDirectStream: Boolean = false,
|
||||
supportsTranscoding: Boolean = false,
|
||||
transcodingUrl: String? = null,
|
||||
defaultAudioStreamIndex: Int? = null,
|
||||
defaultSubtitleStreamIndex: Int? = null,
|
||||
liveStreamId: String? = null,
|
||||
): MediaSourceInfo {
|
||||
return MediaSourceInfo(
|
||||
protocol = protocol,
|
||||
id = id,
|
||||
type = MediaSourceType.DEFAULT,
|
||||
container = "mkv",
|
||||
isRemote = isRemote,
|
||||
readAtNativeFramerate = true,
|
||||
ignoreDts = false,
|
||||
ignoreIndex = false,
|
||||
genPtsInput = false,
|
||||
supportsTranscoding = supportsTranscoding,
|
||||
supportsDirectStream = supportsDirectStream,
|
||||
supportsDirectPlay = supportsDirectPlay,
|
||||
isInfiniteStream = false,
|
||||
requiresOpening = false,
|
||||
requiresClosing = false,
|
||||
liveStreamId = liveStreamId,
|
||||
requiresLooping = false,
|
||||
supportsProbing = true,
|
||||
transcodingUrl = transcodingUrl,
|
||||
defaultAudioStreamIndex = defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex = defaultSubtitleStreamIndex,
|
||||
transcodingSubProtocol = MediaStreamProtocol.HLS,
|
||||
hasSegments = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaSourceType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackSourcePlannerTest {
|
||||
@Test
|
||||
fun `transcode-only source is selected for playback`() {
|
||||
val mediaSource = mediaSource(id = "transcode-only", supportsTranscoding = true)
|
||||
|
||||
val plan = requireNotNull(
|
||||
PlaybackSourcePlanner.plan(
|
||||
mediaSources = listOf(mediaSource),
|
||||
forceTranscode = false
|
||||
)
|
||||
)
|
||||
|
||||
assertSame(mediaSource, plan.mediaSource)
|
||||
assertEquals(PlaybackSourcePlanner.UrlStrategy.TRANSCODE, plan.urlStrategy)
|
||||
assertEquals(PlayMethod.TRANSCODE, plan.playMethod)
|
||||
assertFalse(plan.canRetryWithTranscoding)
|
||||
assertFalse(plan.usedFallback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first source falls back to direct stream when Jellyfin flags are inconsistent`() {
|
||||
val mediaSource = mediaSource(id = "flagless")
|
||||
|
||||
val plan = requireNotNull(
|
||||
PlaybackSourcePlanner.plan(
|
||||
mediaSources = listOf(mediaSource),
|
||||
forceTranscode = false
|
||||
)
|
||||
)
|
||||
|
||||
assertSame(mediaSource, plan.mediaSource)
|
||||
assertEquals(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM, plan.urlStrategy)
|
||||
assertEquals(PlayMethod.DIRECT_STREAM, plan.playMethod)
|
||||
assertFalse(plan.canRetryWithTranscoding)
|
||||
assertTrue(plan.usedFallback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry with transcoding stays enabled when another source can transcode`() {
|
||||
val directPlaySource = mediaSource(id = "direct-play", supportsDirectPlay = true)
|
||||
val transcodeSource = mediaSource(id = "transcode", supportsTranscoding = true)
|
||||
|
||||
val plan = requireNotNull(
|
||||
PlaybackSourcePlanner.plan(
|
||||
mediaSources = listOf(directPlaySource, transcodeSource),
|
||||
forceTranscode = false
|
||||
)
|
||||
)
|
||||
|
||||
assertSame(directPlaySource, plan.mediaSource)
|
||||
assertEquals(PlayMethod.DIRECT_PLAY, plan.playMethod)
|
||||
assertTrue(plan.canRetryWithTranscoding)
|
||||
assertFalse(plan.usedFallback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `force transcode does not fall back to unusable source`() {
|
||||
val mediaSource = mediaSource(id = "flagless")
|
||||
|
||||
val plan = PlaybackSourcePlanner.plan(
|
||||
mediaSources = listOf(mediaSource),
|
||||
forceTranscode = true
|
||||
)
|
||||
|
||||
assertNull(plan)
|
||||
}
|
||||
|
||||
private fun mediaSource(
|
||||
id: String,
|
||||
supportsDirectPlay: Boolean = false,
|
||||
supportsDirectStream: Boolean = false,
|
||||
supportsTranscoding: Boolean = false,
|
||||
container: String? = "mkv"
|
||||
): MediaSourceInfo {
|
||||
return MediaSourceInfo(
|
||||
protocol = MediaProtocol.FILE,
|
||||
id = id,
|
||||
type = MediaSourceType.DEFAULT,
|
||||
container = container,
|
||||
isRemote = false,
|
||||
readAtNativeFramerate = true,
|
||||
ignoreDts = false,
|
||||
ignoreIndex = false,
|
||||
genPtsInput = false,
|
||||
supportsTranscoding = supportsTranscoding,
|
||||
supportsDirectStream = supportsDirectStream,
|
||||
supportsDirectPlay = supportsDirectPlay,
|
||||
isInfiniteStream = false,
|
||||
requiresOpening = false,
|
||||
requiresClosing = false,
|
||||
requiresLooping = false,
|
||||
supportsProbing = true,
|
||||
transcodingSubProtocol = MediaStreamProtocol.HTTP,
|
||||
hasSegments = false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,9 @@ dependencies {
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.medi3.exoplayer)
|
||||
implementation(libs.medi3.exoplayer.hls)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.datastore)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.okhttp)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.data
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
|
||||
sealed interface PlayerMediaLoadResult {
|
||||
data class Success(
|
||||
val mediaItem: MediaItem,
|
||||
val resumePositionMs: Long?
|
||||
) : PlayerMediaLoadResult
|
||||
|
||||
data class Failure(
|
||||
val error: PlayerError
|
||||
) : PlayerMediaLoadResult
|
||||
}
|
||||
@@ -1,68 +1,96 @@
|
||||
package hu.bbara.purefin.core.player.data
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.MediaRepository
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@ViewModelScoped
|
||||
class PlayerMediaRepository @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val downloadManager: DownloadManager
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "PlayerMediaRepo"
|
||||
|
||||
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(mediaId) ?: return@withContext null
|
||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = playbackDecision.url,
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
|
||||
subtitle = seasonEpisodeLabel(baseItem),
|
||||
artworkUrl = artworkUrl,
|
||||
playbackReportContext = playbackDecision.reportContext,
|
||||
)
|
||||
|
||||
Pair(mediaItem, resumePositionMs)
|
||||
}
|
||||
|
||||
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
|
||||
when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) {
|
||||
is PlayerMediaLoadResult.Success -> onlineResult
|
||||
is PlayerMediaLoadResult.Failure -> {
|
||||
when (val offlineResult = buildOfflineMediaItem(mediaId)) {
|
||||
is OfflineLoadResult.Success -> {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}"
|
||||
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
|
||||
episodes.mapNotNull { episode ->
|
||||
val id = episode.id ?: return@mapNotNull null
|
||||
val stringId = id.toString()
|
||||
if (existingIds.contains(stringId)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||
createMediaItem(
|
||||
mediaId = stringId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
|
||||
subtitle = seasonEpisodeLabel(episode),
|
||||
artworkUrl = artworkUrl,
|
||||
playbackReportContext = playbackDecision.reportContext,
|
||||
)
|
||||
offlineResult.result
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
is OfflineLoadResult.Unavailable -> {
|
||||
val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail)
|
||||
Log.w(
|
||||
TAG,
|
||||
"Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}"
|
||||
)
|
||||
PlayerMediaLoadResult.Failure(finalError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun createMediaItem(
|
||||
mediaId: String,
|
||||
playbackUrl: String,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
playbackReportContext: PlaybackReportContext?,
|
||||
): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setArtworkUri(artworkUrl.toUri())
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(playbackUrl.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setTag(playbackReportContext)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
@@ -89,224 +117,9 @@ class PlayerMediaRepository @Inject constructor(
|
||||
return if (percentage in 5.0..95.0) positionMs else null
|
||||
}
|
||||
|
||||
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
|
||||
episodes.mapNotNull { episode ->
|
||||
val id = episode.id ?: return@mapNotNull null
|
||||
val stringId = id.toString()
|
||||
if (existingIds.contains(stringId)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
|
||||
val selectedMediaSource = playbackDecision.mediaSource
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
|
||||
createMediaItem(
|
||||
mediaId = stringId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
title = episode.name ?: selectedMediaSource.name!!,
|
||||
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
|
||||
artworkUrl = artworkUrl,
|
||||
subtitleConfigurations = subtitleConfigs,
|
||||
tag = playbackDecision.reportContext
|
||||
)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load next-up items for $episodeId", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
|
||||
return try {
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
|
||||
mediaId = mediaId,
|
||||
forceTranscode = forceTranscode
|
||||
) ?: run {
|
||||
val detail = if (forceTranscode) {
|
||||
"Jellyfin did not return a transcoded playback source."
|
||||
} else {
|
||||
"Jellyfin did not return a playable media source."
|
||||
}
|
||||
Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)")
|
||||
return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail))
|
||||
}
|
||||
val selectedMediaSource = playbackDecision.mediaSource
|
||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
|
||||
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource)
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = playbackDecision.url,
|
||||
title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
|
||||
subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
|
||||
artworkUrl = artworkUrl,
|
||||
subtitleConfigurations = subtitleConfigs,
|
||||
tag = playbackDecision.reportContext
|
||||
)
|
||||
|
||||
PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
|
||||
PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
|
||||
return try {
|
||||
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
|
||||
?.takeIf { it.state == Download.STATE_COMPLETED }
|
||||
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val movie = mediaRepository.movies.value[mediaId]
|
||||
val episode = mediaRepository.episodes.value[mediaId]
|
||||
|
||||
val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank {
|
||||
"Offline media"
|
||||
}
|
||||
val subtitle = episode?.let { episodeSubtitle(null, it.index) }
|
||||
val artworkUrl = when {
|
||||
movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY)
|
||||
episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY)
|
||||
else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
|
||||
}
|
||||
val resumePositionMs = resumePositionFor(movie, episode)
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = download.request.uri.toString(),
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
artworkUrl = artworkUrl,
|
||||
)
|
||||
OfflineLoadResult.Success(PlayerMediaLoadResult.Success(mediaItem, resumePositionMs))
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Offline fallback failed for $mediaId", error)
|
||||
val technicalDetail = PlayerError.fromThrowable(error).detailText ?: error.javaClass.simpleName
|
||||
OfflineLoadResult.Unavailable(
|
||||
"Offline fallback failed: $technicalDetail"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? {
|
||||
val progress = movie?.progress ?: episode?.progress ?: return null
|
||||
val runtime = movie?.runtime ?: episode?.runtime ?: return null
|
||||
val durationMs = parseRuntimeToMs(runtime) ?: return null
|
||||
if (durationMs <= 0L) return null
|
||||
return (durationMs * (progress / 100.0)).toLong().takeIf { it > 0L }
|
||||
}
|
||||
|
||||
private fun parseRuntimeToMs(runtime: String): Long? {
|
||||
val trimmed = runtime.trim()
|
||||
if (trimmed.isBlank() || trimmed == "—") return null
|
||||
|
||||
val hourMatch = Regex("(\\d+)h").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
|
||||
val minuteMatch = Regex("(\\d+)m").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
|
||||
val totalMinutes = hourMatch * 60L + minuteMatch
|
||||
return if (totalMinutes > 0L) totalMinutes * 60_000L else null
|
||||
}
|
||||
|
||||
private fun episodeSubtitle(seasonNumber: Int?, episodeNumber: Int?): String? {
|
||||
if (seasonNumber == null && episodeNumber == null) return null
|
||||
return buildString {
|
||||
if (seasonNumber != null) append("S").append(seasonNumber)
|
||||
if (episodeNumber != null) {
|
||||
if (isNotEmpty()) append(":")
|
||||
append("E").append(episodeNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun buildExternalSubtitleConfigs(
|
||||
serverUrl: String,
|
||||
mediaId: UUID,
|
||||
mediaSource: MediaSourceInfo
|
||||
): List<MediaItem.SubtitleConfiguration> {
|
||||
val streams = mediaSource.mediaStreams ?: return emptyList()
|
||||
val mediaSourceId = mediaSource.id ?: return emptyList()
|
||||
val baseUrl = serverUrl.trimEnd('/')
|
||||
|
||||
return streams
|
||||
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL }
|
||||
.mapNotNull { stream ->
|
||||
val codec = stream.codec ?: return@mapNotNull null
|
||||
val mimeType = subtitleCodecToMimeType(codec) ?: return@mapNotNull null
|
||||
// Use deliveryUrl from server if available, otherwise construct it
|
||||
val url = if (!stream.deliveryUrl.isNullOrBlank()) {
|
||||
if (stream.deliveryUrl!!.startsWith("http")) {
|
||||
stream.deliveryUrl!!
|
||||
} else {
|
||||
"$baseUrl${stream.deliveryUrl}"
|
||||
}
|
||||
} else {
|
||||
val format = if (codec == "subrip") "srt" else codec
|
||||
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
|
||||
}
|
||||
|
||||
Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url")
|
||||
|
||||
MediaItem.SubtitleConfiguration.Builder(url.toUri())
|
||||
.setMimeType(mimeType)
|
||||
.setLanguage(stream.language)
|
||||
.setLabel(stream.displayTitle ?: stream.language ?: "Track ${stream.index}")
|
||||
.setSelectionFlags(
|
||||
if (stream.isForced) C.SELECTION_FLAG_FORCED
|
||||
else if (stream.isDefault) C.SELECTION_FLAG_DEFAULT
|
||||
else 0
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun subtitleCodecToMimeType(codec: String): String? = when (codec.lowercase()) {
|
||||
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||
"ass", "ssa" -> MimeTypes.TEXT_SSA
|
||||
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
|
||||
"ttml", "dfxp" -> MimeTypes.APPLICATION_TTML
|
||||
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
|
||||
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown subtitle codec: $codec")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaItem(
|
||||
mediaId: String,
|
||||
playbackUrl: String,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
|
||||
tag: PlaybackReportContext? = null
|
||||
): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setArtworkUri(artworkUrl.toUri())
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(playbackUrl.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setTag(tag)
|
||||
.setSubtitleConfigurations(subtitleConfigurations)
|
||||
.build()
|
||||
}
|
||||
|
||||
private sealed interface OfflineLoadResult {
|
||||
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
|
||||
data class Unavailable(val detail: String) : OfflineLoadResult
|
||||
private fun seasonEpisodeLabel(item: BaseItemDto?): String? {
|
||||
val seasonNumber = item?.parentIndexNumber ?: return null
|
||||
val episodeNumber = item.indexNumber ?: return null
|
||||
return "S$seasonNumber:E$episodeNumber"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.manager
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
internal class PendingSeekTracker(
|
||||
private val settleToleranceMs: Long = 500L
|
||||
) {
|
||||
private var pendingSeek: PendingSeek? = null
|
||||
|
||||
fun currentPosition(playerPositionMs: Long): Long {
|
||||
val pending = pendingSeek ?: return playerPositionMs
|
||||
if (pending.isSatisfiedBy(playerPositionMs, settleToleranceMs)) {
|
||||
pendingSeek = null
|
||||
return playerPositionMs
|
||||
}
|
||||
return pending.targetPositionMs
|
||||
}
|
||||
|
||||
fun recordSeek(basePositionMs: Long, targetPositionMs: Long): Long {
|
||||
val target = targetPositionMs.coerceAtLeast(0L)
|
||||
pendingSeek = PendingSeek(
|
||||
targetPositionMs = target,
|
||||
direction = target.compareTo(basePositionMs)
|
||||
)
|
||||
return target
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
pendingSeek = null
|
||||
}
|
||||
|
||||
private data class PendingSeek(
|
||||
val targetPositionMs: Long,
|
||||
val direction: Int
|
||||
) {
|
||||
fun isSatisfiedBy(playerPositionMs: Long, settleToleranceMs: Long): Boolean =
|
||||
when {
|
||||
direction > 0 -> playerPositionMs >= targetPositionMs - settleToleranceMs
|
||||
direction < 0 -> playerPositionMs <= targetPositionMs + settleToleranceMs
|
||||
else -> abs(playerPositionMs - targetPositionMs) <= settleToleranceMs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
@@ -31,6 +30,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Encapsulates the Media3 [Player] wiring and exposes reactive updates for the UI layer.
|
||||
@@ -43,11 +43,14 @@ class PlayerManager @Inject constructor(
|
||||
private val trackPreferencesRepository: TrackPreferencesRepository,
|
||||
private val trackMatcher: TrackMatcher
|
||||
) {
|
||||
companion object {
|
||||
private const val SEEK_SETTLE_TOLERANCE_MS = 750L
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val pendingSeekTracker = PendingSeekTracker()
|
||||
|
||||
private var currentMediaContext: MediaContext? = null
|
||||
private var pendingSeekPositionMs: Long? = null
|
||||
|
||||
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
|
||||
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
|
||||
@@ -83,11 +86,7 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_playbackState.update {
|
||||
it.copy(
|
||||
error = PlayerError.fromPlaybackException(error)
|
||||
)
|
||||
}
|
||||
_playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
@@ -98,7 +97,7 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
pendingSeekTracker.clear()
|
||||
clearPendingSeek()
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
}
|
||||
@@ -108,11 +107,7 @@ class PlayerManager @Inject constructor(
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
if (reason == Player.DISCONTINUITY_REASON_SEEK &&
|
||||
newPosition.positionMs < oldPosition.positionMs
|
||||
) {
|
||||
refreshSubtitleRendererOnBackwardSeek()
|
||||
}
|
||||
syncPendingSeek(newPosition.positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,36 +119,10 @@ class PlayerManager @Inject constructor(
|
||||
startProgressLoop()
|
||||
}
|
||||
|
||||
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
||||
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
|
||||
currentMediaContext = mediaContext
|
||||
pendingSeekTracker.clear()
|
||||
if (startPositionMs != null) {
|
||||
player.setMediaItem(mediaItem, startPositionMs)
|
||||
} else {
|
||||
clearPendingSeek()
|
||||
player.setMediaItem(mediaItem)
|
||||
}
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
_progress.value = PlaybackProgressSnapshot()
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
_playbackState.update { it.copy(isEnded = false, error = null) }
|
||||
}
|
||||
|
||||
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
||||
currentMediaContext = mediaContext
|
||||
pendingSeekTracker.clear()
|
||||
val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run {
|
||||
play(mediaItem, mediaContext, startPositionMs)
|
||||
return
|
||||
}
|
||||
|
||||
player.replaceMediaItem(currentIndex, mediaItem)
|
||||
if (startPositionMs != null) {
|
||||
player.seekTo(currentIndex, startPositionMs)
|
||||
} else {
|
||||
player.seekToDefaultPosition(currentIndex)
|
||||
}
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
refreshMetadata(mediaItem)
|
||||
@@ -171,32 +140,42 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
requestSeek(positionMs)
|
||||
val target = clampSeekPosition(positionMs)
|
||||
pendingSeekPositionMs = target
|
||||
_progress.update { progress ->
|
||||
progress.copy(
|
||||
durationMs = resolveDurationMs(),
|
||||
positionMs = target,
|
||||
isLive = player.isCurrentMediaItemLive
|
||||
)
|
||||
}
|
||||
player.seekTo(target)
|
||||
}
|
||||
|
||||
fun seekBy(deltaMs: Long) {
|
||||
val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition)
|
||||
requestSeek(basePositionMs + deltaMs, basePositionMs)
|
||||
val basePosition = pendingSeekPositionMs ?: player.currentPosition
|
||||
val target = clampSeekPosition(basePosition + deltaMs)
|
||||
seekTo(target)
|
||||
}
|
||||
|
||||
fun seekToLiveEdge() {
|
||||
clearPendingSeek()
|
||||
if (player.isCurrentMediaItemLive) {
|
||||
pendingSeekTracker.clear()
|
||||
player.seekToDefaultPosition()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
fun next() {
|
||||
clearPendingSeek()
|
||||
if (player.hasNextMediaItem()) {
|
||||
pendingSeekTracker.clear()
|
||||
player.seekToNextMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
fun previous() {
|
||||
clearPendingSeek()
|
||||
if (player.hasPreviousMediaItem()) {
|
||||
pendingSeekTracker.clear()
|
||||
player.seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
@@ -250,6 +229,7 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
clearPendingSeek()
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
@@ -258,7 +238,7 @@ class PlayerManager @Inject constructor(
|
||||
val items = _queue.value
|
||||
val targetIndex = items.indexOfFirst { it.id == id }
|
||||
if (targetIndex >= 0) {
|
||||
pendingSeekTracker.clear()
|
||||
clearPendingSeek()
|
||||
player.seekToDefaultPosition(targetIndex)
|
||||
player.playWhenReady = true
|
||||
refreshQueue()
|
||||
@@ -269,35 +249,6 @@ class PlayerManager @Inject constructor(
|
||||
_playbackState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
fun snapshotProgress(): PlaybackProgressSnapshot {
|
||||
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
|
||||
return PlaybackProgressSnapshot(
|
||||
durationMs = duration,
|
||||
positionMs = pendingSeekTracker.currentPosition(player.currentPosition),
|
||||
bufferedMs = player.bufferedPosition,
|
||||
isLive = player.isCurrentMediaItemLive
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestSeek(positionMs: Long, basePositionMs: Long = pendingSeekTracker.currentPosition(player.currentPosition)) {
|
||||
val targetPositionMs = clampSeekPosition(positionMs)
|
||||
pendingSeekTracker.recordSeek(basePositionMs = basePositionMs, targetPositionMs = targetPositionMs)
|
||||
_progress.update {
|
||||
it.copy(
|
||||
durationMs = player.duration.takeIf { value -> value > 0L } ?: it.durationMs,
|
||||
positionMs = targetPositionMs,
|
||||
bufferedMs = player.bufferedPosition,
|
||||
isLive = player.isCurrentMediaItemLive
|
||||
)
|
||||
}
|
||||
player.seekTo(targetPositionMs)
|
||||
}
|
||||
|
||||
private fun clampSeekPosition(positionMs: Long): Long {
|
||||
val durationMs = player.duration.takeIf { it > 0L } ?: _progress.value.durationMs.takeIf { it > 0L }
|
||||
return durationMs?.let { positionMs.coerceIn(0L, it) } ?: positionMs.coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private suspend fun applyTrackPreferences() {
|
||||
val context = currentMediaContext ?: return
|
||||
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return
|
||||
@@ -350,17 +301,6 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSubtitleRendererOnBackwardSeek() {
|
||||
val currentParams = player.trackSelectionParameters
|
||||
if (C.TRACK_TYPE_TEXT in currentParams.disabledTrackTypes) return
|
||||
scope.launch {
|
||||
player.trackSelectionParameters = currentParams.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
.build()
|
||||
player.trackSelectionParameters = currentParams
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
scope.cancel()
|
||||
player.removeListener(listener)
|
||||
@@ -370,7 +310,17 @@ class PlayerManager @Inject constructor(
|
||||
private fun startProgressLoop() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
_progress.value = snapshotProgress()
|
||||
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
|
||||
val actualPosition = player.currentPosition
|
||||
syncPendingSeek(actualPosition)
|
||||
val position = pendingSeekPositionMs ?: actualPosition
|
||||
val buffered = player.bufferedPosition
|
||||
_progress.value = PlaybackProgressSnapshot(
|
||||
durationMs = duration,
|
||||
positionMs = position,
|
||||
bufferedMs = buffered,
|
||||
isLive = player.isCurrentMediaItemLive
|
||||
)
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
@@ -399,20 +349,44 @@ class PlayerManager @Inject constructor(
|
||||
mediaId = mediaItem?.mediaId,
|
||||
title = mediaItem?.mediaMetadata?.title?.toString(),
|
||||
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(),
|
||||
playbackReportContext = playbackReportContext
|
||||
playbackReportContext = playbackReportContext,
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshTracks(tracks: Tracks) {
|
||||
_tracks.value = trackMapper.map(tracks)
|
||||
}
|
||||
|
||||
private fun clampSeekPosition(positionMs: Long): Long {
|
||||
val duration = resolveDurationMs()
|
||||
return if (duration > 0) {
|
||||
positionMs.coerceIn(0L, duration)
|
||||
} else {
|
||||
positionMs.coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDurationMs(): Long {
|
||||
return player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
|
||||
}
|
||||
|
||||
private fun clearPendingSeek() {
|
||||
pendingSeekPositionMs = null
|
||||
}
|
||||
|
||||
private fun syncPendingSeek(positionMs: Long) {
|
||||
val pendingPosition = pendingSeekPositionMs ?: return
|
||||
if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) {
|
||||
clearPendingSeek()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PlaybackStateSnapshot(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val isEnded: Boolean = false,
|
||||
val error: PlayerError? = null
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class PlaybackProgressSnapshot(
|
||||
@@ -426,7 +400,7 @@ data class MetadataState(
|
||||
val mediaId: String? = null,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
val playbackReportContext: PlaybackReportContext? = null
|
||||
val playbackReportContext: PlaybackReportContext? = null,
|
||||
)
|
||||
|
||||
data class MediaContext(
|
||||
|
||||
@@ -31,11 +31,6 @@ class ProgressManager @Inject constructor(
|
||||
private var lastDurationMs: Long = 0L
|
||||
private var isPaused: Boolean = false
|
||||
|
||||
fun syncProgress(snapshot: PlaybackProgressSnapshot) {
|
||||
lastPositionMs = snapshot.positionMs
|
||||
lastDurationMs = snapshot.durationMs
|
||||
}
|
||||
|
||||
fun bind(
|
||||
playbackState: StateFlow<PlaybackStateSnapshot>,
|
||||
progress: StateFlow<PlaybackProgressSnapshot>,
|
||||
@@ -49,16 +44,16 @@ class ProgressManager @Inject constructor(
|
||||
lastDurationMs = prog.durationMs
|
||||
isPaused = !state.isPlaying
|
||||
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
activePlaybackReportContext = meta.playbackReportContext
|
||||
val nextPlaybackReportContext = meta.playbackReportContext
|
||||
|
||||
// Media changed or ended - stop session
|
||||
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
|
||||
stopSession()
|
||||
}
|
||||
|
||||
// Start session when we have a media item and none is active
|
||||
if (activeItemId == null && mediaId != null && !state.isEnded) {
|
||||
startSession(mediaId, prog.positionMs, meta.playbackReportContext)
|
||||
startSession(mediaId, prog.positionMs, nextPlaybackReportContext)
|
||||
} else if (activeItemId == mediaId) {
|
||||
activePlaybackReportContext = nextPlaybackReportContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
|
||||
enum class PlayerErrorSource {
|
||||
LOAD,
|
||||
PLAYBACK
|
||||
}
|
||||
|
||||
data class PlayerError(
|
||||
val summary: String,
|
||||
val technicalDetail: String? = null,
|
||||
val source: PlayerErrorSource,
|
||||
val errorCode: Int? = null,
|
||||
val errorCodeName: String? = null,
|
||||
val retryable: Boolean = false
|
||||
) {
|
||||
val detailText: String?
|
||||
get() = technicalDetail?.takeIf { detail ->
|
||||
detail.isNotBlank() && !detail.equals(summary, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun withAdditionalTechnicalDetail(additionalDetail: String?): PlayerError {
|
||||
val normalizedAdditionalDetail = additionalDetail?.trim()?.takeIf { it.isNotEmpty() } ?: return this
|
||||
val mergedDetail = linkedSetOf<String>().apply {
|
||||
technicalDetail?.trim()?.takeIf { it.isNotEmpty() }?.let(::add)
|
||||
add(normalizedAdditionalDetail)
|
||||
}.joinToString(" | ")
|
||||
|
||||
return copy(technicalDetail = mergedDetail)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun loadFailure(
|
||||
summary: String = "Unable to load media",
|
||||
technicalDetail: String? = null,
|
||||
retryable: Boolean = true
|
||||
): PlayerError {
|
||||
return PlayerError(
|
||||
summary = summary,
|
||||
technicalDetail = technicalDetail,
|
||||
source = PlayerErrorSource.LOAD,
|
||||
retryable = retryable
|
||||
)
|
||||
}
|
||||
|
||||
fun invalidMediaId(mediaId: String): PlayerError {
|
||||
return PlayerError(
|
||||
summary = "Invalid media id",
|
||||
technicalDetail = "The requested media id is not a valid UUID: $mediaId",
|
||||
source = PlayerErrorSource.LOAD,
|
||||
retryable = false
|
||||
)
|
||||
}
|
||||
|
||||
fun fromPlaybackException(error: PlaybackException): PlayerError {
|
||||
return playbackFailure(
|
||||
errorCode = error.errorCode,
|
||||
errorCodeName = error.errorCodeName,
|
||||
technicalDetail = error.localizedMessage,
|
||||
cause = error.cause
|
||||
)
|
||||
}
|
||||
|
||||
fun playbackFailure(
|
||||
errorCode: Int? = null,
|
||||
errorCodeName: String? = null,
|
||||
technicalDetail: String? = null,
|
||||
cause: Throwable? = null,
|
||||
retryable: Boolean = true
|
||||
): PlayerError {
|
||||
val mergedTechnicalDetail = linkedSetOf<String>().apply {
|
||||
errorCodeName?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
technicalDetail?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
cause?.toTechnicalDetail()?.let(::add)
|
||||
}.joinToString(" | ").ifBlank { null }
|
||||
|
||||
return PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = mergedTechnicalDetail,
|
||||
source = PlayerErrorSource.PLAYBACK,
|
||||
errorCode = errorCode,
|
||||
errorCodeName = errorCodeName,
|
||||
retryable = retryable
|
||||
)
|
||||
}
|
||||
|
||||
fun fromThrowable(
|
||||
throwable: Throwable,
|
||||
summary: String = "Unable to load media",
|
||||
retryable: Boolean = true
|
||||
): PlayerError {
|
||||
return loadFailure(
|
||||
summary = summary,
|
||||
technicalDetail = throwable.toTechnicalDetail(),
|
||||
retryable = retryable
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Throwable.toTechnicalDetail(): String {
|
||||
val typeName = this::class.simpleName ?: javaClass.simpleName.ifBlank { javaClass.name }
|
||||
val message = message?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
return if (message != null) {
|
||||
"$typeName: $message"
|
||||
} else {
|
||||
typeName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ data class PlayerUiState(
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val bufferedMs: Long = 0L,
|
||||
val error: PlayerError? = null,
|
||||
val error: String? = null,
|
||||
val playbackSpeed: Float = 1f,
|
||||
val chapters: List<TimedMarker> = emptyList(),
|
||||
val ads: List<TimedMarker> = emptyList(),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
|
||||
data class VideoItem(
|
||||
val title: String,
|
||||
val mediaItem: MediaItem,
|
||||
val uri: Uri
|
||||
)
|
||||
@@ -18,7 +18,6 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ViewModelComponent
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.client.AndroidDeviceProfile
|
||||
|
||||
@Module
|
||||
@InstallIn(ViewModelComponent::class)
|
||||
@@ -28,7 +27,6 @@ object VideoPlayerModule {
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
|
||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(application)
|
||||
val trackSelector = DefaultTrackSelector(application)
|
||||
val audioAttributes =
|
||||
AudioAttributes.Builder()
|
||||
@@ -39,24 +37,15 @@ object VideoPlayerModule {
|
||||
trackSelector.setParameters(
|
||||
trackSelector
|
||||
.buildUponParameters()
|
||||
.setMaxAudioChannelCount(capabilitySnapshot.maxAudioChannels)
|
||||
.setExceedAudioConstraintsIfNecessary(false)
|
||||
.setTunnelingEnabled(false)
|
||||
// .setPreferredAudioLanguage(
|
||||
// appPreferences.getValue(appPreferences.preferredAudioLanguage)
|
||||
// )
|
||||
// .setPreferredTextLanguage(
|
||||
// appPreferences.getValue(appPreferences.preferredSubtitleLanguage)
|
||||
// )
|
||||
)
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
15_000, // minBufferMs
|
||||
50_000, // maxBufferMs
|
||||
2_500, // bufferForPlaybackMs
|
||||
5_000 // bufferForPlaybackAfterRebufferMs
|
||||
10_000,
|
||||
30_000,
|
||||
5_000,
|
||||
5_000
|
||||
)
|
||||
.setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build()
|
||||
|
||||
// Configure RenderersFactory to use all available decoders and enable passthrough
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.stream
|
||||
|
||||
class MediaSourceSelector {
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
internal object PlaybackRetryPolicy {
|
||||
private val retryableErrorCodes = setOf(
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
|
||||
PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
|
||||
PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
|
||||
)
|
||||
|
||||
fun shouldRetryWithTranscoding(
|
||||
error: PlayerError,
|
||||
playbackReportContext: PlaybackReportContext
|
||||
): Boolean {
|
||||
if (error.source != PlayerErrorSource.PLAYBACK) {
|
||||
return false
|
||||
}
|
||||
if (!playbackReportContext.canRetryWithTranscoding) {
|
||||
return false
|
||||
}
|
||||
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
|
||||
return false
|
||||
}
|
||||
|
||||
val errorCode = error.errorCode
|
||||
if (errorCode != null && errorCode in retryableErrorCodes) {
|
||||
return true
|
||||
}
|
||||
|
||||
val detail = buildString {
|
||||
append(error.summary)
|
||||
error.errorCodeName?.let {
|
||||
append(' ')
|
||||
append(it)
|
||||
}
|
||||
error.detailText?.let {
|
||||
append(' ')
|
||||
append(it)
|
||||
}
|
||||
}.lowercase()
|
||||
|
||||
return "decoder" in detail ||
|
||||
"codec" in detail ||
|
||||
"unsupported" in detail ||
|
||||
"audiotrack" in detail ||
|
||||
"audio sink" in detail
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaRepository
|
||||
import hu.bbara.purefin.core.player.data.PlayerMediaLoadResult
|
||||
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
|
||||
import hu.bbara.purefin.core.player.manager.MediaContext
|
||||
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
|
||||
import hu.bbara.purefin.core.player.manager.PlayerManager
|
||||
import hu.bbara.purefin.core.player.manager.ProgressManager
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -33,6 +29,9 @@ class PlayerViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val progressManager: ProgressManager
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
|
||||
}
|
||||
|
||||
val player get() = playerManager.player
|
||||
|
||||
@@ -46,10 +45,7 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
private var autoHideJob: Job? = null
|
||||
private var lastNextUpMediaId: String? = null
|
||||
private var loadError: PlayerError? = null
|
||||
private var activeMediaId: String? = null
|
||||
private var transcodingRetryMediaId: String? = null
|
||||
private var lastLoadRequest: PendingLoadRequest? = null
|
||||
private var dataErrorMessage: String? = null
|
||||
|
||||
init {
|
||||
progressManager.bind(
|
||||
@@ -64,16 +60,12 @@ class PlayerViewModel @Inject constructor(
|
||||
private fun observePlayerState() {
|
||||
viewModelScope.launch {
|
||||
playerManager.playbackState.collect { state ->
|
||||
if (state.error != null && maybeRetryWithTranscoding(state)) {
|
||||
_uiState.update { it.copy(isBuffering = true, error = null) }
|
||||
return@collect
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPlaying = state.isPlaying,
|
||||
isBuffering = state.isBuffering,
|
||||
isEnded = state.isEnded,
|
||||
error = state.error ?: loadError
|
||||
error = state.error ?: dataErrorMessage
|
||||
)
|
||||
}
|
||||
if (state.isEnded) {
|
||||
@@ -104,10 +96,6 @@ class PlayerViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
val currentMediaId = metadata.mediaId
|
||||
if (currentMediaId != activeMediaId) {
|
||||
activeMediaId = currentMediaId
|
||||
transcodingRetryMediaId = null
|
||||
}
|
||||
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
|
||||
lastNextUpMediaId = currentMediaId
|
||||
loadNextUp(currentMediaId)
|
||||
@@ -148,78 +136,36 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun loadMediaById(id: String) {
|
||||
loadMediaById(id = id, forceTranscode = false, startPositionMsOverride = null, replaceCurrent = false)
|
||||
}
|
||||
|
||||
private fun loadMediaById(
|
||||
id: String,
|
||||
forceTranscode: Boolean,
|
||||
startPositionMsOverride: Long?,
|
||||
replaceCurrent: Boolean
|
||||
) {
|
||||
lastLoadRequest = PendingLoadRequest(
|
||||
id = id,
|
||||
forceTranscode = forceTranscode,
|
||||
startPositionMsOverride = startPositionMsOverride,
|
||||
replaceCurrent = replaceCurrent
|
||||
)
|
||||
val uuid = id.toUuidOrNull()
|
||||
if (uuid == null) {
|
||||
loadError = PlayerError.invalidMediaId(id)
|
||||
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
||||
dataErrorMessage = "Invalid media id"
|
||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||
return
|
||||
}
|
||||
loadError = null
|
||||
_uiState.update { it.copy(isBuffering = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
|
||||
when (result) {
|
||||
is PlayerMediaLoadResult.Success -> {
|
||||
val mediaItem = result.mediaItem
|
||||
val resumePositionMs = result.resumePositionMs
|
||||
val result = playerMediaRepository.getMediaItem(uuid)
|
||||
if (result != null) {
|
||||
val (mediaItem, resumePositionMs) = result
|
||||
|
||||
// Determine preference key: movies use their own ID, episodes use series ID
|
||||
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
|
||||
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
|
||||
val startPositionMs = startPositionMsOverride ?: resumePositionMs
|
||||
|
||||
if (replaceCurrent) {
|
||||
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
|
||||
} else {
|
||||
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
||||
}
|
||||
playerManager.play(mediaItem, mediaContext)
|
||||
|
||||
if (loadError != null) {
|
||||
loadError = null
|
||||
// Seek to resume position after play() is called
|
||||
resumePositionMs?.let { playerManager.seekTo(it) }
|
||||
|
||||
if (dataErrorMessage != null) {
|
||||
dataErrorMessage = null
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
is PlayerMediaLoadResult.Failure -> {
|
||||
loadError = result.error
|
||||
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
||||
} else {
|
||||
dataErrorMessage = "Unable to load media"
|
||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
|
||||
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
|
||||
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
|
||||
val error = state.error ?: return false
|
||||
|
||||
if (currentMediaId == transcodingRetryMediaId) return false
|
||||
if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false
|
||||
|
||||
transcodingRetryMediaId = currentMediaId
|
||||
loadMediaById(
|
||||
id = currentMediaId,
|
||||
forceTranscode = true,
|
||||
startPositionMsOverride = player.currentPosition.takeIf { it > 0L },
|
||||
replaceCurrent = true
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun loadNextUp(currentMediaId: String) {
|
||||
val uuid = currentMediaId.toUuidOrNull() ?: return
|
||||
@@ -233,9 +179,9 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.togglePlayPause()
|
||||
showControls()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
@@ -250,38 +196,33 @@ class PlayerViewModel @Inject constructor(
|
||||
playerManager.seekToLiveEdge()
|
||||
}
|
||||
|
||||
fun showControls() {
|
||||
fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
_controlsVisible.value = true
|
||||
scheduleAutoHide()
|
||||
scheduleAutoHide(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
_controlsVisible.value = !_controlsVisible.value
|
||||
if (_controlsVisible.value) scheduleAutoHide()
|
||||
if (_controlsVisible.value) scheduleAutoHide(DEFAULT_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
|
||||
fun hideControls() {
|
||||
_controlsVisible.value = false
|
||||
autoHideJob?.cancel()
|
||||
}
|
||||
|
||||
private fun scheduleAutoHide() {
|
||||
private fun scheduleAutoHide(autoHideDelayMs: Long) {
|
||||
autoHideJob?.cancel()
|
||||
if (!player.isPlaying) return
|
||||
autoHideJob = viewModelScope.launch {
|
||||
delay(3500)
|
||||
delay(autoHideDelayMs)
|
||||
_controlsVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun next() {
|
||||
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.next()
|
||||
showControls()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun previous() {
|
||||
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.previous()
|
||||
showControls()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun selectTrack(option: TrackOption) {
|
||||
@@ -294,21 +235,16 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val error = uiState.value.error
|
||||
if (error?.source == PlayerErrorSource.LOAD) {
|
||||
retryLoad()
|
||||
return
|
||||
}
|
||||
playerManager.retry()
|
||||
}
|
||||
|
||||
fun playQueueItem(id: String) {
|
||||
fun playQueueItem(id: String, autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.playQueueItem(id)
|
||||
showControls()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
loadError = null
|
||||
dataErrorMessage = null
|
||||
playerManager.clearError()
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
@@ -316,27 +252,9 @@ class PlayerViewModel @Inject constructor(
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
autoHideJob?.cancel()
|
||||
progressManager.syncProgress(playerManager.snapshotProgress())
|
||||
progressManager.release()
|
||||
playerManager.release()
|
||||
}
|
||||
|
||||
private fun retryLoad() {
|
||||
val request = lastLoadRequest ?: return
|
||||
loadMediaById(
|
||||
id = request.id,
|
||||
forceTranscode = request.forceTranscode,
|
||||
startPositionMsOverride = request.startPositionMsOverride,
|
||||
replaceCurrent = request.replaceCurrent
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
|
||||
|
||||
private data class PendingLoadRequest(
|
||||
val id: String,
|
||||
val forceTranscode: Boolean,
|
||||
val startPositionMsOverride: Long?,
|
||||
val replaceCurrent: Boolean
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.manager
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PendingSeekTrackerTest {
|
||||
|
||||
@Test
|
||||
fun repeatedForwardSeeksAccumulateWhilePlayerPositionIsStale() {
|
||||
val tracker = PendingSeekTracker()
|
||||
|
||||
val firstBase = tracker.currentPosition(playerPositionMs = 30_000L)
|
||||
tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 40_000L)
|
||||
|
||||
val secondBase = tracker.currentPosition(playerPositionMs = 30_000L)
|
||||
tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase + 10_000L)
|
||||
|
||||
assertEquals(50_000L, tracker.currentPosition(playerPositionMs = 30_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedBackwardSeeksAccumulateWhilePlayerPositionIsStale() {
|
||||
val tracker = PendingSeekTracker()
|
||||
|
||||
val firstBase = tracker.currentPosition(playerPositionMs = 30_000L)
|
||||
tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 20_000L)
|
||||
|
||||
val secondBase = tracker.currentPosition(playerPositionMs = 30_000L)
|
||||
tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase - 10_000L)
|
||||
|
||||
assertEquals(10_000L, tracker.currentPosition(playerPositionMs = 30_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingForwardSeekClearsOncePlayerCatchesUp() {
|
||||
val tracker = PendingSeekTracker()
|
||||
|
||||
tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 40_000L)
|
||||
|
||||
assertEquals(40_000L, tracker.currentPosition(playerPositionMs = 30_000L))
|
||||
assertEquals(39_700L, tracker.currentPosition(playerPositionMs = 39_700L))
|
||||
assertEquals(41_000L, tracker.currentPosition(playerPositionMs = 41_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingBackwardSeekClearsOncePlayerCatchesUp() {
|
||||
val tracker = PendingSeekTracker()
|
||||
|
||||
tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 20_000L)
|
||||
|
||||
assertEquals(20_000L, tracker.currentPosition(playerPositionMs = 30_000L))
|
||||
assertEquals(20_300L, tracker.currentPosition(playerPositionMs = 20_300L))
|
||||
assertEquals(19_000L, tracker.currentPosition(playerPositionMs = 19_000L))
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlayerErrorTest {
|
||||
@Test
|
||||
fun `playbackFailure preserves code and detail`() {
|
||||
val playerError = PlayerError.playbackFailure(
|
||||
errorCode = 4001,
|
||||
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
|
||||
technicalDetail = "Decoder init failed on test device"
|
||||
)
|
||||
|
||||
assertEquals("Playback error", playerError.summary)
|
||||
assertEquals(PlayerErrorSource.PLAYBACK, playerError.source)
|
||||
assertEquals(4001, playerError.errorCode)
|
||||
assertEquals("ERROR_CODE_DECODER_INIT_FAILED", playerError.errorCodeName)
|
||||
assertNotNull(playerError.detailText)
|
||||
assertTrue(playerError.detailText!!.contains("ERROR_CODE_DECODER_INIT_FAILED"))
|
||||
assertTrue(playerError.detailText!!.contains("Decoder init failed on test device"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withAdditionalTechnicalDetail appends distinct detail`() {
|
||||
val playerError = PlayerError.loadFailure(technicalDetail = "IllegalStateException: boom")
|
||||
|
||||
val mergedError = playerError.withAdditionalTechnicalDetail(
|
||||
"Offline fallback unavailable: no completed download."
|
||||
)
|
||||
|
||||
assertTrue(mergedError.detailText!!.contains("IllegalStateException: boom"))
|
||||
assertTrue(mergedError.detailText!!.contains("Offline fallback unavailable: no completed download."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalidMediaId is a non retryable load error`() {
|
||||
val playerError = PlayerError.invalidMediaId("abc")
|
||||
|
||||
assertEquals("Invalid media id", playerError.summary)
|
||||
assertEquals(PlayerErrorSource.LOAD, playerError.source)
|
||||
assertFalse(playerError.retryable)
|
||||
assertTrue(playerError.detailText!!.contains("abc"))
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackRetryPolicyTest {
|
||||
@Test
|
||||
fun `decoder failures are retried when transcoding is available`() {
|
||||
val error = PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = "Decoder init failed",
|
||||
source = PlayerErrorSource.PLAYBACK,
|
||||
errorCode = PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
|
||||
retryable = true
|
||||
)
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
|
||||
|
||||
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transcoded playback is not retried again`() {
|
||||
val error = PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = "Unsupported container",
|
||||
source = PlayerErrorSource.PLAYBACK,
|
||||
errorCodeName = "ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED",
|
||||
retryable = true
|
||||
)
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.TRANSCODE)
|
||||
|
||||
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load errors are never retried as transcoding fallbacks`() {
|
||||
val error = PlayerError.loadFailure(technicalDetail = "No playable source was returned.")
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
|
||||
|
||||
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported codec detail is retryable without an error code`() {
|
||||
val error = PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = "Codec unsupported by this device",
|
||||
source = PlayerErrorSource.PLAYBACK
|
||||
)
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_STREAM)
|
||||
|
||||
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio track init failures are retried when transcoding is available`() {
|
||||
val error = PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = "AudioTrack init failed",
|
||||
source = PlayerErrorSource.PLAYBACK,
|
||||
errorCode = PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
|
||||
errorCodeName = "ERROR_CODE_AUDIO_TRACK_INIT_FAILED",
|
||||
retryable = true
|
||||
)
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
|
||||
|
||||
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext {
|
||||
return PlaybackReportContext(
|
||||
playMethod = playMethod,
|
||||
mediaSourceId = "source-id",
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = null,
|
||||
liveStreamId = null,
|
||||
playSessionId = "session-id",
|
||||
canRetryWithTranscoding = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls"
|
||||
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
|
||||
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
|
||||
medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"}
|
||||
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
||||
media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
||||
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
|
||||
|
||||
Reference in New Issue
Block a user