refactor: Throw out overcomplicated shit code for Playback

This commit is contained in:
2026-04-08 18:03:23 +02:00
parent 674e0881d7
commit 1a17c52326
22 changed files with 162 additions and 1632 deletions

View File

@@ -77,12 +77,12 @@ fun TvPlayerScreen(
}
BackHandler(enabled = controlsVisible) {
viewModel.hideControls()
viewModel.toggleControlsVisibility()
}
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) {
viewModel.hideControls()
if (uiState.isPlaying && controlsVisible) {
viewModel.toggleControlsVisibility()
}
}

View File

@@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@@ -48,20 +47,11 @@ internal fun TvPlayerLoadingErrorEndCard(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = uiState.error?.summary ?: "Playback error",
text = uiState.error ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
uiState.error?.detailText?.let { detail ->
Text(
text = detail,
color = scheme.onBackground.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodySmall,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) {
Text("Retry")

View File

@@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@@ -47,19 +46,10 @@ fun PlayerLoadingErrorEndCard(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = uiState.error?.summary ?: "Playback error",
text = uiState.error ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
uiState.error?.detailText?.let { detail ->
Text(
text = detail,
color = scheme.onBackground.copy(alpha = 0.8f),
style = MaterialTheme.typography.bodySmall,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) {
Text("Retry")

View File

@@ -1,303 +0,0 @@
package hu.bbara.purefin.core.data.client
import android.content.Context
import android.media.AudioAttributes as PlatformAudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodecList
import android.util.Log
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodingProfile
/**
* Creates a DeviceProfile for Android devices with proper codec support detection.
* This prevents playback failures by requesting transcoding for unsupported formats like DTS-HD.
*/
object AndroidDeviceProfile {
private const val TAG = "AndroidDeviceProfile"
private const val DEFAULT_MAX_AUDIO_CHANNELS = 2
private const val PROBE_SAMPLE_RATE = 48_000
private const val PROBE_ENCODING = AudioFormat.ENCODING_PCM_16BIT
@Volatile
private var cachedSnapshot: CapabilitySnapshot? = null
data class CapabilitySnapshot(
val deviceProfile: DeviceProfile,
val maxAudioChannels: Int
)
fun getSnapshot(context: Context): CapabilitySnapshot {
cachedSnapshot?.let { return it }
return synchronized(this) {
cachedSnapshot?.let { return@synchronized it }
val applicationContext = context.applicationContext
// Debug logging is noisy and expensive, so keep it to debug builds/devices only when needed.
val audioCodecs = getAudioCodecs()
val videoCodecs = getVideoCodecs()
val maxAudioChannels = resolveMaxAudioChannels(applicationContext)
Log.d(TAG, "Supported audio codecs: ${audioCodecs.joinToString()}")
Log.d(TAG, "Supported video codecs: ${videoCodecs.joinToString()}")
Log.d(TAG, "Max audio channels: $maxAudioChannels")
val snapshot = CapabilitySnapshot(
deviceProfile = createDeviceProfile(audioCodecs, videoCodecs, maxAudioChannels),
maxAudioChannels = maxAudioChannels
)
cachedSnapshot = snapshot
snapshot
}
}
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
internal fun createDeviceProfile(
audioCodecs: List<String>,
videoCodecs: List<String>,
maxAudioChannels: Int
): DeviceProfile {
val hlsOptions = HlsPlaybackStability.conservativeHlsOptions
return DeviceProfile(
name = "Android Media3",
maxStaticBitrate = 100_000_000,
maxStreamingBitrate = 100_000_000,
// Direct play profiles - what we can play natively
directPlayProfiles = listOf(
DirectPlayProfile(
type = DlnaProfileType.VIDEO,
container = "mp4,m4v,mkv,webm,ts,mov",
videoCodec = videoCodecs.joinToString(","),
audioCodec = audioCodecs.joinToString(",")
)
),
// Explicit video transcoding support keeps Jellyfin 10.10/10.11 from
// returning sources that exist but are not marked usable for playback.
transcodingProfiles = listOf(
TranscodingProfile(
container = "ts",
type = DlnaProfileType.VIDEO,
videoCodec = "h264",
audioCodec = "aac",
protocol = MediaStreamProtocol.HLS,
context = EncodingContext.STREAMING,
maxAudioChannels = maxAudioChannels.toString(),
minSegments = hlsOptions.minSegments,
segmentLength = hlsOptions.segmentLengthSeconds,
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
copyTimestamps = hlsOptions.copyTimestamps,
conditions = emptyList()
)
),
codecProfiles = emptyList(),
subtitleProfiles = listOf(
// Prefer EMBED so subtitles stay in the container — this gives
// correct cues after seeking (Media3 parses them at extraction time).
SubtitleProfile("srt", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("ass", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("subrip", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("sub", SubtitleDeliveryMethod.EMBED),
// EXTERNAL fallback for when embedding isn't possible
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL)
),
containerProfiles = emptyList()
)
}
/**
* Get list of supported audio codecs on this device.
* Excludes unsupported formats like DTS, DTS-HD, TrueHD which commonly cause playback failures.
*/
private fun getAudioCodecs(): List<String> {
val supportedCodecs = mutableListOf<String>()
val commonCodecs = listOf(
"aac" to "audio/mp4a-latm",
"mp3" to "audio/mpeg",
"ac3" to "audio/ac3",
"eac3" to "audio/eac3",
"dts" to "audio/vnd.dts",
"dtshd_ma" to "audio/vnd.dts.hd",
"truehd" to "audio/true-hd",
"flac" to "audio/flac",
"vorbis" to "audio/vorbis",
"opus" to "audio/opus",
"alac" to "audio/alac"
)
for ((codecName, mimeType) in commonCodecs) {
if (isCodecSupported(mimeType)) {
supportedCodecs.add(codecName)
}
}
// AAC is mandatory on Android - ensure it's always included
if (!supportedCodecs.contains("aac")) {
supportedCodecs.add("aac")
}
return supportedCodecs
}
/**
* Get list of supported video codecs on this device.
*/
private fun getVideoCodecs(): List<String> {
val supportedCodecs = mutableListOf<String>()
val commonCodecs = listOf(
"h264" to "video/avc",
"hevc" to "video/hevc",
"vp9" to "video/x-vnd.on2.vp9",
"vp8" to "video/x-vnd.on2.vp8",
"mpeg4" to "video/mp4v-es",
"av1" to "video/av01"
)
for ((codecName, mimeType) in commonCodecs) {
if (isCodecSupported(mimeType)) {
supportedCodecs.add(codecName)
}
}
// H.264 is mandatory on Android - ensure it's always included
if (!supportedCodecs.contains("h264")) {
supportedCodecs.add("h264")
}
return supportedCodecs
}
/**
* Check if a specific decoder (not encoder) is supported on this device.
*/
private fun isCodecSupported(mimeType: String): Boolean {
val platformSupport = try {
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
codecList.codecInfos.any { codecInfo ->
!codecInfo.isEncoder &&
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
}
} catch (_: Exception) {
false
}
if (platformSupport) {
return true
}
return isCodecSupportedByFfmpeg(mimeType)
}
private fun isCodecSupportedByFfmpeg(mimeType: String): Boolean {
return runCatching {
val ffmpegLibraryClass = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary")
val supportsFormat = ffmpegLibraryClass.getMethod("supportsFormat", String::class.java)
supportsFormat.invoke(null, mimeType) as? Boolean ?: false
}.getOrElse { false }
}
private fun resolveMaxAudioChannels(context: Context): Int {
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
return runCatching {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val reportedMaxChannels = devices
.flatMap { device ->
buildList {
addAll(device.channelCounts.toList())
addAll(device.channelMasks.map(::channelMaskToCount))
addAll(device.channelIndexMasks.map { Integer.bitCount(it) })
}
}
.maxOrNull()
?.takeIf { it > 0 }
?: DEFAULT_MAX_AUDIO_CHANNELS
val verifiedMaxChannels = verifyOutputChannelSupport(reportedMaxChannels)
Log.d(TAG, "Reported max audio channels: $reportedMaxChannels, verified: $verifiedMaxChannels")
verifiedMaxChannels
}.getOrElse {
DEFAULT_MAX_AUDIO_CHANNELS
}
}
private fun verifyOutputChannelSupport(reportedMaxChannels: Int): Int {
val candidates = buildList {
if (reportedMaxChannels >= 8) add(8)
if (reportedMaxChannels >= 6) add(6)
add(2)
}.distinct()
return candidates.firstOrNull(::canCreateAudioTrackForChannelCount) ?: DEFAULT_MAX_AUDIO_CHANNELS
}
private fun canCreateAudioTrackForChannelCount(channelCount: Int): Boolean {
val channelMask = when (channelCount) {
8 -> AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
6 -> AudioFormat.CHANNEL_OUT_5POINT1
2 -> AudioFormat.CHANNEL_OUT_STEREO
else -> return false
}
val minBufferSize = AudioTrack.getMinBufferSize(PROBE_SAMPLE_RATE, channelMask, PROBE_ENCODING)
if (minBufferSize <= 0) {
return false
}
return runCatching {
val audioTrack = AudioTrack.Builder()
.setAudioAttributes(
PlatformAudioAttributes.Builder()
.setUsage(PlatformAudioAttributes.USAGE_MEDIA)
.setContentType(PlatformAudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(PROBE_SAMPLE_RATE)
.setEncoding(PROBE_ENCODING)
.setChannelMask(channelMask)
.build()
)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(minBufferSize * 2)
.build()
try {
audioTrack.state == AudioTrack.STATE_INITIALIZED
} finally {
audioTrack.release()
}
}.getOrElse { false }
}
private fun channelMaskToCount(mask: Int): Int {
return if (mask == 0) {
0
} else {
Integer.bitCount(mask)
}
}
}

View File

@@ -1,17 +0,0 @@
package hu.bbara.purefin.core.data.client
internal data class HlsStabilityOptions(
val segmentLengthSeconds: Int,
val minSegments: Int,
val breakOnNonKeyFrames: Boolean,
val copyTimestamps: Boolean
)
internal object HlsPlaybackStability {
val conservativeHlsOptions = HlsStabilityOptions(
segmentLengthSeconds = 6,
minSegments = 3,
breakOnNonKeyFrames = false,
copyTimestamps = true
)
}

View File

@@ -29,7 +29,6 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
@@ -47,19 +46,6 @@ class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository,
) {
companion object {
private const val TAG = "JellyfinApiClient"
private const val MAX_STREAMING_BITRATE = 100_000_000
internal fun streamingHlsOptionsFor(
urlStrategy: PlaybackSourcePlanner.UrlStrategy
): HlsStabilityOptions? = when (urlStrategy) {
PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> null
PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM,
PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> HlsPlaybackStability.conservativeHlsOptions
}
}
private val jellyfin = createJellyfin {
context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
@@ -283,43 +269,18 @@ class JellyfinApiClient @Inject constructor(
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
requestPlaybackInfo(mediaId)?.mediaSources ?: emptyList()
}
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val playbackPlan = PlaybackSourcePlanner.plan(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
val selectedMediaSource = playbackPlan.mediaSource
val url = resolvePlaybackUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
urlStrategy = playbackPlan.urlStrategy,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
) ?: return@withContext null
val reportContext = PlaybackReportContext(
playMethod = playbackPlan.playMethod,
mediaSourceId = selectedMediaSource.id,
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
liveStreamId = selectedMediaSource.liveStreamId,
playSessionId = playbackInfo.playSessionId,
canRetryWithTranscoding = playbackPlan.canRetryWithTranscoding
)
Log.d(
TAG,
"Playback decision for $mediaId: ${playbackPlan.playMethod} using ${selectedMediaSource.id}" +
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
)
PlaybackDecision(
url = url,
mediaSource = selectedMediaSource,
reportContext = reportContext
)
val result = api.mediaInfoApi
.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
// TODO add supportedContainers
deviceProfile = null,
maxStreamingBitrate = 100_000_000,
),
)
Log.d("getMediaSources", result.toString())
result.content.mediaSources
}
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
@@ -327,26 +288,28 @@ class JellyfinApiClient @Inject constructor(
return@withContext null
}
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
// Check if transcoding is required based on the MediaSourceInfo from getMediaSources
val shouldTranscode = mediaSource.supportsTranscoding == true &&
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null
val url = resolvePlaybackUrl(
mediaId = mediaId,
mediaSource = playbackPlan.mediaSource,
urlStrategy = playbackPlan.urlStrategy,
playSessionId = null,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) {
// Use transcoding URL
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
"$baseUrl${mediaSource.transcodingUrl}"
} else {
// Use direct play URL
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = mediaSource.id,
)
}
Log.d(
TAG,
"Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" +
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
)
Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url")
url
}
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
@@ -355,24 +318,15 @@ class JellyfinApiClient @Inject constructor(
canSeek = true,
isPaused = false,
isMuted = false,
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod,
// TODO Send correct play method
playMethod = PlayMethod.DIRECT_PLAY,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT
)
)
}
suspend fun reportPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
reportContext: PlaybackReportContext
) = withContext(Dispatchers.IO) {
suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
@@ -381,197 +335,21 @@ class JellyfinApiClient @Inject constructor(
canSeek = true,
isPaused = isPaused,
isMuted = false,
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod,
playMethod = PlayMethod.DIRECT_PLAY,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT
)
)
}
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemId,
positionTicks = positionTicks,
mediaSourceId = reportContext.mediaSourceId,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
failed = false
)
)
}
private suspend fun requestPlaybackInfo(mediaId: UUID, forceTranscode: Boolean = false): PlaybackInfoResponse? {
if (!ensureConfigured()) {
return null
}
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
Log.d(
TAG,
"Playback request for $mediaId (forceTranscode=$forceTranscode) maxAudioChannels=${capabilitySnapshot.maxAudioChannels}, " +
"hlsOptions=${HlsPlaybackStability.conservativeHlsOptions}"
)
val response = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
deviceProfile = capabilitySnapshot.deviceProfile,
maxStreamingBitrate = MAX_STREAMING_BITRATE,
maxAudioChannels = capabilitySnapshot.maxAudioChannels,
enableDirectPlay = !forceTranscode,
enableDirectStream = !forceTranscode,
enableTranscoding = true,
allowVideoStreamCopy = !forceTranscode,
allowAudioStreamCopy = !forceTranscode,
alwaysBurnInSubtitleWhenTranscoding = false
)
)
logPlaybackInfo(
mediaId = mediaId,
playbackInfo = response.content,
forceTranscode = forceTranscode
)
return response.content
}
private suspend fun resolvePlaybackUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
urlStrategy: PlaybackSourcePlanner.UrlStrategy,
playSessionId: String?,
maxAudioChannels: Int
): String? {
return when (urlStrategy) {
PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = mediaSource.id,
playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId
)
PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM -> resolveDirectStreamUrl(
mediaId = mediaId,
mediaSource = mediaSource,
playSessionId = playSessionId,
maxAudioChannels = maxAudioChannels
)
PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = mediaSource,
playSessionId = playSessionId,
maxAudioChannels = maxAudioChannels
)
}
}
private suspend fun resolveDirectStreamUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
playSessionId: String?,
maxAudioChannels: Int
): String {
val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM)
?: error("Direct stream requests must use HLS stability options.")
Log.d(
TAG,
"Stream URL request for $mediaId strategy=DIRECT_STREAM container=${mediaSource.transcodingContainer ?: mediaSource.container}, " +
"hlsOptions=$hlsOptions"
)
return api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container,
segmentLength = hlsOptions.segmentLengthSeconds,
minSegments = hlsOptions.minSegments,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
copyTimestamps = hlsOptions.copyTimestamps,
maxAudioChannels = maxAudioChannels
)
}
private suspend fun resolveTranscodeUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
playSessionId: String?,
maxAudioChannels: Int
): String? {
val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE)
?: error("Transcode requests must use HLS stability options.")
mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl ->
Log.d(
TAG,
"Using server transcodingUrl for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container}, " +
"hlsOptions=$hlsOptions"
)
if (transcodingUrl.startsWith("http", ignoreCase = true)) {
return transcodingUrl
}
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
return "$baseUrl$transcodingUrl"
}
Log.d(
TAG,
"Stream URL request for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4"}, " +
"hlsOptions=$hlsOptions"
)
return api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4",
segmentLength = hlsOptions.segmentLengthSeconds,
minSegments = hlsOptions.minSegments,
enableAutoStreamCopy = false,
allowVideoStreamCopy = false,
allowAudioStreamCopy = false,
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
copyTimestamps = hlsOptions.copyTimestamps,
maxAudioChannels = maxAudioChannels
)
}
private fun logPlaybackInfo(
mediaId: UUID,
playbackInfo: PlaybackInfoResponse,
forceTranscode: Boolean
) {
Log.d(
TAG,
"PlaybackInfo for $mediaId (forceTranscode=$forceTranscode) -> " +
"sources=${playbackInfo.mediaSources.size}, session=${playbackInfo.playSessionId}, error=${playbackInfo.errorCode}"
)
playbackInfo.mediaSources.forEachIndexed { index, mediaSource ->
Log.d(
TAG,
"PlaybackInfo[$index] id=${mediaSource.id}, container=${mediaSource.container}, " +
"transcodingContainer=${mediaSource.transcodingContainer}, protocol=${mediaSource.protocol}, " +
"supportsDirectPlay=${mediaSource.supportsDirectPlay}, " +
"supportsDirectStream=${mediaSource.supportsDirectStream}, " +
"supportsTranscoding=${mediaSource.supportsTranscoding}, " +
"hasTranscodingUrl=${!mediaSource.transcodingUrl.isNullOrBlank()}"
)
}
}
}

View File

@@ -1,20 +0,0 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
data class PlaybackDecision(
val url: String,
val mediaSource: MediaSourceInfo,
val reportContext: PlaybackReportContext
)
data class PlaybackReportContext(
val playMethod: PlayMethod,
val mediaSourceId: String?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val liveStreamId: String?,
val playSessionId: String?,
val canRetryWithTranscoding: Boolean
)

View File

@@ -1,78 +0,0 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
internal object PlaybackSourcePlanner {
data class Plan(
val mediaSource: MediaSourceInfo,
val urlStrategy: UrlStrategy,
val playMethod: PlayMethod,
val canRetryWithTranscoding: Boolean,
val usedFallback: Boolean
)
enum class UrlStrategy {
DIRECT_PLAY,
DIRECT_STREAM,
TRANSCODE
}
fun plan(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): Plan? {
val selected = when {
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.TRANSCODE,
playMethod = PlayMethod.TRANSCODE
)
}
else -> mediaSources.firstOrNull { it.supportsDirectPlay }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.DIRECT_PLAY,
playMethod = PlayMethod.DIRECT_PLAY
)
} ?: mediaSources.firstOrNull { it.supportsDirectStream }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.DIRECT_STREAM,
playMethod = PlayMethod.DIRECT_STREAM
)
} ?: mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.TRANSCODE,
playMethod = PlayMethod.TRANSCODE
)
} ?: mediaSources.firstOrNull()?.let { mediaSource ->
PlannedSelection(
mediaSource = mediaSource,
urlStrategy = UrlStrategy.DIRECT_STREAM,
playMethod = PlayMethod.DIRECT_STREAM,
usedFallback = true
)
}
} ?: return null
return Plan(
mediaSource = selected.mediaSource,
urlStrategy = selected.urlStrategy,
playMethod = selected.playMethod,
canRetryWithTranscoding = !forceTranscode &&
selected.playMethod != PlayMethod.TRANSCODE &&
mediaSources.any { it.supportsTranscoding },
usedFallback = selected.usedFallback
)
}
fun plan(mediaSource: MediaSourceInfo): Plan? = plan(listOf(mediaSource), forceTranscode = false)
private data class PlannedSelection(
val mediaSource: MediaSourceInfo,
val urlStrategy: UrlStrategy,
val playMethod: PlayMethod,
val usedFallback: Boolean = false
)
}

View File

@@ -34,11 +34,9 @@ dependencies {
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.medi3.exoplayer)
implementation(libs.medi3.exoplayer.hls)
implementation(libs.media3.datasource.okhttp)
implementation(libs.datastore)
implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core)
implementation(libs.okhttp)
testImplementation(libs.junit)
}

View File

@@ -1,15 +0,0 @@
package hu.bbara.purefin.core.player.data
import androidx.media3.common.MediaItem
import hu.bbara.purefin.core.player.model.PlayerError
sealed interface PlayerMediaLoadResult {
data class Success(
val mediaItem: MediaItem,
val resumePositionMs: Long?
) : PlayerMediaLoadResult
data class Failure(
val error: PlayerError
) : PlayerMediaLoadResult
}

View File

@@ -1,71 +1,104 @@
package hu.bbara.purefin.core.player.data
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.player.model.PlayerError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import java.util.UUID
import javax.inject.Inject
@ViewModelScoped
class PlayerMediaRepository @OptIn(UnstableApi::class)
@Inject constructor(
class PlayerMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository,
private val mediaRepository: MediaRepository,
private val downloadManager: DownloadManager
private val userSessionRepository: UserSessionRepository
) {
companion object {
private const val TAG = "PlayerMediaRepo"
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource
) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
// Calculate resume position
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
title = baseItem?.name ?: selectedMediaSource.name!!,
subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}",
artworkUrl = artworkUrl,
)
Pair(mediaItem, resumePositionMs)
}
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) {
is PlayerMediaLoadResult.Success -> onlineResult
is PlayerMediaLoadResult.Failure -> {
when (val offlineResult = buildOfflineMediaItem(mediaId)) {
is OfflineLoadResult.Success -> {
Log.w(
TAG,
"Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}"
)
offlineResult.result
}
is OfflineLoadResult.Unavailable -> {
val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail)
Log.w(
TAG,
"Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}"
)
PlayerMediaLoadResult.Failure(finalError)
}
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
runCatching {
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
episodes.mapNotNull { episode ->
val id = episode.id ?: return@mapNotNull null
val stringId = id.toString()
if (existingIds.contains(stringId)) {
return@mapNotNull null
}
val mediaSources = jellyfinApiClient.getMediaSources(id)
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = id,
mediaSource = selectedMediaSource
) ?: return@mapNotNull null
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
createMediaItem(
mediaId = stringId,
playbackUrl = playbackUrl,
title = episode.name ?: selectedMediaSource.name!!,
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
artworkUrl = artworkUrl,
)
}
}.getOrElse { error ->
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
emptyList()
}
}
private fun createMediaItem(
mediaId: String,
playbackUrl: String,
title: String,
subtitle: String?,
artworkUrl: String,
): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setArtworkUri(artworkUrl.toUri())
.build()
return MediaItem.Builder()
.setUri(playbackUrl.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.build()
}
private fun calculateResumePosition(
baseItem: BaseItemDto?,
mediaSource: MediaSourceInfo
@@ -89,225 +122,4 @@ class PlayerMediaRepository @OptIn(UnstableApi::class)
// Apply thresholds: resume only if 5% ≤ progress ≤ 95%
return if (percentage in 5.0..95.0) positionMs else null
}
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
runCatching {
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
episodes.mapNotNull { episode ->
val id = episode.id ?: return@mapNotNull null
val stringId = id.toString()
if (existingIds.contains(stringId)) {
return@mapNotNull null
}
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
val selectedMediaSource = playbackDecision.mediaSource
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
createMediaItem(
mediaId = stringId,
playbackUrl = playbackDecision.url,
title = episode.name ?: selectedMediaSource.name!!,
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs,
tag = playbackDecision.reportContext
)
}
}.getOrElse { error ->
Log.w(TAG, "Unable to load next-up items for $episodeId", error)
emptyList()
}
}
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
return try {
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
mediaId = mediaId,
forceTranscode = forceTranscode
) ?: run {
val detail = if (forceTranscode) {
"Jellyfin did not return a transcoded playback source."
} else {
"Jellyfin did not return a playable media source."
}
Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)")
return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail))
}
val selectedMediaSource = playbackDecision.mediaSource
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = playbackDecision.url,
title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs,
tag = playbackDecision.reportContext
)
PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
} catch (error: Exception) {
Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
}
}
@OptIn(UnstableApi::class)
private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
return try {
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
?.takeIf { it.state == Download.STATE_COMPLETED }
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
val serverUrl = userSessionRepository.serverUrl.first()
val movie = mediaRepository.movies.value[mediaId]
val episode = mediaRepository.episodes.value[mediaId]
val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank {
"Offline media"
}
val subtitle = episode?.let { episodeSubtitle(null, it.index) }
val artworkUrl = when {
movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY)
episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY)
else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
}
val resumePositionMs = resumePositionFor(movie, episode)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = download.request.uri.toString(),
title = title,
subtitle = subtitle,
artworkUrl = artworkUrl,
)
OfflineLoadResult.Success(PlayerMediaLoadResult.Success(mediaItem, resumePositionMs))
} catch (error: Exception) {
Log.w(TAG, "Offline fallback failed for $mediaId", error)
val technicalDetail = PlayerError.fromThrowable(error).detailText ?: error.javaClass.simpleName
OfflineLoadResult.Unavailable(
"Offline fallback failed: $technicalDetail"
)
}
}
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? {
val progress = movie?.progress ?: episode?.progress ?: return null
val runtime = movie?.runtime ?: episode?.runtime ?: return null
val durationMs = parseRuntimeToMs(runtime) ?: return null
if (durationMs <= 0L) return null
return (durationMs * (progress / 100.0)).toLong().takeIf { it > 0L }
}
private fun parseRuntimeToMs(runtime: String): Long? {
val trimmed = runtime.trim()
if (trimmed.isBlank() || trimmed == "") return null
val hourMatch = Regex("(\\d+)h").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
val minuteMatch = Regex("(\\d+)m").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
val totalMinutes = hourMatch * 60L + minuteMatch
return if (totalMinutes > 0L) totalMinutes * 60_000L else null
}
private fun episodeSubtitle(seasonNumber: Int?, episodeNumber: Int?): String? {
if (seasonNumber == null && episodeNumber == null) return null
return buildString {
if (seasonNumber != null) append("S").append(seasonNumber)
if (episodeNumber != null) {
if (isNotEmpty()) append(":")
append("E").append(episodeNumber)
}
}
}
@OptIn(UnstableApi::class)
private fun buildExternalSubtitleConfigs(
serverUrl: String,
mediaId: UUID,
mediaSource: MediaSourceInfo
): List<MediaItem.SubtitleConfiguration> {
val streams = mediaSource.mediaStreams ?: return emptyList()
val mediaSourceId = mediaSource.id ?: return emptyList()
val baseUrl = serverUrl.trimEnd('/')
return streams
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL }
.mapNotNull { stream ->
val codec = stream.codec ?: return@mapNotNull null
val mimeType = subtitleCodecToMimeType(codec) ?: return@mapNotNull null
// Use deliveryUrl from server if available, otherwise construct it
val url = if (!stream.deliveryUrl.isNullOrBlank()) {
if (stream.deliveryUrl!!.startsWith("http")) {
stream.deliveryUrl!!
} else {
"$baseUrl${stream.deliveryUrl}"
}
} else {
val format = if (codec == "subrip") "srt" else codec
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
}
Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url")
MediaItem.SubtitleConfiguration.Builder(url.toUri())
.setMimeType(mimeType)
.setLanguage(stream.language)
.setLabel(stream.displayTitle ?: stream.language ?: "Track ${stream.index}")
.setSelectionFlags(
if (stream.isForced) C.SELECTION_FLAG_FORCED
else if (stream.isDefault) C.SELECTION_FLAG_DEFAULT
else 0
)
.build()
}
}
@OptIn(UnstableApi::class)
private fun subtitleCodecToMimeType(codec: String): String? = when (codec.lowercase()) {
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
"ass", "ssa" -> MimeTypes.TEXT_SSA
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"ttml", "dfxp" -> MimeTypes.APPLICATION_TTML
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
else -> {
Log.w(TAG, "Unknown subtitle codec: $codec")
null
}
}
private fun createMediaItem(
mediaId: String,
playbackUrl: String,
title: String,
subtitle: String?,
artworkUrl: String,
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
tag: PlaybackReportContext? = null
): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setArtworkUri(artworkUrl.toUri())
.build()
return MediaItem.Builder()
.setUri(playbackUrl.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setTag(tag)
.setSubtitleConfigurations(subtitleConfigurations)
.build()
}
private sealed interface OfflineLoadResult {
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
data class Unavailable(val detail: String) : OfflineLoadResult
}
}

View File

@@ -1,43 +0,0 @@
package hu.bbara.purefin.core.player.manager
import kotlin.math.abs
internal class PendingSeekTracker(
private val settleToleranceMs: Long = 500L
) {
private var pendingSeek: PendingSeek? = null
fun currentPosition(playerPositionMs: Long): Long {
val pending = pendingSeek ?: return playerPositionMs
if (pending.isSatisfiedBy(playerPositionMs, settleToleranceMs)) {
pendingSeek = null
return playerPositionMs
}
return pending.targetPositionMs
}
fun recordSeek(basePositionMs: Long, targetPositionMs: Long): Long {
val target = targetPositionMs.coerceAtLeast(0L)
pendingSeek = PendingSeek(
targetPositionMs = target,
direction = target.compareTo(basePositionMs)
)
return target
}
fun clear() {
pendingSeek = null
}
private data class PendingSeek(
val targetPositionMs: Long,
val direction: Int
) {
fun isSatisfiedBy(playerPositionMs: Long, settleToleranceMs: Long): Boolean =
when {
direction > 0 -> playerPositionMs >= targetPositionMs - settleToleranceMs
direction < 0 -> playerPositionMs <= targetPositionMs + settleToleranceMs
else -> abs(playerPositionMs - targetPositionMs) <= settleToleranceMs
}
}
}

View File

@@ -9,8 +9,6 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
@@ -45,7 +43,6 @@ class PlayerManager @Inject constructor(
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val pendingSeekTracker = PendingSeekTracker()
private var currentMediaContext: MediaContext? = null
@@ -83,11 +80,7 @@ class PlayerManager @Inject constructor(
}
override fun onPlayerError(error: PlaybackException) {
_playbackState.update {
it.copy(
error = PlayerError.fromPlaybackException(error)
)
}
_playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
}
override fun onTracksChanged(tracks: Tracks) {
@@ -98,22 +91,9 @@ class PlayerManager @Inject constructor(
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
pendingSeekTracker.clear()
refreshMetadata(mediaItem)
refreshQueue()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
if (reason == Player.DISCONTINUITY_REASON_SEEK &&
newPosition.positionMs < oldPosition.positionMs
) {
refreshSubtitleRendererOnBackwardSeek()
}
}
}
init {
@@ -124,36 +104,9 @@ class PlayerManager @Inject constructor(
startProgressLoop()
}
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
currentMediaContext = mediaContext
pendingSeekTracker.clear()
if (startPositionMs != null) {
player.setMediaItem(mediaItem, startPositionMs)
} else {
player.setMediaItem(mediaItem)
}
player.prepare()
player.playWhenReady = true
_progress.value = PlaybackProgressSnapshot()
refreshMetadata(mediaItem)
refreshQueue()
_playbackState.update { it.copy(isEnded = false, error = null) }
}
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
currentMediaContext = mediaContext
pendingSeekTracker.clear()
val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run {
play(mediaItem, mediaContext, startPositionMs)
return
}
player.replaceMediaItem(currentIndex, mediaItem)
if (startPositionMs != null) {
player.seekTo(currentIndex, startPositionMs)
} else {
player.seekToDefaultPosition(currentIndex)
}
player.setMediaItem(mediaItem)
player.prepare()
player.playWhenReady = true
refreshMetadata(mediaItem)
@@ -171,17 +124,16 @@ class PlayerManager @Inject constructor(
}
fun seekTo(positionMs: Long) {
requestSeek(positionMs)
player.seekTo(positionMs)
}
fun seekBy(deltaMs: Long) {
val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition)
requestSeek(basePositionMs + deltaMs, basePositionMs)
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L)
seekTo(target)
}
fun seekToLiveEdge() {
if (player.isCurrentMediaItemLive) {
pendingSeekTracker.clear()
player.seekToDefaultPosition()
player.play()
}
@@ -189,14 +141,12 @@ class PlayerManager @Inject constructor(
fun next() {
if (player.hasNextMediaItem()) {
pendingSeekTracker.clear()
player.seekToNextMediaItem()
}
}
fun previous() {
if (player.hasPreviousMediaItem()) {
pendingSeekTracker.clear()
player.seekToPreviousMediaItem()
}
}
@@ -258,7 +208,6 @@ 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()
@@ -269,35 +218,6 @@ class PlayerManager @Inject constructor(
_playbackState.update { it.copy(error = null) }
}
fun snapshotProgress(): PlaybackProgressSnapshot {
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
return PlaybackProgressSnapshot(
durationMs = duration,
positionMs = pendingSeekTracker.currentPosition(player.currentPosition),
bufferedMs = player.bufferedPosition,
isLive = player.isCurrentMediaItemLive
)
}
private fun requestSeek(positionMs: Long, basePositionMs: Long = pendingSeekTracker.currentPosition(player.currentPosition)) {
val targetPositionMs = clampSeekPosition(positionMs)
pendingSeekTracker.recordSeek(basePositionMs = basePositionMs, targetPositionMs = targetPositionMs)
_progress.update {
it.copy(
durationMs = player.duration.takeIf { value -> value > 0L } ?: it.durationMs,
positionMs = targetPositionMs,
bufferedMs = player.bufferedPosition,
isLive = player.isCurrentMediaItemLive
)
}
player.seekTo(targetPositionMs)
}
private fun clampSeekPosition(positionMs: Long): Long {
val durationMs = player.duration.takeIf { it > 0L } ?: _progress.value.durationMs.takeIf { it > 0L }
return durationMs?.let { positionMs.coerceIn(0L, it) } ?: positionMs.coerceAtLeast(0L)
}
private suspend fun applyTrackPreferences() {
val context = currentMediaContext ?: return
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return
@@ -350,17 +270,6 @@ class PlayerManager @Inject constructor(
}
}
private fun refreshSubtitleRendererOnBackwardSeek() {
val currentParams = player.trackSelectionParameters
if (C.TRACK_TYPE_TEXT in currentParams.disabledTrackTypes) return
scope.launch {
player.trackSelectionParameters = currentParams.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
player.trackSelectionParameters = currentParams
}
}
fun release() {
scope.cancel()
player.removeListener(listener)
@@ -370,7 +279,15 @@ class PlayerManager @Inject constructor(
private fun startProgressLoop() {
scope.launch {
while (isActive) {
_progress.value = snapshotProgress()
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
val position = player.currentPosition
val buffered = player.bufferedPosition
_progress.value = PlaybackProgressSnapshot(
durationMs = duration,
positionMs = position,
bufferedMs = buffered,
isLive = player.isCurrentMediaItemLive
)
delay(500)
}
}
@@ -394,12 +311,10 @@ class PlayerManager @Inject constructor(
}
private fun refreshMetadata(mediaItem: MediaItem?) {
val playbackReportContext = mediaItem?.localConfiguration?.tag as? PlaybackReportContext
_metadata.value = MetadataState(
mediaId = mediaItem?.mediaId,
title = mediaItem?.mediaMetadata?.title?.toString(),
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(),
playbackReportContext = playbackReportContext
)
}
@@ -412,7 +327,7 @@ data class PlaybackStateSnapshot(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val isEnded: Boolean = false,
val error: PlayerError? = null
val error: String? = null
)
data class PlaybackProgressSnapshot(
@@ -426,7 +341,6 @@ data class MetadataState(
val mediaId: String? = null,
val title: String? = null,
val subtitle: String? = null,
val playbackReportContext: PlaybackReportContext? = null
)
data class MediaContext(

View File

@@ -3,7 +3,6 @@ package hu.bbara.purefin.core.player.manager
import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -26,16 +25,10 @@ class ProgressManager @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null
private var activeItemId: UUID? = null
private var activePlaybackReportContext: PlaybackReportContext? = null
private var lastPositionMs: Long = 0L
private var lastDurationMs: Long = 0L
private var isPaused: Boolean = false
fun syncProgress(snapshot: PlaybackProgressSnapshot) {
lastPositionMs = snapshot.positionMs
lastDurationMs = snapshot.durationMs
}
fun bind(
playbackState: StateFlow<PlaybackStateSnapshot>,
progress: StateFlow<PlaybackProgressSnapshot>,
@@ -49,7 +42,6 @@ class ProgressManager @Inject constructor(
lastDurationMs = prog.durationMs
isPaused = !state.isPlaying
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
activePlaybackReportContext = meta.playbackReportContext
// Media changed or ended - stop session
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
@@ -58,20 +50,19 @@ class ProgressManager @Inject constructor(
// Start session when we have a media item and none is active
if (activeItemId == null && mediaId != null && !state.isEnded) {
startSession(mediaId, prog.positionMs, meta.playbackReportContext)
startSession(mediaId, prog.positionMs)
}
}
}
}
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
private fun startSession(itemId: UUID, positionMs: Long) {
activeItemId = itemId
activePlaybackReportContext = reportContext
report(itemId, positionMs, reportContext = reportContext, isStart = true)
report(itemId, positionMs, isStart = true)
progressJob = scope.launch {
while (isActive) {
delay(5000)
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
report(itemId, lastPositionMs, isPaused = isPaused)
}
}
}
@@ -79,7 +70,7 @@ class ProgressManager @Inject constructor(
private fun stopSession() {
progressJob?.cancel()
activeItemId?.let { itemId ->
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
report(itemId, lastPositionMs, isStop = true)
scope.launch(Dispatchers.IO) {
try {
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
@@ -89,13 +80,11 @@ class ProgressManager @Inject constructor(
}
}
activeItemId = null
activePlaybackReportContext = null
}
private fun report(
itemId: UUID,
positionMs: Long,
reportContext: PlaybackReportContext?,
isPaused: Boolean = false,
isStart: Boolean = false,
isStop: Boolean = false
@@ -103,13 +92,10 @@ class ProgressManager @Inject constructor(
val ticks = positionMs * 10_000
scope.launch(Dispatchers.IO) {
try {
if (reportContext == null) {
return@launch
}
when {
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
}
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
} catch (e: Exception) {
@@ -126,9 +112,7 @@ class ProgressManager @Inject constructor(
val durMs = lastDurationMs
CoroutineScope(Dispatchers.IO).launch {
try {
activePlaybackReportContext?.let { reportContext ->
jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
}
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
updateWatchProgressUseCase(itemId, posMs, durMs)
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
} catch (e: Exception) {

View File

@@ -1,110 +0,0 @@
package hu.bbara.purefin.core.player.model
import androidx.media3.common.PlaybackException
enum class PlayerErrorSource {
LOAD,
PLAYBACK
}
data class PlayerError(
val summary: String,
val technicalDetail: String? = null,
val source: PlayerErrorSource,
val errorCode: Int? = null,
val errorCodeName: String? = null,
val retryable: Boolean = false
) {
val detailText: String?
get() = technicalDetail?.takeIf { detail ->
detail.isNotBlank() && !detail.equals(summary, ignoreCase = true)
}
fun withAdditionalTechnicalDetail(additionalDetail: String?): PlayerError {
val normalizedAdditionalDetail = additionalDetail?.trim()?.takeIf { it.isNotEmpty() } ?: return this
val mergedDetail = linkedSetOf<String>().apply {
technicalDetail?.trim()?.takeIf { it.isNotEmpty() }?.let(::add)
add(normalizedAdditionalDetail)
}.joinToString(" | ")
return copy(technicalDetail = mergedDetail)
}
companion object {
fun loadFailure(
summary: String = "Unable to load media",
technicalDetail: String? = null,
retryable: Boolean = true
): PlayerError {
return PlayerError(
summary = summary,
technicalDetail = technicalDetail,
source = PlayerErrorSource.LOAD,
retryable = retryable
)
}
fun invalidMediaId(mediaId: String): PlayerError {
return PlayerError(
summary = "Invalid media id",
technicalDetail = "The requested media id is not a valid UUID: $mediaId",
source = PlayerErrorSource.LOAD,
retryable = false
)
}
fun fromPlaybackException(error: PlaybackException): PlayerError {
return playbackFailure(
errorCode = error.errorCode,
errorCodeName = error.errorCodeName,
technicalDetail = error.localizedMessage,
cause = error.cause
)
}
fun playbackFailure(
errorCode: Int? = null,
errorCodeName: String? = null,
technicalDetail: String? = null,
cause: Throwable? = null,
retryable: Boolean = true
): PlayerError {
val mergedTechnicalDetail = linkedSetOf<String>().apply {
errorCodeName?.takeIf { it.isNotBlank() }?.let(::add)
technicalDetail?.takeIf { it.isNotBlank() }?.let(::add)
cause?.toTechnicalDetail()?.let(::add)
}.joinToString(" | ").ifBlank { null }
return PlayerError(
summary = "Playback error",
technicalDetail = mergedTechnicalDetail,
source = PlayerErrorSource.PLAYBACK,
errorCode = errorCode,
errorCodeName = errorCodeName,
retryable = retryable
)
}
fun fromThrowable(
throwable: Throwable,
summary: String = "Unable to load media",
retryable: Boolean = true
): PlayerError {
return loadFailure(
summary = summary,
technicalDetail = throwable.toTechnicalDetail(),
retryable = retryable
)
}
internal fun Throwable.toTechnicalDetail(): String {
val typeName = this::class.simpleName ?: javaClass.simpleName.ifBlank { javaClass.name }
val message = message?.trim().takeIf { !it.isNullOrEmpty() }
return if (message != null) {
"$typeName: $message"
} else {
typeName
}
}
}
}

View File

@@ -10,7 +10,7 @@ data class PlayerUiState(
val durationMs: Long = 0L,
val positionMs: Long = 0L,
val bufferedMs: Long = 0L,
val error: PlayerError? = null,
val error: String? = null,
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(),

View File

@@ -18,7 +18,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.AndroidDeviceProfile
@Module
@InstallIn(ViewModelComponent::class)
@@ -28,7 +27,6 @@ object VideoPlayerModule {
@Provides
@ViewModelScoped
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(application)
val trackSelector = DefaultTrackSelector(application)
val audioAttributes =
AudioAttributes.Builder()
@@ -39,24 +37,15 @@ object VideoPlayerModule {
trackSelector.setParameters(
trackSelector
.buildUponParameters()
.setMaxAudioChannelCount(capabilitySnapshot.maxAudioChannels)
.setExceedAudioConstraintsIfNecessary(false)
.setTunnelingEnabled(false)
// .setPreferredAudioLanguage(
// appPreferences.getValue(appPreferences.preferredAudioLanguage)
// )
// .setPreferredTextLanguage(
// appPreferences.getValue(appPreferences.preferredSubtitleLanguage)
// )
)
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
15_000, // minBufferMs
50_000, // maxBufferMs
2_500, // bufferForPlaybackMs
5_000 // bufferForPlaybackAfterRebufferMs
10_000,
30_000,
5_000,
5_000
)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
// Configure RenderersFactory to use all available decoders and enable passthrough

View File

@@ -1,58 +0,0 @@
package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.PlayerErrorSource
import org.jellyfin.sdk.model.api.PlayMethod
internal object PlaybackRetryPolicy {
private val retryableErrorCodes = setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
)
fun shouldRetryWithTranscoding(
error: PlayerError,
playbackReportContext: PlaybackReportContext
): Boolean {
if (error.source != PlayerErrorSource.PLAYBACK) {
return false
}
if (!playbackReportContext.canRetryWithTranscoding) {
return false
}
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
return false
}
val errorCode = error.errorCode
if (errorCode != null && errorCode in retryableErrorCodes) {
return true
}
val detail = buildString {
append(error.summary)
error.errorCodeName?.let {
append(' ')
append(it)
}
error.detailText?.let {
append(' ')
append(it)
}
}.lowercase()
return "decoder" in detail ||
"codec" in detail ||
"unsupported" in detail ||
"audiotrack" in detail ||
"audio sink" in detail
}
}

View File

@@ -5,14 +5,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.player.data.PlayerMediaLoadResult
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
import hu.bbara.purefin.core.player.manager.MediaContext
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
import hu.bbara.purefin.core.player.manager.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.PlayerErrorSource
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption
import kotlinx.coroutines.Job
@@ -46,10 +42,7 @@ class PlayerViewModel @Inject constructor(
private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null
private var loadError: PlayerError? = null
private var activeMediaId: String? = null
private var transcodingRetryMediaId: String? = null
private var lastLoadRequest: PendingLoadRequest? = null
private var dataErrorMessage: String? = null
init {
progressManager.bind(
@@ -64,16 +57,12 @@ class PlayerViewModel @Inject constructor(
private fun observePlayerState() {
viewModelScope.launch {
playerManager.playbackState.collect { state ->
if (state.error != null && maybeRetryWithTranscoding(state)) {
_uiState.update { it.copy(isBuffering = true, error = null) }
return@collect
}
_uiState.update {
it.copy(
isPlaying = state.isPlaying,
isBuffering = state.isBuffering,
isEnded = state.isEnded,
error = state.error ?: loadError
error = state.error ?: dataErrorMessage
)
}
if (state.isEnded) {
@@ -104,10 +93,6 @@ class PlayerViewModel @Inject constructor(
)
}
val currentMediaId = metadata.mediaId
if (currentMediaId != activeMediaId) {
activeMediaId = currentMediaId
transcodingRetryMediaId = null
}
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
lastNextUpMediaId = currentMediaId
loadNextUp(currentMediaId)
@@ -148,79 +133,37 @@ class PlayerViewModel @Inject constructor(
}
private fun loadMediaById(id: String) {
loadMediaById(id = id, forceTranscode = false, startPositionMsOverride = null, replaceCurrent = false)
}
private fun loadMediaById(
id: String,
forceTranscode: Boolean,
startPositionMsOverride: Long?,
replaceCurrent: Boolean
) {
lastLoadRequest = PendingLoadRequest(
id = id,
forceTranscode = forceTranscode,
startPositionMsOverride = startPositionMsOverride,
replaceCurrent = replaceCurrent
)
val uuid = id.toUuidOrNull()
if (uuid == null) {
loadError = PlayerError.invalidMediaId(id)
_uiState.update { it.copy(isBuffering = false, error = loadError) }
dataErrorMessage = "Invalid media id"
_uiState.update { it.copy(error = dataErrorMessage) }
return
}
loadError = null
_uiState.update { it.copy(isBuffering = true, error = null) }
viewModelScope.launch {
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
when (result) {
is PlayerMediaLoadResult.Success -> {
val mediaItem = result.mediaItem
val resumePositionMs = result.resumePositionMs
val result = playerMediaRepository.getMediaItem(uuid)
if (result != null) {
val (mediaItem, resumePositionMs) = result
// Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
val startPositionMs = startPositionMsOverride ?: resumePositionMs
// Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
if (replaceCurrent) {
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
} else {
playerManager.play(mediaItem, mediaContext, startPositionMs)
}
playerManager.play(mediaItem, mediaContext)
if (loadError != null) {
loadError = null
_uiState.update { it.copy(error = null) }
}
}
is PlayerMediaLoadResult.Failure -> {
loadError = result.error
_uiState.update { it.copy(isBuffering = false, error = loadError) }
// Seek to resume position after play() is called
resumePositionMs?.let { playerManager.seekTo(it) }
if (dataErrorMessage != null) {
dataErrorMessage = null
_uiState.update { it.copy(error = null) }
}
} else {
dataErrorMessage = "Unable to load media"
_uiState.update { it.copy(error = dataErrorMessage) }
}
}
}
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
val error = state.error ?: return false
if (currentMediaId == transcodingRetryMediaId) return false
if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false
transcodingRetryMediaId = currentMediaId
loadMediaById(
id = currentMediaId,
forceTranscode = true,
startPositionMsOverride = player.currentPosition.takeIf { it > 0L },
replaceCurrent = true
)
return true
}
private fun loadNextUp(currentMediaId: String) {
val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch {
@@ -260,11 +203,6 @@ class PlayerViewModel @Inject constructor(
if (_controlsVisible.value) scheduleAutoHide()
}
fun hideControls() {
_controlsVisible.value = false
autoHideJob?.cancel()
}
private fun scheduleAutoHide() {
autoHideJob?.cancel()
if (!player.isPlaying) return
@@ -294,11 +232,6 @@ class PlayerViewModel @Inject constructor(
}
fun retry() {
val error = uiState.value.error
if (error?.source == PlayerErrorSource.LOAD) {
retryLoad()
return
}
playerManager.retry()
}
@@ -308,7 +241,7 @@ class PlayerViewModel @Inject constructor(
}
fun clearError() {
loadError = null
dataErrorMessage = null
playerManager.clearError()
_uiState.update { it.copy(error = null) }
}
@@ -316,27 +249,9 @@ class PlayerViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
autoHideJob?.cancel()
progressManager.syncProgress(playerManager.snapshotProgress())
progressManager.release()
playerManager.release()
}
private fun retryLoad() {
val request = lastLoadRequest ?: return
loadMediaById(
id = request.id,
forceTranscode = request.forceTranscode,
startPositionMsOverride = request.startPositionMsOverride,
replaceCurrent = request.replaceCurrent
)
}
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
private data class PendingLoadRequest(
val id: String,
val forceTranscode: Boolean,
val startPositionMsOverride: Long?,
val replaceCurrent: Boolean
)
}

View File

@@ -1,55 +0,0 @@
package hu.bbara.purefin.core.player.manager
import org.junit.Assert.assertEquals
import org.junit.Test
class PendingSeekTrackerTest {
@Test
fun repeatedForwardSeeksAccumulateWhilePlayerPositionIsStale() {
val tracker = PendingSeekTracker()
val firstBase = tracker.currentPosition(playerPositionMs = 30_000L)
tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 40_000L)
val secondBase = tracker.currentPosition(playerPositionMs = 30_000L)
tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase + 10_000L)
assertEquals(50_000L, tracker.currentPosition(playerPositionMs = 30_000L))
}
@Test
fun repeatedBackwardSeeksAccumulateWhilePlayerPositionIsStale() {
val tracker = PendingSeekTracker()
val firstBase = tracker.currentPosition(playerPositionMs = 30_000L)
tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 20_000L)
val secondBase = tracker.currentPosition(playerPositionMs = 30_000L)
tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase - 10_000L)
assertEquals(10_000L, tracker.currentPosition(playerPositionMs = 30_000L))
}
@Test
fun pendingForwardSeekClearsOncePlayerCatchesUp() {
val tracker = PendingSeekTracker()
tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 40_000L)
assertEquals(40_000L, tracker.currentPosition(playerPositionMs = 30_000L))
assertEquals(39_700L, tracker.currentPosition(playerPositionMs = 39_700L))
assertEquals(41_000L, tracker.currentPosition(playerPositionMs = 41_000L))
}
@Test
fun pendingBackwardSeekClearsOncePlayerCatchesUp() {
val tracker = PendingSeekTracker()
tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 20_000L)
assertEquals(20_000L, tracker.currentPosition(playerPositionMs = 30_000L))
assertEquals(20_300L, tracker.currentPosition(playerPositionMs = 20_300L))
assertEquals(19_000L, tracker.currentPosition(playerPositionMs = 19_000L))
}
}

View File

@@ -1,48 +0,0 @@
package hu.bbara.purefin.core.player.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PlayerErrorTest {
@Test
fun `playbackFailure preserves code and detail`() {
val playerError = PlayerError.playbackFailure(
errorCode = 4001,
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
technicalDetail = "Decoder init failed on test device"
)
assertEquals("Playback error", playerError.summary)
assertEquals(PlayerErrorSource.PLAYBACK, playerError.source)
assertEquals(4001, playerError.errorCode)
assertEquals("ERROR_CODE_DECODER_INIT_FAILED", playerError.errorCodeName)
assertNotNull(playerError.detailText)
assertTrue(playerError.detailText!!.contains("ERROR_CODE_DECODER_INIT_FAILED"))
assertTrue(playerError.detailText!!.contains("Decoder init failed on test device"))
}
@Test
fun `withAdditionalTechnicalDetail appends distinct detail`() {
val playerError = PlayerError.loadFailure(technicalDetail = "IllegalStateException: boom")
val mergedError = playerError.withAdditionalTechnicalDetail(
"Offline fallback unavailable: no completed download."
)
assertTrue(mergedError.detailText!!.contains("IllegalStateException: boom"))
assertTrue(mergedError.detailText!!.contains("Offline fallback unavailable: no completed download."))
}
@Test
fun `invalidMediaId is a non retryable load error`() {
val playerError = PlayerError.invalidMediaId("abc")
assertEquals("Invalid media id", playerError.summary)
assertEquals(PlayerErrorSource.LOAD, playerError.source)
assertFalse(playerError.retryable)
assertTrue(playerError.detailText!!.contains("abc"))
}
}

View File

@@ -1,93 +0,0 @@
package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.PlayerErrorSource
import org.jellyfin.sdk.model.api.PlayMethod
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackRetryPolicyTest {
@Test
fun `decoder failures are retried when transcoding is available`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "Decoder init failed",
source = PlayerErrorSource.PLAYBACK,
errorCode = PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
retryable = true
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
@Test
fun `transcoded playback is not retried again`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "Unsupported container",
source = PlayerErrorSource.PLAYBACK,
errorCodeName = "ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED",
retryable = true
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.TRANSCODE)
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
@Test
fun `load errors are never retried as transcoding fallbacks`() {
val error = PlayerError.loadFailure(technicalDetail = "No playable source was returned.")
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
@Test
fun `unsupported codec detail is retryable without an error code`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "Codec unsupported by this device",
source = PlayerErrorSource.PLAYBACK
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_STREAM)
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
@Test
fun `audio track init failures are retried when transcoding is available`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "AudioTrack init failed",
source = PlayerErrorSource.PLAYBACK,
errorCode = PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
errorCodeName = "ERROR_CODE_AUDIO_TRACK_INIT_FAILED",
retryable = true
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext {
return PlaybackReportContext(
playMethod = playMethod,
mediaSourceId = "source-id",
audioStreamIndex = 0,
subtitleStreamIndex = null,
liveStreamId = null,
playSessionId = "session-id",
canRetryWithTranscoding = true
)
}
}