Compare commits

...

6 Commits

17 changed files with 669 additions and 113 deletions

View File

@@ -165,6 +165,7 @@ fun TvPlayerScreen(
}
Key.DirectionUp, Key.DirectionDown,
Key.DirectionCenter, Key.Enter -> {
viewModel.togglePlayPause()
viewModel.showControls()
true
}

View File

@@ -49,4 +49,5 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
testImplementation(libs.junit)
}

View File

@@ -1,14 +1,20 @@
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.
@@ -17,7 +23,9 @@ import org.jellyfin.sdk.model.api.SubtitleProfile
object AndroidDeviceProfile {
private const val TAG = "AndroidDeviceProfile"
private const val DEFAULT_MAX_AUDIO_CHANNELS = 8
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
@@ -45,7 +53,7 @@ object AndroidDeviceProfile {
Log.d(TAG, "Max audio channels: $maxAudioChannels")
val snapshot = CapabilitySnapshot(
deviceProfile = buildDeviceProfile(audioCodecs, videoCodecs),
deviceProfile = createDeviceProfile(audioCodecs, videoCodecs, maxAudioChannels),
maxAudioChannels = maxAudioChannels
)
cachedSnapshot = snapshot
@@ -55,10 +63,13 @@ object AndroidDeviceProfile {
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
private fun buildDeviceProfile(
internal fun createDeviceProfile(
audioCodecs: List<String>,
videoCodecs: List<String>
videoCodecs: List<String>,
maxAudioChannels: Int
): DeviceProfile {
val hlsOptions = HlsPlaybackStability.conservativeHlsOptions
return DeviceProfile(
name = "Android Media3",
maxStaticBitrate = 100_000_000,
@@ -74,8 +85,24 @@ object AndroidDeviceProfile {
)
),
// Empty transcoding profiles - Jellyfin will use its defaults
transcodingProfiles = emptyList(),
// 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(),
@@ -196,7 +223,7 @@ object AndroidDeviceProfile {
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
return runCatching {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val maxDeviceChannels = devices
val reportedMaxChannels = devices
.flatMap { device ->
buildList {
addAll(device.channelCounts.toList())
@@ -205,15 +232,67 @@ object AndroidDeviceProfile {
}
}
.maxOrNull()
maxDeviceChannels
?.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

View File

@@ -0,0 +1,17 @@
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

@@ -50,6 +50,14 @@ class JellyfinApiClient @Inject constructor(
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 {
@@ -281,76 +289,32 @@ class JellyfinApiClient @Inject constructor(
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 selectedMediaSource = selectMediaSource(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
val playbackPlan = PlaybackSourcePlanner.plan(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
val selectedMediaSource = playbackPlan.mediaSource
val url = when {
forceTranscode && selectedMediaSource.supportsTranscoding -> {
resolveTranscodeUrl(
val url = resolvePlaybackUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
urlStrategy = playbackPlan.urlStrategy,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
!forceTranscode && selectedMediaSource.supportsDirectPlay -> {
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = selectedMediaSource.id,
playSessionId = playbackInfo.playSessionId,
liveStreamId = selectedMediaSource.liveStreamId
)
}
!forceTranscode && selectedMediaSource.supportsDirectStream -> {
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = selectedMediaSource.id,
playSessionId = playbackInfo.playSessionId,
liveStreamId = selectedMediaSource.liveStreamId,
container = selectedMediaSource.transcodingContainer ?: selectedMediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
selectedMediaSource.supportsTranscoding -> {
resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
else -> null
} ?: return@withContext null
val playMethod = when {
forceTranscode -> PlayMethod.TRANSCODE
selectedMediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
selectedMediaSource.supportsDirectStream -> PlayMethod.DIRECT_STREAM
selectedMediaSource.supportsTranscoding -> PlayMethod.TRANSCODE
else -> return@withContext null
}
) ?: return@withContext null
val reportContext = PlaybackReportContext(
playMethod = playMethod,
playMethod = playbackPlan.playMethod,
mediaSourceId = selectedMediaSource.id,
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
liveStreamId = selectedMediaSource.liveStreamId,
playSessionId = playbackInfo.playSessionId,
canRetryWithTranscoding = !forceTranscode &&
playMethod != PlayMethod.TRANSCODE &&
selectedMediaSource.supportsTranscoding
canRetryWithTranscoding = playbackPlan.canRetryWithTranscoding
)
Log.d(TAG, "Playback decision for $mediaId: $playMethod using ${selectedMediaSource.id}")
Log.d(
TAG,
"Playback decision for $mediaId: ${playbackPlan.playMethod} using ${selectedMediaSource.id}" +
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
)
PlaybackDecision(
url = url,
mediaSource = selectedMediaSource,
@@ -365,37 +329,20 @@ class JellyfinApiClient @Inject constructor(
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val url = when {
mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = mediaSource.id,
liveStreamId = mediaSource.liveStreamId
)
mediaSource.supportsDirectStream -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
mediaSource.supportsTranscoding -> resolveTranscodeUrl(
val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null
val url = resolvePlaybackUrl(
mediaId = mediaId,
mediaSource = mediaSource,
mediaSource = playbackPlan.mediaSource,
urlStrategy = playbackPlan.urlStrategy,
playSessionId = null,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
else -> null
}
Log.d(TAG, "Resolved standalone playback URL for $mediaId -> $url")
Log.d(
TAG,
"Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" +
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
)
url
}
@@ -466,6 +413,11 @@ class JellyfinApiClient @Inject constructor(
}
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(
@@ -482,27 +434,93 @@ class JellyfinApiClient @Inject constructor(
)
)
Log.d(TAG, "PlaybackInfo for $mediaId -> sources=${response.content.mediaSources.size}, session=${response.content.playSessionId}")
logPlaybackInfo(
mediaId = mediaId,
playbackInfo = response.content,
forceTranscode = forceTranscode
)
return response.content
}
private fun selectMediaSource(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): MediaSourceInfo? {
return when {
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }
else -> mediaSources.firstOrNull { it.supportsDirectPlay }
?: mediaSources.firstOrNull { it.supportsDirectStream }
?: mediaSources.firstOrNull { it.supportsTranscoding }
?: mediaSources.firstOrNull()
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
}
@@ -510,6 +528,11 @@ class JellyfinApiClient @Inject constructor(
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,
@@ -517,10 +540,38 @@ class JellyfinApiClient @Inject constructor(
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()}"
)
}
}
}

View File

@@ -0,0 +1,78 @@
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

@@ -0,0 +1,34 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidDeviceProfileTest {
@Test
fun `device profile includes explicit video transcoding support`() {
val profile = AndroidDeviceProfile.createDeviceProfile(
audioCodecs = listOf("aac", "ac3"),
videoCodecs = listOf("h264", "hevc"),
maxAudioChannels = 6
)
val transcodingProfile = profile.transcodingProfiles.single()
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)
}
}

View File

@@ -0,0 +1,29 @@
package hu.bbara.purefin.core.data.client
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class JellyfinApiClientTest {
@Test
fun `direct play requests do not apply HLS stability options`() {
val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY)
assertNull(options)
}
@Test
fun `direct stream requests use conservative HLS stability options`() {
val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM)
assertEquals(HlsPlaybackStability.conservativeHlsOptions, options)
}
@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)
assertEquals(directStreamOptions, transcodeOptions)
}
}

View File

@@ -0,0 +1,111 @@
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,6 +34,7 @@ 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)

View File

@@ -0,0 +1,43 @@
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

@@ -45,6 +45,7 @@ class PlayerManager @Inject constructor(
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val pendingSeekTracker = PendingSeekTracker()
private var currentMediaContext: MediaContext? = null
@@ -97,6 +98,7 @@ class PlayerManager @Inject constructor(
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
pendingSeekTracker.clear()
refreshMetadata(mediaItem)
refreshQueue()
}
@@ -124,6 +126,7 @@ class PlayerManager @Inject constructor(
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
currentMediaContext = mediaContext
pendingSeekTracker.clear()
if (startPositionMs != null) {
player.setMediaItem(mediaItem, startPositionMs)
} else {
@@ -139,6 +142,7 @@ class PlayerManager @Inject constructor(
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
@@ -167,16 +171,17 @@ class PlayerManager @Inject constructor(
}
fun seekTo(positionMs: Long) {
player.seekTo(positionMs)
requestSeek(positionMs)
}
fun seekBy(deltaMs: Long) {
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L)
seekTo(target)
val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition)
requestSeek(basePositionMs + deltaMs, basePositionMs)
}
fun seekToLiveEdge() {
if (player.isCurrentMediaItemLive) {
pendingSeekTracker.clear()
player.seekToDefaultPosition()
player.play()
}
@@ -184,12 +189,14 @@ class PlayerManager @Inject constructor(
fun next() {
if (player.hasNextMediaItem()) {
pendingSeekTracker.clear()
player.seekToNextMediaItem()
}
}
fun previous() {
if (player.hasPreviousMediaItem()) {
pendingSeekTracker.clear()
player.seekToPreviousMediaItem()
}
}
@@ -251,6 +258,7 @@ class PlayerManager @Inject constructor(
val items = _queue.value
val targetIndex = items.indexOfFirst { it.id == id }
if (targetIndex >= 0) {
pendingSeekTracker.clear()
player.seekToDefaultPosition(targetIndex)
player.playWhenReady = true
refreshQueue()
@@ -265,12 +273,31 @@ class PlayerManager @Inject constructor(
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
return PlaybackProgressSnapshot(
durationMs = duration,
positionMs = player.currentPosition,
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

View File

@@ -18,6 +18,7 @@ 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)
@@ -27,6 +28,7 @@ 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()
@@ -37,7 +39,9 @@ object VideoPlayerModule {
trackSelector.setParameters(
trackSelector
.buildUponParameters()
.setTunnelingEnabled(true)
.setMaxAudioChannelCount(capabilitySnapshot.maxAudioChannels)
.setExceedAudioConstraintsIfNecessary(false)
.setTunnelingEnabled(false)
// .setPreferredAudioLanguage(
// appPreferences.getValue(appPreferences.preferredAudioLanguage)
// )

View File

@@ -10,6 +10,10 @@ 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
)
@@ -45,6 +49,10 @@ internal object PlaybackRetryPolicy {
}
}.lowercase()
return "decoder" in detail || "codec" in detail || "unsupported" in detail
return "decoder" in detail ||
"codec" in detail ||
"unsupported" in detail ||
"audiotrack" in detail ||
"audio sink" in detail
}
}

View File

@@ -0,0 +1,55 @@
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

@@ -63,6 +63,22 @@ class PlaybackRetryPolicyTest {
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,

View File

@@ -54,6 +54,7 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3"}
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"}