Compare commits

...

5 Commits

38 changed files with 2206 additions and 1738 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,5 +16,4 @@ data class PlaybackReportContext(
val subtitleStreamIndex: Int?,
val liveStreamId: String?,
val playSessionId: String?,
val canRetryWithTranscoding: Boolean
)

View File

@@ -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('/')}"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
package hu.bbara.purefin.core.player.stream
class MediaSourceSelector {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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