mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
6 Commits
b91a8a81e8
...
69ae10c761
| Author | SHA1 | Date | |
|---|---|---|---|
| 69ae10c761 | |||
| 985fdec3a5 | |||
| 4b0e02e017 | |||
| 356816ba0e | |||
| cca75d8a5a | |||
| 59c9e31ca5 |
@@ -165,6 +165,7 @@ fun TvPlayerScreen(
|
||||
}
|
||||
Key.DirectionUp, Key.DirectionDown,
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
viewModel.togglePlayPause()
|
||||
viewModel.showControls()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -49,4 +49,5 @@ dependencies {
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
mediaId = mediaId,
|
||||
mediaSource = selectedMediaSource,
|
||||
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
|
||||
}
|
||||
val url = resolvePlaybackUrl(
|
||||
mediaId = mediaId,
|
||||
mediaSource = selectedMediaSource,
|
||||
urlStrategy = playbackPlan.urlStrategy,
|
||||
playSessionId = playbackInfo.playSessionId,
|
||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||
) ?: 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
|
||||
)
|
||||
val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null
|
||||
val url = resolvePlaybackUrl(
|
||||
mediaId = mediaId,
|
||||
mediaSource = playbackPlan.mediaSource,
|
||||
urlStrategy = playbackPlan.urlStrategy,
|
||||
playSessionId = null,
|
||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||
)
|
||||
|
||||
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(
|
||||
mediaId = mediaId,
|
||||
mediaSource = mediaSource,
|
||||
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()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
// )
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user