mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
refactor: Throw out overcomplicated shit code for Playback
This commit is contained in:
@@ -77,12 +77,12 @@ fun TvPlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = controlsVisible) {
|
BackHandler(enabled = controlsVisible) {
|
||||||
viewModel.hideControls()
|
viewModel.toggleControlsVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(uiState.isPlaying) {
|
LaunchedEffect(uiState.isPlaying) {
|
||||||
if (uiState.isPlaying) {
|
if (uiState.isPlaying && controlsVisible) {
|
||||||
viewModel.hideControls()
|
viewModel.toggleControlsVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||||
|
|
||||||
@@ -48,20 +47,11 @@ internal fun TvPlayerLoadingErrorEndCard(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = uiState.error?.summary ?: "Playback error",
|
text = uiState.error ?: "Playback error",
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
style = MaterialTheme.typography.titleMedium
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = onRetry) {
|
Button(onClick = onRetry) {
|
||||||
Text("Retry")
|
Text("Retry")
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||||
@@ -47,19 +46,10 @@ fun PlayerLoadingErrorEndCard(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = uiState.error?.summary ?: "Playback error",
|
text = uiState.error ?: "Playback error",
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontWeight = FontWeight.Bold
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = onRetry) {
|
Button(onClick = onRetry) {
|
||||||
Text("Retry")
|
Text("Retry")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo
|
|||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
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.PlaybackOrder
|
||||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||||
@@ -47,19 +46,6 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
@ApplicationContext private val applicationContext: Context,
|
@ApplicationContext private val applicationContext: Context,
|
||||||
private val userSessionRepository: UserSessionRepository,
|
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 {
|
private val jellyfin = createJellyfin {
|
||||||
context = applicationContext
|
context = applicationContext
|
||||||
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
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) {
|
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
||||||
requestPlaybackInfo(mediaId)?.mediaSources ?: emptyList()
|
val result = api.mediaInfoApi
|
||||||
}
|
.getPostedPlaybackInfo(
|
||||||
|
mediaId,
|
||||||
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
|
PlaybackInfoDto(
|
||||||
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
|
userId = getUserId(),
|
||||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
// TODO add supportedContainers
|
||||||
val playbackPlan = PlaybackSourcePlanner.plan(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
|
deviceProfile = null,
|
||||||
val selectedMediaSource = playbackPlan.mediaSource
|
maxStreamingBitrate = 100_000_000,
|
||||||
|
),
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
Log.d("getMediaSources", result.toString())
|
||||||
|
result.content.mediaSources
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
|
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
|
||||||
@@ -327,26 +288,28 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
return@withContext null
|
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 = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) {
|
||||||
val url = resolvePlaybackUrl(
|
// Use transcoding URL
|
||||||
mediaId = mediaId,
|
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
|
||||||
mediaSource = playbackPlan.mediaSource,
|
"$baseUrl${mediaSource.transcodingUrl}"
|
||||||
urlStrategy = playbackPlan.urlStrategy,
|
} else {
|
||||||
playSessionId = null,
|
// Use direct play URL
|
||||||
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = true,
|
||||||
|
mediaSourceId = mediaSource.id,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(
|
Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url")
|
||||||
TAG,
|
|
||||||
"Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" +
|
|
||||||
if (playbackPlan.usedFallback) " (conservative fallback)" else ""
|
|
||||||
)
|
|
||||||
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
|
if (!ensureConfigured()) return@withContext
|
||||||
api.playStateApi.reportPlaybackStart(
|
api.playStateApi.reportPlaybackStart(
|
||||||
PlaybackStartInfo(
|
PlaybackStartInfo(
|
||||||
@@ -355,24 +318,15 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
canSeek = true,
|
canSeek = true,
|
||||||
isPaused = false,
|
isPaused = false,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
mediaSourceId = reportContext.mediaSourceId,
|
// TODO Send correct play method
|
||||||
audioStreamIndex = reportContext.audioStreamIndex,
|
playMethod = PlayMethod.DIRECT_PLAY,
|
||||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
|
||||||
liveStreamId = reportContext.liveStreamId,
|
|
||||||
playSessionId = reportContext.playSessionId,
|
|
||||||
playMethod = reportContext.playMethod,
|
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
playbackOrder = PlaybackOrder.DEFAULT
|
playbackOrder = PlaybackOrder.DEFAULT
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reportPlaybackProgress(
|
suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) {
|
||||||
itemId: UUID,
|
|
||||||
positionTicks: Long,
|
|
||||||
isPaused: Boolean,
|
|
||||||
reportContext: PlaybackReportContext
|
|
||||||
) = withContext(Dispatchers.IO) {
|
|
||||||
if (!ensureConfigured()) return@withContext
|
if (!ensureConfigured()) return@withContext
|
||||||
api.playStateApi.reportPlaybackProgress(
|
api.playStateApi.reportPlaybackProgress(
|
||||||
PlaybackProgressInfo(
|
PlaybackProgressInfo(
|
||||||
@@ -381,197 +335,21 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
canSeek = true,
|
canSeek = true,
|
||||||
isPaused = isPaused,
|
isPaused = isPaused,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
mediaSourceId = reportContext.mediaSourceId,
|
playMethod = PlayMethod.DIRECT_PLAY,
|
||||||
audioStreamIndex = reportContext.audioStreamIndex,
|
|
||||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
|
||||||
liveStreamId = reportContext.liveStreamId,
|
|
||||||
playSessionId = reportContext.playSessionId,
|
|
||||||
playMethod = reportContext.playMethod,
|
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
playbackOrder = PlaybackOrder.DEFAULT
|
playbackOrder = PlaybackOrder.DEFAULT
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
|
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) return@withContext
|
if (!ensureConfigured()) return@withContext
|
||||||
api.playStateApi.reportPlaybackStopped(
|
api.playStateApi.reportPlaybackStopped(
|
||||||
PlaybackStopInfo(
|
PlaybackStopInfo(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
positionTicks = positionTicks,
|
positionTicks = positionTicks,
|
||||||
mediaSourceId = reportContext.mediaSourceId,
|
|
||||||
liveStreamId = reportContext.liveStreamId,
|
|
||||||
playSessionId = reportContext.playSessionId,
|
|
||||||
failed = false
|
failed = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestPlaybackInfo(mediaId: UUID, forceTranscode: Boolean = false): PlaybackInfoResponse? {
|
|
||||||
if (!ensureConfigured()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
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()}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -34,11 +34,9 @@ dependencies {
|
|||||||
implementation(libs.hilt)
|
implementation(libs.hilt)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.medi3.exoplayer)
|
implementation(libs.medi3.exoplayer)
|
||||||
implementation(libs.medi3.exoplayer.hls)
|
|
||||||
implementation(libs.media3.datasource.okhttp)
|
implementation(libs.media3.datasource.okhttp)
|
||||||
implementation(libs.datastore)
|
implementation(libs.datastore)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.jellyfin.core)
|
implementation(libs.jellyfin.core)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
testImplementation(libs.junit)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,102 @@
|
|||||||
package hu.bbara.purefin.core.player.data
|
package hu.bbara.purefin.core.player.data
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.C
|
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
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 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.JellyfinApiClient
|
||||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||||
import hu.bbara.purefin.core.player.model.PlayerError
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
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 java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ViewModelScoped
|
@ViewModelScoped
|
||||||
class PlayerMediaRepository @OptIn(UnstableApi::class)
|
class PlayerMediaRepository @Inject constructor(
|
||||||
@Inject constructor(
|
|
||||||
private val jellyfinApiClient: JellyfinApiClient,
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
private val userSessionRepository: UserSessionRepository,
|
private val userSessionRepository: UserSessionRepository
|
||||||
private val mediaRepository: MediaRepository,
|
|
||||||
private val downloadManager: DownloadManager
|
|
||||||
) {
|
) {
|
||||||
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) {
|
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||||
when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) {
|
runCatching {
|
||||||
is PlayerMediaLoadResult.Success -> onlineResult
|
val serverUrl = userSessionRepository.serverUrl.first()
|
||||||
is PlayerMediaLoadResult.Failure -> {
|
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
|
||||||
when (val offlineResult = buildOfflineMediaItem(mediaId)) {
|
episodes.mapNotNull { episode ->
|
||||||
is OfflineLoadResult.Success -> {
|
val id = episode.id ?: return@mapNotNull null
|
||||||
Log.w(
|
val stringId = id.toString()
|
||||||
TAG,
|
if (existingIds.contains(stringId)) {
|
||||||
"Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}"
|
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,
|
||||||
)
|
)
|
||||||
offlineResult.result
|
}
|
||||||
|
}.getOrElse { error ->
|
||||||
|
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is OfflineLoadResult.Unavailable -> {
|
private fun createMediaItem(
|
||||||
val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail)
|
mediaId: String,
|
||||||
Log.w(
|
playbackUrl: String,
|
||||||
TAG,
|
title: String,
|
||||||
"Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}"
|
subtitle: String?,
|
||||||
)
|
artworkUrl: String,
|
||||||
PlayerMediaLoadResult.Failure(finalError)
|
): 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(
|
private fun calculateResumePosition(
|
||||||
@@ -89,225 +122,4 @@ class PlayerMediaRepository @OptIn(UnstableApi::class)
|
|||||||
// Apply thresholds: resume only if 5% ≤ progress ≤ 95%
|
// Apply thresholds: resume only if 5% ≤ progress ≤ 95%
|
||||||
return if (percentage in 5.0..95.0) positionMs else null
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,6 @@ import androidx.media3.common.TrackSelectionOverride
|
|||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
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.QueueItemUi
|
||||||
import hu.bbara.purefin.core.player.model.TrackOption
|
import hu.bbara.purefin.core.player.model.TrackOption
|
||||||
import hu.bbara.purefin.core.player.model.TrackType
|
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private val pendingSeekTracker = PendingSeekTracker()
|
|
||||||
|
|
||||||
private var currentMediaContext: MediaContext? = null
|
private var currentMediaContext: MediaContext? = null
|
||||||
|
|
||||||
@@ -83,11 +80,7 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
_playbackState.update {
|
_playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
|
||||||
it.copy(
|
|
||||||
error = PlayerError.fromPlaybackException(error)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
@@ -98,22 +91,9 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
pendingSeekTracker.clear()
|
|
||||||
refreshMetadata(mediaItem)
|
refreshMetadata(mediaItem)
|
||||||
refreshQueue()
|
refreshQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPositionDiscontinuity(
|
|
||||||
oldPosition: Player.PositionInfo,
|
|
||||||
newPosition: Player.PositionInfo,
|
|
||||||
reason: Int
|
|
||||||
) {
|
|
||||||
if (reason == Player.DISCONTINUITY_REASON_SEEK &&
|
|
||||||
newPosition.positionMs < oldPosition.positionMs
|
|
||||||
) {
|
|
||||||
refreshSubtitleRendererOnBackwardSeek()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -124,36 +104,9 @@ class PlayerManager @Inject constructor(
|
|||||||
startProgressLoop()
|
startProgressLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
|
||||||
currentMediaContext = mediaContext
|
currentMediaContext = mediaContext
|
||||||
pendingSeekTracker.clear()
|
|
||||||
if (startPositionMs != null) {
|
|
||||||
player.setMediaItem(mediaItem, startPositionMs)
|
|
||||||
} else {
|
|
||||||
player.setMediaItem(mediaItem)
|
player.setMediaItem(mediaItem)
|
||||||
}
|
|
||||||
player.prepare()
|
|
||||||
player.playWhenReady = true
|
|
||||||
_progress.value = PlaybackProgressSnapshot()
|
|
||||||
refreshMetadata(mediaItem)
|
|
||||||
refreshQueue()
|
|
||||||
_playbackState.update { it.copy(isEnded = false, error = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
|
||||||
currentMediaContext = mediaContext
|
|
||||||
pendingSeekTracker.clear()
|
|
||||||
val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run {
|
|
||||||
play(mediaItem, mediaContext, startPositionMs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
player.replaceMediaItem(currentIndex, mediaItem)
|
|
||||||
if (startPositionMs != null) {
|
|
||||||
player.seekTo(currentIndex, startPositionMs)
|
|
||||||
} else {
|
|
||||||
player.seekToDefaultPosition(currentIndex)
|
|
||||||
}
|
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
refreshMetadata(mediaItem)
|
refreshMetadata(mediaItem)
|
||||||
@@ -171,17 +124,16 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
requestSeek(positionMs)
|
player.seekTo(positionMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(deltaMs: Long) {
|
fun seekBy(deltaMs: Long) {
|
||||||
val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition)
|
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L)
|
||||||
requestSeek(basePositionMs + deltaMs, basePositionMs)
|
seekTo(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekToLiveEdge() {
|
fun seekToLiveEdge() {
|
||||||
if (player.isCurrentMediaItemLive) {
|
if (player.isCurrentMediaItemLive) {
|
||||||
pendingSeekTracker.clear()
|
|
||||||
player.seekToDefaultPosition()
|
player.seekToDefaultPosition()
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
@@ -189,14 +141,12 @@ class PlayerManager @Inject constructor(
|
|||||||
|
|
||||||
fun next() {
|
fun next() {
|
||||||
if (player.hasNextMediaItem()) {
|
if (player.hasNextMediaItem()) {
|
||||||
pendingSeekTracker.clear()
|
|
||||||
player.seekToNextMediaItem()
|
player.seekToNextMediaItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun previous() {
|
fun previous() {
|
||||||
if (player.hasPreviousMediaItem()) {
|
if (player.hasPreviousMediaItem()) {
|
||||||
pendingSeekTracker.clear()
|
|
||||||
player.seekToPreviousMediaItem()
|
player.seekToPreviousMediaItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +208,6 @@ class PlayerManager @Inject constructor(
|
|||||||
val items = _queue.value
|
val items = _queue.value
|
||||||
val targetIndex = items.indexOfFirst { it.id == id }
|
val targetIndex = items.indexOfFirst { it.id == id }
|
||||||
if (targetIndex >= 0) {
|
if (targetIndex >= 0) {
|
||||||
pendingSeekTracker.clear()
|
|
||||||
player.seekToDefaultPosition(targetIndex)
|
player.seekToDefaultPosition(targetIndex)
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
refreshQueue()
|
refreshQueue()
|
||||||
@@ -269,35 +218,6 @@ class PlayerManager @Inject constructor(
|
|||||||
_playbackState.update { it.copy(error = null) }
|
_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() {
|
private suspend fun applyTrackPreferences() {
|
||||||
val context = currentMediaContext ?: return
|
val context = currentMediaContext ?: return
|
||||||
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return
|
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return
|
||||||
@@ -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() {
|
fun release() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
player.removeListener(listener)
|
player.removeListener(listener)
|
||||||
@@ -370,7 +279,15 @@ class PlayerManager @Inject constructor(
|
|||||||
private fun startProgressLoop() {
|
private fun startProgressLoop() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
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)
|
delay(500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,12 +311,10 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshMetadata(mediaItem: MediaItem?) {
|
private fun refreshMetadata(mediaItem: MediaItem?) {
|
||||||
val playbackReportContext = mediaItem?.localConfiguration?.tag as? PlaybackReportContext
|
|
||||||
_metadata.value = MetadataState(
|
_metadata.value = MetadataState(
|
||||||
mediaId = mediaItem?.mediaId,
|
mediaId = mediaItem?.mediaId,
|
||||||
title = mediaItem?.mediaMetadata?.title?.toString(),
|
title = mediaItem?.mediaMetadata?.title?.toString(),
|
||||||
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(),
|
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(),
|
||||||
playbackReportContext = playbackReportContext
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +327,7 @@ data class PlaybackStateSnapshot(
|
|||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false,
|
||||||
val isBuffering: Boolean = false,
|
val isBuffering: Boolean = false,
|
||||||
val isEnded: Boolean = false,
|
val isEnded: Boolean = false,
|
||||||
val error: PlayerError? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlaybackProgressSnapshot(
|
data class PlaybackProgressSnapshot(
|
||||||
@@ -426,7 +341,6 @@ data class MetadataState(
|
|||||||
val mediaId: String? = null,
|
val mediaId: String? = null,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val subtitle: String? = null,
|
val subtitle: String? = null,
|
||||||
val playbackReportContext: PlaybackReportContext? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MediaContext(
|
data class MediaContext(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package hu.bbara.purefin.core.player.manager
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
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 hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -26,16 +25,10 @@ class ProgressManager @Inject constructor(
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private var progressJob: Job? = null
|
private var progressJob: Job? = null
|
||||||
private var activeItemId: UUID? = null
|
private var activeItemId: UUID? = null
|
||||||
private var activePlaybackReportContext: PlaybackReportContext? = null
|
|
||||||
private var lastPositionMs: Long = 0L
|
private var lastPositionMs: Long = 0L
|
||||||
private var lastDurationMs: Long = 0L
|
private var lastDurationMs: Long = 0L
|
||||||
private var isPaused: Boolean = false
|
private var isPaused: Boolean = false
|
||||||
|
|
||||||
fun syncProgress(snapshot: PlaybackProgressSnapshot) {
|
|
||||||
lastPositionMs = snapshot.positionMs
|
|
||||||
lastDurationMs = snapshot.durationMs
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(
|
fun bind(
|
||||||
playbackState: StateFlow<PlaybackStateSnapshot>,
|
playbackState: StateFlow<PlaybackStateSnapshot>,
|
||||||
progress: StateFlow<PlaybackProgressSnapshot>,
|
progress: StateFlow<PlaybackProgressSnapshot>,
|
||||||
@@ -49,7 +42,6 @@ class ProgressManager @Inject constructor(
|
|||||||
lastDurationMs = prog.durationMs
|
lastDurationMs = prog.durationMs
|
||||||
isPaused = !state.isPlaying
|
isPaused = !state.isPlaying
|
||||||
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||||
activePlaybackReportContext = meta.playbackReportContext
|
|
||||||
|
|
||||||
// Media changed or ended - stop session
|
// Media changed or ended - stop session
|
||||||
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
|
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
|
// Start session when we have a media item and none is active
|
||||||
if (activeItemId == null && mediaId != null && !state.isEnded) {
|
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
|
activeItemId = itemId
|
||||||
activePlaybackReportContext = reportContext
|
report(itemId, positionMs, isStart = true)
|
||||||
report(itemId, positionMs, reportContext = reportContext, isStart = true)
|
|
||||||
progressJob = scope.launch {
|
progressJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5000)
|
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() {
|
private fun stopSession() {
|
||||||
progressJob?.cancel()
|
progressJob?.cancel()
|
||||||
activeItemId?.let { itemId ->
|
activeItemId?.let { itemId ->
|
||||||
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
|
report(itemId, lastPositionMs, isStop = true)
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
|
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
|
||||||
@@ -89,13 +80,11 @@ class ProgressManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeItemId = null
|
activeItemId = null
|
||||||
activePlaybackReportContext = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun report(
|
private fun report(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
reportContext: PlaybackReportContext?,
|
|
||||||
isPaused: Boolean = false,
|
isPaused: Boolean = false,
|
||||||
isStart: Boolean = false,
|
isStart: Boolean = false,
|
||||||
isStop: Boolean = false
|
isStop: Boolean = false
|
||||||
@@ -103,13 +92,10 @@ class ProgressManager @Inject constructor(
|
|||||||
val ticks = positionMs * 10_000
|
val ticks = positionMs * 10_000
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (reportContext == null) {
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
when {
|
when {
|
||||||
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
|
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks)
|
||||||
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
|
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
||||||
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
|
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
|
||||||
}
|
}
|
||||||
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
|
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -126,9 +112,7 @@ class ProgressManager @Inject constructor(
|
|||||||
val durMs = lastDurationMs
|
val durMs = lastDurationMs
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
activePlaybackReportContext?.let { reportContext ->
|
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
||||||
jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
|
|
||||||
}
|
|
||||||
updateWatchProgressUseCase(itemId, posMs, durMs)
|
updateWatchProgressUseCase(itemId, posMs, durMs)
|
||||||
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
|
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ data class PlayerUiState(
|
|||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val positionMs: Long = 0L,
|
val positionMs: Long = 0L,
|
||||||
val bufferedMs: Long = 0L,
|
val bufferedMs: Long = 0L,
|
||||||
val error: PlayerError? = null,
|
val error: String? = null,
|
||||||
val playbackSpeed: Float = 1f,
|
val playbackSpeed: Float = 1f,
|
||||||
val chapters: List<TimedMarker> = emptyList(),
|
val chapters: List<TimedMarker> = emptyList(),
|
||||||
val ads: List<TimedMarker> = emptyList(),
|
val ads: List<TimedMarker> = emptyList(),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.components.ViewModelComponent
|
import dagger.hilt.android.components.ViewModelComponent
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import hu.bbara.purefin.core.data.client.AndroidDeviceProfile
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(ViewModelComponent::class)
|
@InstallIn(ViewModelComponent::class)
|
||||||
@@ -28,7 +27,6 @@ object VideoPlayerModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@ViewModelScoped
|
@ViewModelScoped
|
||||||
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
|
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
|
||||||
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(application)
|
|
||||||
val trackSelector = DefaultTrackSelector(application)
|
val trackSelector = DefaultTrackSelector(application)
|
||||||
val audioAttributes =
|
val audioAttributes =
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
@@ -39,24 +37,15 @@ object VideoPlayerModule {
|
|||||||
trackSelector.setParameters(
|
trackSelector.setParameters(
|
||||||
trackSelector
|
trackSelector
|
||||||
.buildUponParameters()
|
.buildUponParameters()
|
||||||
.setMaxAudioChannelCount(capabilitySnapshot.maxAudioChannels)
|
|
||||||
.setExceedAudioConstraintsIfNecessary(false)
|
|
||||||
.setTunnelingEnabled(false)
|
.setTunnelingEnabled(false)
|
||||||
// .setPreferredAudioLanguage(
|
|
||||||
// appPreferences.getValue(appPreferences.preferredAudioLanguage)
|
|
||||||
// )
|
|
||||||
// .setPreferredTextLanguage(
|
|
||||||
// appPreferences.getValue(appPreferences.preferredSubtitleLanguage)
|
|
||||||
// )
|
|
||||||
)
|
)
|
||||||
val loadControl = DefaultLoadControl.Builder()
|
val loadControl = DefaultLoadControl.Builder()
|
||||||
.setBufferDurationsMs(
|
.setBufferDurationsMs(
|
||||||
15_000, // minBufferMs
|
10_000,
|
||||||
50_000, // maxBufferMs
|
30_000,
|
||||||
2_500, // bufferForPlaybackMs
|
5_000,
|
||||||
5_000 // bufferForPlaybackAfterRebufferMs
|
5_000
|
||||||
)
|
)
|
||||||
.setPrioritizeTimeOverSizeThresholds(true)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Configure RenderersFactory to use all available decoders and enable passthrough
|
// Configure RenderersFactory to use all available decoders and enable passthrough
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.core.data.MediaRepository
|
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.data.PlayerMediaRepository
|
||||||
import hu.bbara.purefin.core.player.manager.MediaContext
|
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.PlayerManager
|
||||||
import hu.bbara.purefin.core.player.manager.ProgressManager
|
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.PlayerUiState
|
||||||
import hu.bbara.purefin.core.player.model.TrackOption
|
import hu.bbara.purefin.core.player.model.TrackOption
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -46,10 +42,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var autoHideJob: Job? = null
|
private var autoHideJob: Job? = null
|
||||||
private var lastNextUpMediaId: String? = null
|
private var lastNextUpMediaId: String? = null
|
||||||
private var loadError: PlayerError? = null
|
private var dataErrorMessage: String? = null
|
||||||
private var activeMediaId: String? = null
|
|
||||||
private var transcodingRetryMediaId: String? = null
|
|
||||||
private var lastLoadRequest: PendingLoadRequest? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
progressManager.bind(
|
progressManager.bind(
|
||||||
@@ -64,16 +57,12 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private fun observePlayerState() {
|
private fun observePlayerState() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerManager.playbackState.collect { state ->
|
playerManager.playbackState.collect { state ->
|
||||||
if (state.error != null && maybeRetryWithTranscoding(state)) {
|
|
||||||
_uiState.update { it.copy(isBuffering = true, error = null) }
|
|
||||||
return@collect
|
|
||||||
}
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isPlaying = state.isPlaying,
|
isPlaying = state.isPlaying,
|
||||||
isBuffering = state.isBuffering,
|
isBuffering = state.isBuffering,
|
||||||
isEnded = state.isEnded,
|
isEnded = state.isEnded,
|
||||||
error = state.error ?: loadError
|
error = state.error ?: dataErrorMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.isEnded) {
|
if (state.isEnded) {
|
||||||
@@ -104,10 +93,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val currentMediaId = metadata.mediaId
|
val currentMediaId = metadata.mediaId
|
||||||
if (currentMediaId != activeMediaId) {
|
|
||||||
activeMediaId = currentMediaId
|
|
||||||
transcodingRetryMediaId = null
|
|
||||||
}
|
|
||||||
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
|
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
|
||||||
lastNextUpMediaId = currentMediaId
|
lastNextUpMediaId = currentMediaId
|
||||||
loadNextUp(currentMediaId)
|
loadNextUp(currentMediaId)
|
||||||
@@ -148,78 +133,36 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadMediaById(id: String) {
|
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()
|
val uuid = id.toUuidOrNull()
|
||||||
if (uuid == null) {
|
if (uuid == null) {
|
||||||
loadError = PlayerError.invalidMediaId(id)
|
dataErrorMessage = "Invalid media id"
|
||||||
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadError = null
|
|
||||||
_uiState.update { it.copy(isBuffering = true, error = null) }
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
|
val result = playerMediaRepository.getMediaItem(uuid)
|
||||||
when (result) {
|
if (result != null) {
|
||||||
is PlayerMediaLoadResult.Success -> {
|
val (mediaItem, resumePositionMs) = result
|
||||||
val mediaItem = result.mediaItem
|
|
||||||
val resumePositionMs = result.resumePositionMs
|
|
||||||
|
|
||||||
// Determine preference key: movies use their own ID, episodes use series ID
|
// Determine preference key: movies use their own ID, episodes use series ID
|
||||||
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
|
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
|
||||||
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
|
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
|
||||||
val startPositionMs = startPositionMsOverride ?: resumePositionMs
|
|
||||||
|
|
||||||
if (replaceCurrent) {
|
playerManager.play(mediaItem, mediaContext)
|
||||||
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
|
|
||||||
} else {
|
|
||||||
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadError != null) {
|
// Seek to resume position after play() is called
|
||||||
loadError = null
|
resumePositionMs?.let { playerManager.seekTo(it) }
|
||||||
|
|
||||||
|
if (dataErrorMessage != null) {
|
||||||
|
dataErrorMessage = null
|
||||||
_uiState.update { it.copy(error = null) }
|
_uiState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
dataErrorMessage = "Unable to load media"
|
||||||
is PlayerMediaLoadResult.Failure -> {
|
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||||
loadError = result.error
|
|
||||||
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
private fun loadNextUp(currentMediaId: String) {
|
||||||
val uuid = currentMediaId.toUuidOrNull() ?: return
|
val uuid = currentMediaId.toUuidOrNull() ?: return
|
||||||
@@ -260,11 +203,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
if (_controlsVisible.value) scheduleAutoHide()
|
if (_controlsVisible.value) scheduleAutoHide()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hideControls() {
|
|
||||||
_controlsVisible.value = false
|
|
||||||
autoHideJob?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleAutoHide() {
|
private fun scheduleAutoHide() {
|
||||||
autoHideJob?.cancel()
|
autoHideJob?.cancel()
|
||||||
if (!player.isPlaying) return
|
if (!player.isPlaying) return
|
||||||
@@ -294,11 +232,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun retry() {
|
fun retry() {
|
||||||
val error = uiState.value.error
|
|
||||||
if (error?.source == PlayerErrorSource.LOAD) {
|
|
||||||
retryLoad()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
playerManager.retry()
|
playerManager.retry()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +241,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
loadError = null
|
dataErrorMessage = null
|
||||||
playerManager.clearError()
|
playerManager.clearError()
|
||||||
_uiState.update { it.copy(error = null) }
|
_uiState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
@@ -316,27 +249,9 @@ class PlayerViewModel @Inject constructor(
|
|||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
autoHideJob?.cancel()
|
autoHideJob?.cancel()
|
||||||
progressManager.syncProgress(playerManager.snapshotProgress())
|
|
||||||
progressManager.release()
|
progressManager.release()
|
||||||
playerManager.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 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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user