mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
Implement Jellyfin playback decision flow
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
package hu.bbara.purefin.core.data.client
|
package hu.bbara.purefin.core.data.client
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
import android.media.MediaCodecList
|
import android.media.MediaCodecList
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
@@ -14,32 +16,59 @@ import org.jellyfin.sdk.model.api.SubtitleProfile
|
|||||||
*/
|
*/
|
||||||
object AndroidDeviceProfile {
|
object AndroidDeviceProfile {
|
||||||
|
|
||||||
fun create(): DeviceProfile {
|
private const val TAG = "AndroidDeviceProfile"
|
||||||
// Debug: Log all available decoders
|
private const val DEFAULT_MAX_AUDIO_CHANNELS = 8
|
||||||
CodecDebugHelper.logAvailableDecoders()
|
|
||||||
|
|
||||||
|
@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 audioCodecs = getAudioCodecs()
|
||||||
val videoCodecs = getVideoCodecs()
|
val videoCodecs = getVideoCodecs()
|
||||||
|
val maxAudioChannels = resolveMaxAudioChannels(applicationContext)
|
||||||
|
|
||||||
Log.d("AndroidDeviceProfile", "Supported audio codecs: ${audioCodecs.joinToString()}")
|
Log.d(TAG, "Supported audio codecs: ${audioCodecs.joinToString()}")
|
||||||
Log.d("AndroidDeviceProfile", "Supported video codecs: ${videoCodecs.joinToString()}")
|
Log.d(TAG, "Supported video codecs: ${videoCodecs.joinToString()}")
|
||||||
|
Log.d(TAG, "Max audio channels: $maxAudioChannels")
|
||||||
|
|
||||||
// Check specifically for DTS
|
val snapshot = CapabilitySnapshot(
|
||||||
val hasDTS = CodecDebugHelper.hasDecoderFor("audio/vnd.dts")
|
deviceProfile = buildDeviceProfile(audioCodecs, videoCodecs),
|
||||||
val hasDTSHD = CodecDebugHelper.hasDecoderFor("audio/vnd.dts.hd")
|
maxAudioChannels = maxAudioChannels
|
||||||
Log.d("AndroidDeviceProfile", "Has DTS decoder: $hasDTS, Has DTS-HD decoder: $hasDTSHD")
|
)
|
||||||
|
cachedSnapshot = snapshot
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
|
||||||
|
|
||||||
|
private fun buildDeviceProfile(
|
||||||
|
audioCodecs: List<String>,
|
||||||
|
videoCodecs: List<String>
|
||||||
|
): DeviceProfile {
|
||||||
return DeviceProfile(
|
return DeviceProfile(
|
||||||
name = "Android",
|
name = "Android Media3",
|
||||||
maxStaticBitrate = 100_000_000,
|
maxStaticBitrate = 100_000_000,
|
||||||
maxStreamingBitrate = 100_000_000,
|
maxStreamingBitrate = 100_000_000,
|
||||||
|
|
||||||
// Direct play profiles - what we can play natively
|
// Direct play profiles - what we can play natively
|
||||||
// By specifying supported codecs, Jellyfin will transcode unsupported formats like DTS-HD
|
|
||||||
directPlayProfiles = listOf(
|
directPlayProfiles = listOf(
|
||||||
DirectPlayProfile(
|
DirectPlayProfile(
|
||||||
type = DlnaProfileType.VIDEO,
|
type = DlnaProfileType.VIDEO,
|
||||||
container = "mp4,m4v,mkv,webm",
|
container = "mp4,m4v,mkv,webm,ts,mov",
|
||||||
videoCodec = videoCodecs.joinToString(","),
|
videoCodec = videoCodecs.joinToString(","),
|
||||||
audioCodec = audioCodecs.joinToString(",")
|
audioCodec = audioCodecs.joinToString(",")
|
||||||
)
|
)
|
||||||
@@ -77,15 +106,18 @@ object AndroidDeviceProfile {
|
|||||||
private fun getAudioCodecs(): List<String> {
|
private fun getAudioCodecs(): List<String> {
|
||||||
val supportedCodecs = mutableListOf<String>()
|
val supportedCodecs = mutableListOf<String>()
|
||||||
|
|
||||||
// Common codecs supported on most Android devices
|
|
||||||
val commonCodecs = listOf(
|
val commonCodecs = listOf(
|
||||||
"aac" to android.media.MediaFormat.MIMETYPE_AUDIO_AAC,
|
"aac" to "audio/mp4a-latm",
|
||||||
"mp3" to android.media.MediaFormat.MIMETYPE_AUDIO_MPEG,
|
"mp3" to "audio/mpeg",
|
||||||
"ac3" to android.media.MediaFormat.MIMETYPE_AUDIO_AC3,
|
"ac3" to "audio/ac3",
|
||||||
"eac3" to android.media.MediaFormat.MIMETYPE_AUDIO_EAC3,
|
"eac3" to "audio/eac3",
|
||||||
"flac" to android.media.MediaFormat.MIMETYPE_AUDIO_FLAC,
|
"dts" to "audio/vnd.dts",
|
||||||
"vorbis" to android.media.MediaFormat.MIMETYPE_AUDIO_VORBIS,
|
"dtshd_ma" to "audio/vnd.dts.hd",
|
||||||
"opus" to android.media.MediaFormat.MIMETYPE_AUDIO_OPUS
|
"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) {
|
for ((codecName, mimeType) in commonCodecs) {
|
||||||
@@ -109,12 +141,12 @@ object AndroidDeviceProfile {
|
|||||||
val supportedCodecs = mutableListOf<String>()
|
val supportedCodecs = mutableListOf<String>()
|
||||||
|
|
||||||
val commonCodecs = listOf(
|
val commonCodecs = listOf(
|
||||||
"h264" to android.media.MediaFormat.MIMETYPE_VIDEO_AVC,
|
"h264" to "video/avc",
|
||||||
"hevc" to android.media.MediaFormat.MIMETYPE_VIDEO_HEVC,
|
"hevc" to "video/hevc",
|
||||||
"vp9" to android.media.MediaFormat.MIMETYPE_VIDEO_VP9,
|
"vp9" to "video/x-vnd.on2.vp9",
|
||||||
"vp8" to android.media.MediaFormat.MIMETYPE_VIDEO_VP8,
|
"vp8" to "video/x-vnd.on2.vp8",
|
||||||
"mpeg4" to android.media.MediaFormat.MIMETYPE_VIDEO_MPEG4,
|
"mpeg4" to "video/mp4v-es",
|
||||||
"av1" to android.media.MediaFormat.MIMETYPE_VIDEO_AV1
|
"av1" to "video/av01"
|
||||||
)
|
)
|
||||||
|
|
||||||
for ((codecName, mimeType) in commonCodecs) {
|
for ((codecName, mimeType) in commonCodecs) {
|
||||||
@@ -135,15 +167,58 @@ object AndroidDeviceProfile {
|
|||||||
* Check if a specific decoder (not encoder) is supported on this device.
|
* Check if a specific decoder (not encoder) is supported on this device.
|
||||||
*/
|
*/
|
||||||
private fun isCodecSupported(mimeType: String): Boolean {
|
private fun isCodecSupported(mimeType: String): Boolean {
|
||||||
return try {
|
val platformSupport = try {
|
||||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||||
codecList.codecInfos.any { codecInfo ->
|
codecList.codecInfos.any { codecInfo ->
|
||||||
!codecInfo.isEncoder &&
|
!codecInfo.isEncoder &&
|
||||||
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
|
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// If we can't determine, assume not supported
|
|
||||||
false
|
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 maxDeviceChannels = devices
|
||||||
|
.flatMap { device ->
|
||||||
|
buildList {
|
||||||
|
addAll(device.channelCounts.toList())
|
||||||
|
addAll(device.channelMasks.map(::channelMaskToCount))
|
||||||
|
addAll(device.channelIndexMasks.map { Integer.bitCount(it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.maxOrNull()
|
||||||
|
|
||||||
|
maxDeviceChannels
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?: DEFAULT_MAX_AUDIO_CHANNELS
|
||||||
|
}.getOrElse {
|
||||||
|
DEFAULT_MAX_AUDIO_CHANNELS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channelMaskToCount(mask: Int): Int {
|
||||||
|
return if (mask == 0) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
Integer.bitCount(mask)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.ItemFields
|
|||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
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
|
||||||
@@ -44,6 +45,11 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
@@ -248,17 +254,87 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
||||||
val result = api.mediaInfoApi
|
requestPlaybackInfo(mediaId)?.mediaSources ?: emptyList()
|
||||||
.getPostedPlaybackInfo(
|
}
|
||||||
mediaId,
|
|
||||||
PlaybackInfoDto(
|
suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
|
||||||
userId = getUserId(),
|
val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
|
||||||
deviceProfile = AndroidDeviceProfile.create(),
|
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
||||||
maxStreamingBitrate = 100_000_000,
|
val selectedMediaSource = selectMediaSource(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
|
||||||
),
|
|
||||||
|
val url = when {
|
||||||
|
forceTranscode && selectedMediaSource.supportsTranscoding -> {
|
||||||
|
resolveTranscodeUrl(
|
||||||
|
mediaId = mediaId,
|
||||||
|
mediaSource = selectedMediaSource,
|
||||||
|
playSessionId = playbackInfo.playSessionId,
|
||||||
|
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
!forceTranscode && selectedMediaSource.supportsDirectPlay -> {
|
||||||
|
api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = true,
|
||||||
|
mediaSourceId = selectedMediaSource.id,
|
||||||
|
playSessionId = playbackInfo.playSessionId,
|
||||||
|
liveStreamId = selectedMediaSource.liveStreamId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
!forceTranscode && selectedMediaSource.supportsDirectStream -> {
|
||||||
|
api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = false,
|
||||||
|
mediaSourceId = selectedMediaSource.id,
|
||||||
|
playSessionId = playbackInfo.playSessionId,
|
||||||
|
liveStreamId = selectedMediaSource.liveStreamId,
|
||||||
|
container = selectedMediaSource.transcodingContainer ?: selectedMediaSource.container,
|
||||||
|
enableAutoStreamCopy = true,
|
||||||
|
allowVideoStreamCopy = true,
|
||||||
|
allowAudioStreamCopy = true,
|
||||||
|
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedMediaSource.supportsTranscoding -> {
|
||||||
|
resolveTranscodeUrl(
|
||||||
|
mediaId = mediaId,
|
||||||
|
mediaSource = selectedMediaSource,
|
||||||
|
playSessionId = playbackInfo.playSessionId,
|
||||||
|
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
} ?: return@withContext null
|
||||||
|
|
||||||
|
val playMethod = when {
|
||||||
|
forceTranscode -> PlayMethod.TRANSCODE
|
||||||
|
selectedMediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
|
||||||
|
selectedMediaSource.supportsDirectStream -> PlayMethod.DIRECT_STREAM
|
||||||
|
selectedMediaSource.supportsTranscoding -> PlayMethod.TRANSCODE
|
||||||
|
else -> return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val reportContext = PlaybackReportContext(
|
||||||
|
playMethod = playMethod,
|
||||||
|
mediaSourceId = selectedMediaSource.id,
|
||||||
|
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
|
||||||
|
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
|
||||||
|
liveStreamId = selectedMediaSource.liveStreamId,
|
||||||
|
playSessionId = playbackInfo.playSessionId,
|
||||||
|
canRetryWithTranscoding = !forceTranscode &&
|
||||||
|
playMethod != PlayMethod.TRANSCODE &&
|
||||||
|
selectedMediaSource.supportsTranscoding
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "Playback decision for $mediaId: $playMethod using ${selectedMediaSource.id}")
|
||||||
|
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) {
|
||||||
@@ -266,28 +342,43 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if transcoding is required based on the MediaSourceInfo from getMediaSources
|
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
|
||||||
val shouldTranscode = mediaSource.supportsTranscoding == true &&
|
|
||||||
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
|
|
||||||
|
|
||||||
val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) {
|
val url = when {
|
||||||
// Use transcoding URL
|
mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl(
|
||||||
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
|
|
||||||
"$baseUrl${mediaSource.transcodingUrl}"
|
|
||||||
} else {
|
|
||||||
// Use direct play URL
|
|
||||||
api.videosApi.getVideoStreamUrl(
|
|
||||||
itemId = mediaId,
|
itemId = mediaId,
|
||||||
static = true,
|
static = true,
|
||||||
mediaSourceId = mediaSource.id,
|
mediaSourceId = mediaSource.id,
|
||||||
|
liveStreamId = mediaSource.liveStreamId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mediaSource.supportsDirectStream -> api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = false,
|
||||||
|
mediaSourceId = mediaSource.id,
|
||||||
|
liveStreamId = mediaSource.liveStreamId,
|
||||||
|
container = mediaSource.transcodingContainer ?: mediaSource.container,
|
||||||
|
enableAutoStreamCopy = true,
|
||||||
|
allowVideoStreamCopy = true,
|
||||||
|
allowAudioStreamCopy = true,
|
||||||
|
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||||
|
)
|
||||||
|
|
||||||
|
mediaSource.supportsTranscoding -> resolveTranscodeUrl(
|
||||||
|
mediaId = mediaId,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
playSessionId = null,
|
||||||
|
maxAudioChannels = capabilitySnapshot.maxAudioChannels
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url")
|
Log.d(TAG, "Resolved standalone playback URL for $mediaId -> $url")
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) {
|
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) return@withContext
|
if (!ensureConfigured()) return@withContext
|
||||||
api.playStateApi.reportPlaybackStart(
|
api.playStateApi.reportPlaybackStart(
|
||||||
PlaybackStartInfo(
|
PlaybackStartInfo(
|
||||||
@@ -296,14 +387,24 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
canSeek = true,
|
canSeek = true,
|
||||||
isPaused = false,
|
isPaused = false,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
playMethod = PlayMethod.DIRECT_PLAY,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
|
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 reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) {
|
suspend fun reportPlaybackProgress(
|
||||||
|
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(
|
||||||
@@ -312,21 +413,93 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
canSeek = true,
|
canSeek = true,
|
||||||
isPaused = isPaused,
|
isPaused = isPaused,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
playMethod = PlayMethod.DIRECT_PLAY,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
|
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) = withContext(Dispatchers.IO) {
|
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) = 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)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "PlaybackInfo for $mediaId -> sources=${response.content.mediaSources.size}, session=${response.content.playSessionId}")
|
||||||
|
return response.content
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectMediaSource(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): MediaSourceInfo? {
|
||||||
|
return when {
|
||||||
|
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }
|
||||||
|
else -> mediaSources.firstOrNull { it.supportsDirectPlay }
|
||||||
|
?: mediaSources.firstOrNull { it.supportsDirectStream }
|
||||||
|
?: mediaSources.firstOrNull { it.supportsTranscoding }
|
||||||
|
?: mediaSources.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveTranscodeUrl(
|
||||||
|
mediaId: UUID,
|
||||||
|
mediaSource: MediaSourceInfo,
|
||||||
|
playSessionId: String?,
|
||||||
|
maxAudioChannels: Int
|
||||||
|
): String? {
|
||||||
|
mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl ->
|
||||||
|
if (transcodingUrl.startsWith("http", ignoreCase = true)) {
|
||||||
|
return transcodingUrl
|
||||||
|
}
|
||||||
|
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
|
||||||
|
return "$baseUrl$transcodingUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = false,
|
||||||
|
mediaSourceId = mediaSource.id,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
liveStreamId = mediaSource.liveStreamId,
|
||||||
|
container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4",
|
||||||
|
enableAutoStreamCopy = false,
|
||||||
|
allowVideoStreamCopy = false,
|
||||||
|
allowAudioStreamCopy = false,
|
||||||
|
maxAudioChannels = maxAudioChannels
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ 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.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 kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -34,8 +35,8 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
private val downloadManager: DownloadManager
|
private val downloadManager: DownloadManager
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
||||||
buildOnlineMediaItem(mediaId) ?: buildOfflineMediaItem(mediaId)
|
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateResumePosition(
|
private fun calculateResumePosition(
|
||||||
@@ -72,21 +73,18 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
if (existingIds.contains(stringId)) {
|
if (existingIds.contains(stringId)) {
|
||||||
return@mapNotNull null
|
return@mapNotNull null
|
||||||
}
|
}
|
||||||
val mediaSources = jellyfinApiClient.getMediaSources(id)
|
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
|
||||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
|
val selectedMediaSource = playbackDecision.mediaSource
|
||||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
|
||||||
mediaId = id,
|
|
||||||
mediaSource = selectedMediaSource
|
|
||||||
) ?: return@mapNotNull null
|
|
||||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||||
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
|
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
|
||||||
createMediaItem(
|
createMediaItem(
|
||||||
mediaId = stringId,
|
mediaId = stringId,
|
||||||
playbackUrl = playbackUrl,
|
playbackUrl = playbackDecision.url,
|
||||||
title = episode.name ?: selectedMediaSource.name!!,
|
title = episode.name ?: selectedMediaSource.name!!,
|
||||||
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
|
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
|
||||||
artworkUrl = artworkUrl,
|
artworkUrl = artworkUrl,
|
||||||
subtitleConfigurations = subtitleConfigs
|
subtitleConfigurations = subtitleConfigs,
|
||||||
|
tag = playbackDecision.reportContext
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
@@ -95,14 +93,13 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildOnlineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
|
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
|
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
|
||||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return null
|
|
||||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
|
||||||
mediaId = mediaId,
|
mediaId = mediaId,
|
||||||
mediaSource = selectedMediaSource
|
forceTranscode = forceTranscode
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
val selectedMediaSource = playbackDecision.mediaSource
|
||||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||||
|
|
||||||
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
|
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
|
||||||
@@ -112,11 +109,12 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
|
|
||||||
val mediaItem = createMediaItem(
|
val mediaItem = createMediaItem(
|
||||||
mediaId = mediaId.toString(),
|
mediaId = mediaId.toString(),
|
||||||
playbackUrl = playbackUrl,
|
playbackUrl = playbackDecision.url,
|
||||||
title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
|
title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
|
||||||
subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
|
subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
|
||||||
artworkUrl = artworkUrl,
|
artworkUrl = artworkUrl,
|
||||||
subtitleConfigurations = subtitleConfigs
|
subtitleConfigurations = subtitleConfigs,
|
||||||
|
tag = playbackDecision.reportContext
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaItem to resumePositionMs
|
mediaItem to resumePositionMs
|
||||||
@@ -247,7 +245,8 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
title: String,
|
title: String,
|
||||||
subtitle: String?,
|
subtitle: String?,
|
||||||
artworkUrl: String,
|
artworkUrl: String,
|
||||||
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList()
|
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
|
||||||
|
tag: PlaybackReportContext? = null
|
||||||
): MediaItem {
|
): MediaItem {
|
||||||
val metadata = MediaMetadata.Builder()
|
val metadata = MediaMetadata.Builder()
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
@@ -258,6 +257,7 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
.setUri(playbackUrl.toUri())
|
.setUri(playbackUrl.toUri())
|
||||||
.setMediaId(mediaId)
|
.setMediaId(mediaId)
|
||||||
.setMediaMetadata(metadata)
|
.setMediaMetadata(metadata)
|
||||||
|
.setTag(tag)
|
||||||
.setSubtitleConfigurations(subtitleConfigurations)
|
.setSubtitleConfigurations(subtitleConfigurations)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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.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
|
||||||
@@ -73,14 +74,20 @@ class PlayerManager @Inject constructor(
|
|||||||
state.copy(
|
state.copy(
|
||||||
isBuffering = buffering,
|
isBuffering = buffering,
|
||||||
isEnded = ended,
|
isEnded = ended,
|
||||||
error = if (playbackState == Player.STATE_IDLE) state.error else null
|
error = if (playbackState == Player.STATE_IDLE) state.error else null,
|
||||||
|
errorCode = if (playbackState == Player.STATE_IDLE) state.errorCode else null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (ended) player.pause()
|
if (ended) player.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
_playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
|
_playbackState.update {
|
||||||
|
it.copy(
|
||||||
|
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error",
|
||||||
|
errorCode = error.errorCode
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
@@ -128,7 +135,27 @@ class PlayerManager @Inject constructor(
|
|||||||
_progress.value = PlaybackProgressSnapshot()
|
_progress.value = PlaybackProgressSnapshot()
|
||||||
refreshMetadata(mediaItem)
|
refreshMetadata(mediaItem)
|
||||||
refreshQueue()
|
refreshQueue()
|
||||||
_playbackState.update { it.copy(isEnded = false, error = null) }
|
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
||||||
|
currentMediaContext = mediaContext
|
||||||
|
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.playWhenReady = true
|
||||||
|
refreshMetadata(mediaItem)
|
||||||
|
refreshQueue()
|
||||||
|
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueue(mediaItem: MediaItem) {
|
fun addToQueue(mediaItem: MediaItem) {
|
||||||
@@ -232,7 +259,7 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_playbackState.update { it.copy(error = null) }
|
_playbackState.update { it.copy(error = null, errorCode = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshotProgress(): PlaybackProgressSnapshot {
|
fun snapshotProgress(): PlaybackProgressSnapshot {
|
||||||
@@ -341,10 +368,12 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +386,8 @@ 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: String? = null
|
val error: String? = null,
|
||||||
|
val errorCode: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlaybackProgressSnapshot(
|
data class PlaybackProgressSnapshot(
|
||||||
@@ -370,7 +400,8 @@ data class PlaybackProgressSnapshot(
|
|||||||
data class MetadataState(
|
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,6 +3,7 @@ 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
|
||||||
@@ -25,6 +26,7 @@ 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
|
||||||
@@ -47,6 +49,7 @@ 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)) {
|
||||||
@@ -55,19 +58,20 @@ 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)
|
startSession(mediaId, prog.positionMs, meta.playbackReportContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startSession(itemId: UUID, positionMs: Long) {
|
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
|
||||||
activeItemId = itemId
|
activeItemId = itemId
|
||||||
report(itemId, positionMs, isStart = true)
|
activePlaybackReportContext = reportContext
|
||||||
|
report(itemId, positionMs, reportContext = reportContext, isStart = true)
|
||||||
progressJob = scope.launch {
|
progressJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5000)
|
delay(5000)
|
||||||
report(itemId, lastPositionMs, isPaused = isPaused)
|
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +79,7 @@ class ProgressManager @Inject constructor(
|
|||||||
private fun stopSession() {
|
private fun stopSession() {
|
||||||
progressJob?.cancel()
|
progressJob?.cancel()
|
||||||
activeItemId?.let { itemId ->
|
activeItemId?.let { itemId ->
|
||||||
report(itemId, lastPositionMs, isStop = true)
|
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
|
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
|
||||||
@@ -85,16 +89,27 @@ class ProgressManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeItemId = null
|
activeItemId = null
|
||||||
|
activePlaybackReportContext = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun report(itemId: UUID, positionMs: Long, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false) {
|
private fun report(
|
||||||
|
itemId: UUID,
|
||||||
|
positionMs: Long,
|
||||||
|
reportContext: PlaybackReportContext?,
|
||||||
|
isPaused: Boolean = false,
|
||||||
|
isStart: Boolean = false,
|
||||||
|
isStop: Boolean = false
|
||||||
|
) {
|
||||||
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)
|
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
|
||||||
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||||
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
|
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
|
||||||
}
|
}
|
||||||
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) {
|
||||||
@@ -111,7 +126,9 @@ class ProgressManager @Inject constructor(
|
|||||||
val durMs = lastDurationMs
|
val durMs = lastDurationMs
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
activePlaybackReportContext?.let { reportContext ->
|
||||||
|
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,16 +1,20 @@
|
|||||||
package hu.bbara.purefin.core.player.viewmodel
|
package hu.bbara.purefin.core.player.viewmodel
|
||||||
|
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
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.data.client.PlaybackReportContext
|
||||||
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.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 org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -43,6 +47,8 @@ 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 dataErrorMessage: String? = null
|
private var dataErrorMessage: String? = null
|
||||||
|
private var activeMediaId: String? = null
|
||||||
|
private var transcodingRetryMediaId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
progressManager.bind(
|
progressManager.bind(
|
||||||
@@ -57,6 +63,10 @@ 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,
|
||||||
@@ -93,6 +103,10 @@ 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)
|
||||||
@@ -133,6 +147,15 @@ 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
|
||||||
|
) {
|
||||||
val uuid = id.toUuidOrNull()
|
val uuid = id.toUuidOrNull()
|
||||||
if (uuid == null) {
|
if (uuid == null) {
|
||||||
dataErrorMessage = "Invalid media id"
|
dataErrorMessage = "Invalid media id"
|
||||||
@@ -140,15 +163,20 @@ class PlayerViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = playerMediaRepository.getMediaItem(uuid)
|
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
val (mediaItem, resumePositionMs) = result
|
val (mediaItem, resumePositionMs) = result
|
||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
playerManager.play(mediaItem, mediaContext, resumePositionMs)
|
if (replaceCurrent) {
|
||||||
|
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
|
||||||
|
} else {
|
||||||
|
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
||||||
|
}
|
||||||
|
|
||||||
if (dataErrorMessage != null) {
|
if (dataErrorMessage != null) {
|
||||||
dataErrorMessage = null
|
dataErrorMessage = null
|
||||||
@@ -161,6 +189,48 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
|
||||||
|
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
|
||||||
|
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
|
||||||
|
val errorCode = state.errorCode ?: return false
|
||||||
|
|
||||||
|
if (currentMediaId == transcodingRetryMediaId) return false
|
||||||
|
if (!playbackReportContext.canRetryWithTranscoding) return false
|
||||||
|
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
|
||||||
|
|
||||||
|
transcodingRetryMediaId = currentMediaId
|
||||||
|
loadMediaById(
|
||||||
|
id = currentMediaId,
|
||||||
|
forceTranscode = true,
|
||||||
|
startPositionMsOverride = player.currentPosition.takeIf { it > 0L },
|
||||||
|
replaceCurrent = true
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isRetryablePlaybackError(
|
||||||
|
errorCode: Int,
|
||||||
|
errorMessage: String?,
|
||||||
|
playbackReportContext: PlaybackReportContext
|
||||||
|
): Boolean {
|
||||||
|
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCode in setOf(
|
||||||
|
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val message = errorMessage?.lowercase().orEmpty()
|
||||||
|
return "decoder" in message || "codec" in message || "unsupported" in message
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadNextUp(currentMediaId: String) {
|
private fun loadNextUp(currentMediaId: String) {
|
||||||
val uuid = currentMediaId.toUuidOrNull() ?: return
|
val uuid = currentMediaId.toUuidOrNull() ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
Reference in New Issue
Block a user