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.DirectionUp, Key.DirectionDown,
Key.DirectionCenter, Key.Enter -> { Key.DirectionCenter, Key.Enter -> {
viewModel.togglePlayPause()
viewModel.showControls() viewModel.showControls()
true true
} }

View File

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

View File

@@ -1,14 +1,20 @@
package hu.bbara.purefin.core.data.client package hu.bbara.purefin.core.data.client
import android.content.Context import android.content.Context
import android.media.AudioAttributes as PlatformAudioAttributes
import android.media.AudioFormat
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodecList import android.media.MediaCodecList
import android.util.Log import android.util.Log
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType 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.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile 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. * Creates a DeviceProfile for Android devices with proper codec support detection.
@@ -17,7 +23,9 @@ import org.jellyfin.sdk.model.api.SubtitleProfile
object AndroidDeviceProfile { object AndroidDeviceProfile {
private const val TAG = "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 @Volatile
private var cachedSnapshot: CapabilitySnapshot? = null private var cachedSnapshot: CapabilitySnapshot? = null
@@ -45,7 +53,7 @@ object AndroidDeviceProfile {
Log.d(TAG, "Max audio channels: $maxAudioChannels") Log.d(TAG, "Max audio channels: $maxAudioChannels")
val snapshot = CapabilitySnapshot( val snapshot = CapabilitySnapshot(
deviceProfile = buildDeviceProfile(audioCodecs, videoCodecs), deviceProfile = createDeviceProfile(audioCodecs, videoCodecs, maxAudioChannels),
maxAudioChannels = maxAudioChannels maxAudioChannels = maxAudioChannels
) )
cachedSnapshot = snapshot cachedSnapshot = snapshot
@@ -55,10 +63,13 @@ object AndroidDeviceProfile {
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
private fun buildDeviceProfile( internal fun createDeviceProfile(
audioCodecs: List<String>, audioCodecs: List<String>,
videoCodecs: List<String> videoCodecs: List<String>,
maxAudioChannels: Int
): DeviceProfile { ): DeviceProfile {
val hlsOptions = HlsPlaybackStability.conservativeHlsOptions
return DeviceProfile( return DeviceProfile(
name = "Android Media3", name = "Android Media3",
maxStaticBitrate = 100_000_000, maxStaticBitrate = 100_000_000,
@@ -74,8 +85,24 @@ object AndroidDeviceProfile {
) )
), ),
// Empty transcoding profiles - Jellyfin will use its defaults // Explicit video transcoding support keeps Jellyfin 10.10/10.11 from
transcodingProfiles = emptyList(), // 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(), codecProfiles = emptyList(),
@@ -196,7 +223,7 @@ object AndroidDeviceProfile {
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
return runCatching { return runCatching {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val maxDeviceChannels = devices val reportedMaxChannels = devices
.flatMap { device -> .flatMap { device ->
buildList { buildList {
addAll(device.channelCounts.toList()) addAll(device.channelCounts.toList())
@@ -205,15 +232,67 @@ object AndroidDeviceProfile {
} }
} }
.maxOrNull() .maxOrNull()
maxDeviceChannels
?.takeIf { it > 0 } ?.takeIf { it > 0 }
?: DEFAULT_MAX_AUDIO_CHANNELS ?: DEFAULT_MAX_AUDIO_CHANNELS
val verifiedMaxChannels = verifyOutputChannelSupport(reportedMaxChannels)
Log.d(TAG, "Reported max audio channels: $reportedMaxChannels, verified: $verifiedMaxChannels")
verifiedMaxChannels
}.getOrElse { }.getOrElse {
DEFAULT_MAX_AUDIO_CHANNELS 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 { private fun channelMaskToCount(mask: Int): Int {
return if (mask == 0) { return if (mask == 0) {
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 { companion object {
private const val TAG = "JellyfinApiClient" private const val TAG = "JellyfinApiClient"
private const val MAX_STREAMING_BITRATE = 100_000_000 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 { private val jellyfin = createJellyfin {
@@ -281,76 +289,32 @@ class JellyfinApiClient @Inject constructor(
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) { suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) 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 { val url = resolvePlaybackUrl(
forceTranscode && selectedMediaSource.supportsTranscoding -> { mediaId = mediaId,
resolveTranscodeUrl( mediaSource = selectedMediaSource,
mediaId = mediaId, urlStrategy = playbackPlan.urlStrategy,
mediaSource = selectedMediaSource, playSessionId = playbackInfo.playSessionId,
playSessionId = playbackInfo.playSessionId, maxAudioChannels = capabilitySnapshot.maxAudioChannels
maxAudioChannels = capabilitySnapshot.maxAudioChannels ) ?: return@withContext null
)
}
!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
}
val reportContext = PlaybackReportContext( val reportContext = PlaybackReportContext(
playMethod = playMethod, playMethod = playbackPlan.playMethod,
mediaSourceId = selectedMediaSource.id, mediaSourceId = selectedMediaSource.id,
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex, audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex, subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
liveStreamId = selectedMediaSource.liveStreamId, liveStreamId = selectedMediaSource.liveStreamId,
playSessionId = playbackInfo.playSessionId, playSessionId = playbackInfo.playSessionId,
canRetryWithTranscoding = !forceTranscode && canRetryWithTranscoding = playbackPlan.canRetryWithTranscoding
playMethod != PlayMethod.TRANSCODE &&
selectedMediaSource.supportsTranscoding
) )
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( PlaybackDecision(
url = url, url = url,
mediaSource = selectedMediaSource, mediaSource = selectedMediaSource,
@@ -365,37 +329,20 @@ class JellyfinApiClient @Inject constructor(
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val url = when { val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null
mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl( val url = resolvePlaybackUrl(
itemId = mediaId, mediaId = mediaId,
static = true, mediaSource = playbackPlan.mediaSource,
mediaSourceId = mediaSource.id, urlStrategy = playbackPlan.urlStrategy,
liveStreamId = mediaSource.liveStreamId playSessionId = null,
) maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
mediaSource.supportsDirectStream -> api.videosApi.getVideoStreamUrl( Log.d(
itemId = mediaId, TAG,
static = false, "Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" +
mediaSourceId = mediaSource.id, if (playbackPlan.usedFallback) " (conservative fallback)" else ""
liveStreamId = mediaSource.liveStreamId, )
container = mediaSource.transcodingContainer ?: mediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
mediaSource.supportsTranscoding -> resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = mediaSource,
playSessionId = null,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
else -> null
}
Log.d(TAG, "Resolved standalone playback URL for $mediaId -> $url")
url url
} }
@@ -466,6 +413,11 @@ class JellyfinApiClient @Inject constructor(
} }
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) 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( val response = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId, mediaId,
PlaybackInfoDto( 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 return response.content
} }
private fun selectMediaSource(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): MediaSourceInfo? { private suspend fun resolvePlaybackUrl(
return when { mediaId: UUID,
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding } mediaSource: MediaSourceInfo,
else -> mediaSources.firstOrNull { it.supportsDirectPlay } urlStrategy: PlaybackSourcePlanner.UrlStrategy,
?: mediaSources.firstOrNull { it.supportsDirectStream } playSessionId: String?,
?: mediaSources.firstOrNull { it.supportsTranscoding } maxAudioChannels: Int
?: mediaSources.firstOrNull() ): 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( private suspend fun resolveTranscodeUrl(
mediaId: UUID, mediaId: UUID,
mediaSource: MediaSourceInfo, mediaSource: MediaSourceInfo,
playSessionId: String?, playSessionId: String?,
maxAudioChannels: Int maxAudioChannels: Int
): String? { ): String? {
val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE)
?: error("Transcode requests must use HLS stability options.")
mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl -> 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)) { if (transcodingUrl.startsWith("http", ignoreCase = true)) {
return transcodingUrl return transcodingUrl
} }
@@ -510,6 +528,11 @@ class JellyfinApiClient @Inject constructor(
return "$baseUrl$transcodingUrl" 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( return api.videosApi.getVideoStreamUrl(
itemId = mediaId, itemId = mediaId,
static = false, static = false,
@@ -517,10 +540,38 @@ class JellyfinApiClient @Inject constructor(
playSessionId = playSessionId, playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId, liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4", container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4",
segmentLength = hlsOptions.segmentLengthSeconds,
minSegments = hlsOptions.minSegments,
enableAutoStreamCopy = false, enableAutoStreamCopy = false,
allowVideoStreamCopy = false, allowVideoStreamCopy = false,
allowAudioStreamCopy = false, allowAudioStreamCopy = false,
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
copyTimestamps = hlsOptions.copyTimestamps,
maxAudioChannels = maxAudioChannels 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) implementation(libs.hilt)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.medi3.exoplayer) implementation(libs.medi3.exoplayer)
implementation(libs.medi3.exoplayer.hls)
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
implementation(libs.datastore) implementation(libs.datastore)
implementation(libs.kotlinx.serialization.json) 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val pendingSeekTracker = PendingSeekTracker()
private var currentMediaContext: MediaContext? = null private var currentMediaContext: MediaContext? = null
@@ -97,6 +98,7 @@ class PlayerManager @Inject constructor(
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
pendingSeekTracker.clear()
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() refreshQueue()
} }
@@ -124,6 +126,7 @@ class PlayerManager @Inject constructor(
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
currentMediaContext = mediaContext currentMediaContext = mediaContext
pendingSeekTracker.clear()
if (startPositionMs != null) { if (startPositionMs != null) {
player.setMediaItem(mediaItem, startPositionMs) player.setMediaItem(mediaItem, startPositionMs)
} else { } else {
@@ -139,6 +142,7 @@ class PlayerManager @Inject constructor(
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
currentMediaContext = mediaContext currentMediaContext = mediaContext
pendingSeekTracker.clear()
val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run { val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run {
play(mediaItem, mediaContext, startPositionMs) play(mediaItem, mediaContext, startPositionMs)
return return
@@ -167,16 +171,17 @@ class PlayerManager @Inject constructor(
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
player.seekTo(positionMs) requestSeek(positionMs)
} }
fun seekBy(deltaMs: Long) { fun seekBy(deltaMs: Long) {
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition)
seekTo(target) requestSeek(basePositionMs + deltaMs, basePositionMs)
} }
fun seekToLiveEdge() { fun seekToLiveEdge() {
if (player.isCurrentMediaItemLive) { if (player.isCurrentMediaItemLive) {
pendingSeekTracker.clear()
player.seekToDefaultPosition() player.seekToDefaultPosition()
player.play() player.play()
} }
@@ -184,12 +189,14 @@ class PlayerManager @Inject constructor(
fun next() { fun next() {
if (player.hasNextMediaItem()) { if (player.hasNextMediaItem()) {
pendingSeekTracker.clear()
player.seekToNextMediaItem() player.seekToNextMediaItem()
} }
} }
fun previous() { fun previous() {
if (player.hasPreviousMediaItem()) { if (player.hasPreviousMediaItem()) {
pendingSeekTracker.clear()
player.seekToPreviousMediaItem() player.seekToPreviousMediaItem()
} }
} }
@@ -251,6 +258,7 @@ class PlayerManager @Inject constructor(
val items = _queue.value val items = _queue.value
val targetIndex = items.indexOfFirst { it.id == id } val targetIndex = items.indexOfFirst { it.id == id }
if (targetIndex >= 0) { if (targetIndex >= 0) {
pendingSeekTracker.clear()
player.seekToDefaultPosition(targetIndex) player.seekToDefaultPosition(targetIndex)
player.playWhenReady = true player.playWhenReady = true
refreshQueue() refreshQueue()
@@ -265,12 +273,31 @@ class PlayerManager @Inject constructor(
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
return PlaybackProgressSnapshot( return PlaybackProgressSnapshot(
durationMs = duration, durationMs = duration,
positionMs = player.currentPosition, positionMs = pendingSeekTracker.currentPosition(player.currentPosition),
bufferedMs = player.bufferedPosition, bufferedMs = player.bufferedPosition,
isLive = player.isCurrentMediaItemLive 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() { private suspend fun applyTrackPreferences() {
val context = currentMediaContext ?: return val context = currentMediaContext ?: return
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: 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.InstallIn
import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.AndroidDeviceProfile
@Module @Module
@InstallIn(ViewModelComponent::class) @InstallIn(ViewModelComponent::class)
@@ -27,6 +28,7 @@ object VideoPlayerModule {
@Provides @Provides
@ViewModelScoped @ViewModelScoped
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player { fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(application)
val trackSelector = DefaultTrackSelector(application) val trackSelector = DefaultTrackSelector(application)
val audioAttributes = val audioAttributes =
AudioAttributes.Builder() AudioAttributes.Builder()
@@ -37,7 +39,9 @@ object VideoPlayerModule {
trackSelector.setParameters( trackSelector.setParameters(
trackSelector trackSelector
.buildUponParameters() .buildUponParameters()
.setTunnelingEnabled(true) .setMaxAudioChannelCount(capabilitySnapshot.maxAudioChannels)
.setExceedAudioConstraintsIfNecessary(false)
.setTunnelingEnabled(false)
// .setPreferredAudioLanguage( // .setPreferredAudioLanguage(
// appPreferences.getValue(appPreferences.preferredAudioLanguage) // appPreferences.getValue(appPreferences.preferredAudioLanguage)
// ) // )

View File

@@ -10,6 +10,10 @@ internal object PlaybackRetryPolicy {
private val retryableErrorCodes = setOf( private val retryableErrorCodes = setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_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_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
) )
@@ -45,6 +49,10 @@ internal object PlaybackRetryPolicy {
} }
}.lowercase() }.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)) 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 { private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext {
return PlaybackReportContext( return PlaybackReportContext(
playMethod = playMethod, 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-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" } 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 = { 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 = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", 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"} medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"}