diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt index 4635759cf..e97abb2af 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt @@ -20,7 +20,7 @@ interface DownloadDao { fun getAllDownloads(): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE item_id LIKE :downloadId") - suspend fun get(downloadId: String): DownloadEntity + suspend fun get(downloadId: String): DownloadEntity? @Query("SELECT download_folder_uri FROM $TABLE_NAME WHERE item_id LIKE :downloadId") suspend fun getDownloadFolderUri(downloadId: String): String diff --git a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt index 71e8cbc7b..c38fc9270 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt @@ -4,8 +4,10 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import kotlinx.serialization.json.Json.Default.decodeFromString import org.jellyfin.mobile.data.entity.DownloadEntity.Key.ITEM_ID import org.jellyfin.mobile.data.entity.DownloadEntity.Key.TABLE_NAME +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource @Entity( tableName = TABLE_NAME, @@ -28,6 +30,8 @@ data class DownloadEntity( @ColumnInfo(name = DOWNLOAD_LENGTH) val downloadLength: Long, ) { + fun asMediaSource() = decodeFromString(mediaSource) + constructor(itemId: String, mediaUri: String, mediaSource: String, downloadFolderUri: String, downloadLength: Long) : this(0, itemId, mediaUri, mediaSource, downloadFolderUri, downloadLength) diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt index acd157b28..5a17cbb2d 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt @@ -4,13 +4,13 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import kotlinx.serialization.json.Json import org.jellyfin.mobile.data.entity.DownloadEntity -import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.mobile.utils.Constants import java.io.File import java.util.Locale data class DownloadItem(private val download: DownloadEntity) { - val mediaSource: JellyfinMediaSource = Json.decodeFromString(download.mediaSource) + val mediaSource: RemoteJellyfinMediaSource = Json.decodeFromString(download.mediaSource) val thumbnail: Bitmap? = BitmapFactory.decodeFile( File(download.downloadFolderUri, Constants.DOWNLOAD_THUMBNAIL_FILENAME).canonicalPath, ) diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt index 925c443de..6e40b410f 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt @@ -37,8 +37,8 @@ import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder -import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.player.source.MediaSourceResolver +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.mobile.utils.AndroidVersion import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.requestPermission @@ -79,7 +79,7 @@ class DownloadUtils( private var downloadTracker: DownloadTracker private val jellyfinDownloadTracker: DownloadUtils.JellyfinDownloadTracker = JellyfinDownloadTracker() - private var jellyfinMediaSource: JellyfinMediaSource? = null + private var jellyfinMediaSource: RemoteJellyfinMediaSource? = null init { val regex = Regex("""Items/([a-f0-9]{32})/Download""") diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt index c2a34b68b..5c45b4d9a 100644 --- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt @@ -2,7 +2,7 @@ package org.jellyfin.mobile.events import android.net.Uri import org.jellyfin.mobile.player.interaction.PlayOptions -import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.json.JSONArray sealed class ActivityEvent { @@ -10,7 +10,7 @@ sealed class ActivityEvent { class LaunchNativePlayer(val playOptions: PlayOptions) : ActivityEvent() class OpenUrl(val uri: String) : ActivityEvent() class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent() - class RemoveDownload(val download: JellyfinMediaSource, val force: Boolean = false) : ActivityEvent() + class RemoveDownload(val download: RemoteJellyfinMediaSource, val force: Boolean = false) : ActivityEvent() class CastMessage(val action: String, val args: JSONArray) : ActivityEvent() data object RequestBluetoothPermission : ActivityEvent() data object OpenSettings : ActivityEvent() diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index bf321ab5e..febb5ac20 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -40,6 +40,7 @@ import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper import org.jellyfin.mobile.player.queue.QueueManager import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.mobile.player.ui.DecoderType import org.jellyfin.mobile.player.ui.DisplayPreferences import org.jellyfin.mobile.player.ui.PlayState @@ -241,7 +242,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), _player.value = null } - fun load(jellyfinMediaSource: JellyfinMediaSource, exoMediaSource: MediaSource, playWhenReady: Boolean) { + fun load(jellyfinMediaSource: RemoteJellyfinMediaSource, exoMediaSource: MediaSource, playWhenReady: Boolean) { val player = playerOrNull ?: return player.setMediaSource(exoMediaSource) @@ -261,12 +262,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), } private fun startProgressUpdates() { - if (queueManager.currentMediaSourceOrNull?.isDownload == false) { - progressUpdateJob = viewModelScope.launch { - while (true) { - delay(Constants.PLAYER_TIME_UPDATE_RATE) - playerOrNull?.reportPlaybackState() - } + if (mediaSourceOrNull != null && mediaSourceOrNull !is RemoteJellyfinMediaSource) return + progressUpdateJob = viewModelScope.launch { + while (true) { + delay(Constants.PLAYER_TIME_UPDATE_RATE) + playerOrNull?.reportPlaybackState() } } } @@ -294,8 +294,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), queueManager.tryRestartPlayback() } - private suspend fun Player.reportPlaybackStart(mediaSource: JellyfinMediaSource) { - if (mediaSource.isDownload) return + private suspend fun Player.reportPlaybackStart(mediaSource: RemoteJellyfinMediaSource) { + if (mediaSourceOrNull != null && mediaSourceOrNull !is RemoteJellyfinMediaSource) return try { playStateApi.reportPlaybackStart( PlaybackStartInfo( @@ -318,9 +318,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), } private suspend fun Player.reportPlaybackState() { - val mediaSource = mediaSourceOrNull ?: return + val mediaSource = mediaSourceOrNull as? RemoteJellyfinMediaSource ?: return val playbackPositionMillis = currentPosition - if (playbackState != Player.STATE_ENDED && !mediaSource.isDownload) { + if (playbackState != Player.STATE_ENDED) { val stream = AudioManager.STREAM_MUSIC val volumeRange = audioManager.getVolumeRange(stream) val currentVolume = audioManager.getStreamVolume(stream) @@ -347,9 +347,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), } private fun reportPlaybackStop() { - val mediaSource = mediaSourceOrNull ?: return + val mediaSource = mediaSourceOrNull as? RemoteJellyfinMediaSource ?: return val player = playerOrNull ?: return - if (mediaSourceOrNull?.isDownload == true) return val hasFinished = player.playbackState == Player.STATE_ENDED val lastPositionTicks = when { hasFinished -> mediaSource.runTimeTicks @@ -383,7 +382,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), } } - suspend fun stopTranscoding(mediaSource: JellyfinMediaSource) { + suspend fun stopTranscoding(mediaSource: RemoteJellyfinMediaSource) { if (mediaSource.playMethod == PlayMethod.TRANSCODE) { hlsSegmentApi.stopEncodingProcess( deviceId = apiClient.deviceInfo.id, diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt index 263fb79c3..3628894d6 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt @@ -26,6 +26,8 @@ import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.player.PlayerViewModel import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.mobile.utils.AndroidVersion import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID @@ -148,21 +150,24 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom } private suspend fun loadImage(mediaSource: JellyfinMediaSource): Bitmap? { - if (mediaSource.isDownload) { - val downloadFolder = File(downloadDao.getDownloadFolderUri(mediaSource.id)) - val thumbnailFile = File(downloadFolder, Constants.DOWNLOAD_THUMBNAIL_FILENAME) - return BitmapFactory.decodeFile(thumbnailFile.canonicalPath) - } else { - val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height) - - val imageUrl = imageApi.getItemImageUrl( - itemId = mediaSource.itemId, - imageType = ImageType.PRIMARY, - maxWidth = size, - maxHeight = size, - ) - val imageRequest = ImageRequest.Builder(context).data(imageUrl).build() - return imageLoader.execute(imageRequest).drawable?.toBitmap() + return when (mediaSource) { + is LocalJellyfinMediaSource -> { + val downloadFolder = File(downloadDao.getDownloadFolderUri(mediaSource.id)) + val thumbnailFile = File(downloadFolder, Constants.DOWNLOAD_THUMBNAIL_FILENAME) + BitmapFactory.decodeFile(thumbnailFile.canonicalPath) + } + is RemoteJellyfinMediaSource -> { + val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height) + + val imageUrl = imageApi.getItemImageUrl( + itemId = mediaSource.itemId, + imageType = ImageType.PRIMARY, + maxWidth = size, + maxHeight = size, + ) + val imageRequest = ImageRequest.Builder(context).data(imageUrl).build() + imageLoader.execute(imageRequest).drawable?.toBitmap() + } } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt index 4c655e6b9..c1dceaabf 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt @@ -18,7 +18,7 @@ import org.jellyfin.mobile.player.PlayerViewModel import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder import org.jellyfin.mobile.player.interaction.PlayOptions import org.jellyfin.mobile.player.source.ExternalSubtitleStream -import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.mobile.player.source.MediaSourceResolver import org.jellyfin.mobile.utils.Constants import org.jellyfin.sdk.api.client.ApiClient @@ -47,11 +47,11 @@ class QueueManager( private var currentQueue: List = emptyList() private var currentQueueIndex: Int = 0 - private val _currentMediaSource: MutableLiveData = MutableLiveData() - val currentMediaSource: LiveData + private val _currentMediaSource: MutableLiveData = MutableLiveData() + val currentMediaSource: LiveData get() = _currentMediaSource - inline val currentMediaSourceOrNull: JellyfinMediaSource? + inline val currentMediaSourceOrNull: RemoteJellyfinMediaSource? get() = currentMediaSource.value /** @@ -76,7 +76,7 @@ class QueueManager( playWhenReady = true, ) } - else -> startPlayback( + else -> startRemotePlayback( itemId = itemId, mediaSourceId = playOptions.mediaSourceId, maxStreamingBitrate = null, @@ -94,18 +94,15 @@ class QueueManager( mediaSourceId: String, playWhenReady: Boolean, ): PlayerException? { - mediaSourceResolver.resolveDownloadSource( - mediaSourceId = mediaSourceId, - downloadDao = get(), - ).onSuccess { jellyfinMediaSource -> - _currentMediaSource.value = jellyfinMediaSource - - // Load new media source - viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady) - }.onFailure { error -> - // Should always be of this type, other errors are silently dropped - return error as? PlayerException - } + get() + .get(mediaSourceId) + ?.asMediaSource() + ?.also { jellyfinMediaSource -> + _currentMediaSource.value = jellyfinMediaSource + + // Load new media source + viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady) + } return null } @@ -114,7 +111,7 @@ class QueueManager( * * @return an error of type [PlayerException] or null on success. */ - private suspend fun startPlayback( + private suspend fun startRemotePlayback( itemId: UUID, mediaSourceId: String?, maxStreamingBitrate: Int?, @@ -168,7 +165,7 @@ class QueueManager( val currentPlayState = viewModel.getStateAndPause() ?: return false - return startPlayback( + return startRemotePlayback( itemId = currentMediaSource.itemId, mediaSourceId = currentMediaSource.id, maxStreamingBitrate = bitrate, @@ -188,7 +185,7 @@ class QueueManager( val currentMediaSource = currentMediaSourceOrNull ?: return false - startPlayback( + startRemotePlayback( itemId = currentQueue[--currentQueueIndex], mediaSourceId = null, maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, @@ -201,7 +198,7 @@ class QueueManager( val currentMediaSource = currentMediaSourceOrNull ?: return false - startPlayback( + startRemotePlayback( itemId = currentQueue[++currentQueueIndex], mediaSourceId = null, maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, @@ -212,24 +209,17 @@ class QueueManager( /** * Builds the [MediaSource] to be played by ExoPlayer. * - * @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played. + * @param source The [RemoteJellyfinMediaSource] object containing all necessary info about the item to be played. * @return A [MediaSource]. This can be the media stream of the correct type for the playback method or * a [MergingMediaSource] containing the mentioned media stream and all external subtitle streams. */ @CheckResult - private fun prepareStreams(source: JellyfinMediaSource): MediaSource { - if (source.isDownload) { - val downloadDao: DownloadDao = get() - val fileUri = runBlocking { - downloadDao.getMediaUri(source.id) - } - return prepareDownloadStreams(source, fileUri) - } else { - return prepareSeverStreams(source) - } - } + private fun prepareStreams(source: RemoteJellyfinMediaSource) = get() + .let { runBlocking { it.get(source.id) } } + ?.let { prepareDownloadStreams(it.asMediaSource(), it.mediaUri) } + ?: prepareSeverStreams(source) - private fun prepareSeverStreams(source: JellyfinMediaSource): MediaSource { + private fun prepareSeverStreams(source: RemoteJellyfinMediaSource): MediaSource { val videoSource = createVideoMediaSource(source) val subtitleSources = createExternalSubtitleMediaSources(source) return when { @@ -241,11 +231,11 @@ class QueueManager( /** * Builds the [MediaSource] for the main media stream (video/audio/embedded subs). * - * @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played. + * @param source The [RemoteJellyfinMediaSource] object containing all necessary info about the item to be played. * @return A [MediaSource]. The type of MediaSource depends on the playback method/protocol. */ @CheckResult - private fun createVideoMediaSource(source: JellyfinMediaSource): MediaSource { + private fun createVideoMediaSource(source: RemoteJellyfinMediaSource): MediaSource { val sourceInfo = source.sourceInfo val (url, factory) = when (source.playMethod) { PlayMethod.DIRECT_PLAY -> { @@ -302,14 +292,14 @@ class QueueManager( } /** - * Creates [MediaSource]s for all external subtitle streams in the [JellyfinMediaSource]. + * Creates [MediaSource]s for all external subtitle streams in the [RemoteJellyfinMediaSource]. * - * @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played. + * @param source The [RemoteJellyfinMediaSource] object containing all necessary info about the item to be played. * @return The parsed MediaSources for the subtitles. */ @CheckResult private fun createExternalSubtitleMediaSources( - source: JellyfinMediaSource, + source: RemoteJellyfinMediaSource, ): Array { val factory = get() return source.externalSubtitleStreams.map { stream -> @@ -324,7 +314,7 @@ class QueueManager( }.toTypedArray() } - private fun prepareDownloadStreams(source: JellyfinMediaSource, fileUri: String): MediaSource { + private fun prepareDownloadStreams(source: RemoteJellyfinMediaSource, fileUri: String): MediaSource { val videoSource: MediaSource = createDownloadVideoMediaSource(source.id, fileUri) val subtitleSources: Array = createDownloadExternalSubtitleMediaSources(source, fileUri) return when { @@ -346,7 +336,7 @@ class QueueManager( } @CheckResult - private fun createDownloadExternalSubtitleMediaSources(source: JellyfinMediaSource, fileUri: String): Array { + private fun createDownloadExternalSubtitleMediaSources(source: RemoteJellyfinMediaSource, fileUri: String): Array { val downloadDir: String = File(fileUri).parent val factory = get() return source.externalSubtitleStreams.map { stream -> @@ -371,7 +361,7 @@ class QueueManager( val currentMediaSource = currentMediaSourceOrNull ?: return false val currentPlayState = viewModel.getStateAndPause() ?: return false - startPlayback( + startRemotePlayback( itemId = currentMediaSource.itemId, mediaSourceId = currentMediaSource.id, maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, @@ -395,7 +385,7 @@ class QueueManager( val currentMediaSource = currentMediaSourceOrNull ?: return false val currentPlayState = viewModel.getStateAndPause() ?: return false - startPlayback( + startRemotePlayback( itemId = currentMediaSource.itemId, mediaSourceId = currentMediaSource.id, maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt index de186da89..7d0fb7676 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt @@ -1,6 +1,5 @@ package org.jellyfin.mobile.player.source -import kotlinx.serialization.Serializable import org.jellyfin.mobile.player.deviceprofile.CodecHelpers import org.jellyfin.mobile.utils.Constants import org.jellyfin.sdk.model.api.BaseItemDto @@ -11,58 +10,44 @@ import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import java.util.UUID -@Serializable(with = JellyfinMediaSourceSerializer::class) -class JellyfinMediaSource( +sealed class JellyfinMediaSource( val itemId: UUID, val item: BaseItemDto?, val sourceInfo: MediaSourceInfo, val playSessionId: String, - val liveStreamId: String?, - val maxStreamingBitrate: Int?, - var isDownload: Boolean = false, - private var startTimeTicks: Long? = null, - audioStreamIndex: Int? = null, - subtitleStreamIndex: Int? = null, + private var startTimeTicks: Long?, + audioStreamIndex: Int?, + subtitleStreamIndex: Int?, ) { val id: String = requireNotNull(sourceInfo.id) { "Media source has no id" } val name: String = item?.name ?: sourceInfo.name.orEmpty() - - val playMethod: PlayMethod = when { - sourceInfo.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - sourceInfo.supportsDirectStream -> PlayMethod.DIRECT_STREAM - sourceInfo.supportsTranscoding -> PlayMethod.TRANSCODE - else -> throw IllegalArgumentException("No play method found for $name ($itemId)") - } - - var startTimeMs: Long - get() = (startTimeTicks ?: 0L) / Constants.TICKS_PER_MILLISECOND - set(value) { - startTimeTicks = value * Constants.TICKS_PER_MILLISECOND - } + abstract val playMethod: PlayMethod val runTimeTicks: Long = sourceInfo.runTimeTicks ?: 0 val runTimeMs: Long = runTimeTicks / Constants.TICKS_PER_MILLISECOND val mediaStreams: List = sourceInfo.mediaStreams.orEmpty() - val audioStreams: List - val subtitleStreams: List - val externalSubtitleStreams: List - + lateinit var audioStreams: List + lateinit var subtitleStreams: List + lateinit var externalSubtitleStreams: List var selectedVideoStream: MediaStream? = null private set var selectedAudioStream: MediaStream? = null private set var selectedSubtitleStream: MediaStream? = null private set - val selectedAudioStreamIndex: Int? get() = selectedAudioStream?.index val selectedSubtitleStreamIndex: Int // -1 disables subtitles, null would select the default subtitle // If the default should be played, it would be explicitly set above get() = selectedSubtitleStream?.index ?: -1 + var startTimeMs: Long + get() = (startTimeTicks ?: 0L) / Constants.TICKS_PER_MILLISECOND + set(value) { + startTimeTicks = value * Constants.TICKS_PER_MILLISECOND + } init { - // Classify MediaStreams val audio = ArrayList() val subtitles = ArrayList() val externalSubtitles = ArrayList() @@ -109,10 +94,6 @@ class JellyfinMediaSource( -> Unit // ignore } } - - audioStreams = audio - subtitleStreams = subtitles - externalSubtitleStreams = externalSubtitles } /** diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt index 088a105e9..5f81d817d 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt @@ -15,7 +15,7 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID -class JellyfinMediaSourceSerializer : KSerializer { +class JellyfinMediaSourceSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JellyfinMediaSource") { element("itemId") element("item", isOptional = true) @@ -23,7 +23,7 @@ class JellyfinMediaSourceSerializer : KSerializer { element("playSessionId") } - override fun serialize(encoder: Encoder, mediaSource: JellyfinMediaSource) = + override fun serialize(encoder: Encoder, mediaSource: LocalJellyfinMediaSource) = encoder.encodeStructure(descriptor) { encodeStringElement(descriptor, 0, mediaSource.itemId.toString()) encodeNullableSerializableElement(descriptor, 1, BaseItemDto.serializer(), mediaSource.item) @@ -31,7 +31,7 @@ class JellyfinMediaSourceSerializer : KSerializer { encodeStringElement(descriptor, 3, mediaSource.playSessionId) } - override fun deserialize(decoder: Decoder): JellyfinMediaSource = + override fun deserialize(decoder: Decoder): LocalJellyfinMediaSource = decoder.decodeStructure(descriptor) { var itemId: UUID? = null var item: BaseItemDto? = null @@ -51,13 +51,11 @@ class JellyfinMediaSourceSerializer : KSerializer { require(itemId != null && sourceInfo != null && playSessionId != null) - JellyfinMediaSource( + LocalJellyfinMediaSource( itemId = itemId, item = item, sourceInfo = sourceInfo, playSessionId = playSessionId, - liveStreamId = null, - maxStreamingBitrate = null, ) } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt new file mode 100644 index 000000000..beab99004 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt @@ -0,0 +1,20 @@ +package org.jellyfin.mobile.player.source + +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.PlayMethod +import java.util.UUID + +@Serializable(with = JellyfinMediaSourceSerializer::class) +class LocalJellyfinMediaSource( + itemId: UUID, + item: BaseItemDto?, + sourceInfo: MediaSourceInfo, + playSessionId: String, + startTimeTicks: Long? = null, + audioStreamIndex: Int? = null, + subtitleStreamIndex: Int? = null, +) : JellyfinMediaSource(itemId, item, sourceInfo, playSessionId, startTimeTicks, audioStreamIndex, subtitleStreamIndex) { + override val playMethod: PlayMethod = PlayMethod.DIRECT_PLAY +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt index 6c8971127..d6f6e6cab 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt @@ -1,8 +1,5 @@ package org.jellyfin.mobile.player.source -import kotlinx.serialization.json.Json -import org.jellyfin.mobile.data.dao.DownloadDao -import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.player.PlayerException import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException @@ -32,7 +29,7 @@ class MediaSourceResolver(private val apiClient: ApiClient) { audioStreamIndex: Int? = null, subtitleStreamIndex: Int? = null, autoOpenLiveStream: Boolean = true, - ): Result { + ): Result { // Load media source info val playSessionId: String val mediaSourceInfo: MediaSourceInfo = try { @@ -74,7 +71,7 @@ class MediaSourceResolver(private val apiClient: ApiClient) { // Create JellyfinMediaSource return try { - val source = JellyfinMediaSource( + val source = RemoteJellyfinMediaSource( itemId = itemId, item = item, sourceInfo = mediaSourceInfo, @@ -91,11 +88,4 @@ class MediaSourceResolver(private val apiClient: ApiClient) { Result.failure(PlayerException.UnsupportedContent(e)) } } - - suspend fun resolveDownloadSource(mediaSourceId: String, downloadDao: DownloadDao): Result { - val download: DownloadEntity = downloadDao.get(mediaSourceId) - var jellyfinMediaSource: JellyfinMediaSource = Json.decodeFromString(download.mediaSource) - jellyfinMediaSource.isDownload = true - return Result.success(jellyfinMediaSource) - } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/RemoteJellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/RemoteJellyfinMediaSource.kt new file mode 100644 index 000000000..a678bab7c --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/source/RemoteJellyfinMediaSource.kt @@ -0,0 +1,25 @@ +package org.jellyfin.mobile.player.source + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.PlayMethod +import java.util.UUID + +class RemoteJellyfinMediaSource( + itemId: UUID, + item: BaseItemDto?, + sourceInfo: MediaSourceInfo, + playSessionId: String, + val liveStreamId: String?, + val maxStreamingBitrate: Int?, + startTimeTicks: Long? = null, + val audioStreamIndex: Int? = null, + val subtitleStreamIndex: Int? = null, +) : JellyfinMediaSource(itemId, item, sourceInfo, playSessionId, startTimeTicks, audioStreamIndex, subtitleStreamIndex) { + override val playMethod: PlayMethod = when { + sourceInfo.supportsDirectPlay -> PlayMethod.DIRECT_PLAY + sourceInfo.supportsDirectStream -> PlayMethod.DIRECT_STREAM + sourceInfo.supportsTranscoding -> PlayMethod.TRANSCODE + else -> throw IllegalArgumentException("No play method found for $name ($itemId)") + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt index 800e593c3..491c3e295 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt @@ -15,6 +15,8 @@ import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.sdk.model.api.MediaStream import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -130,10 +132,15 @@ class PlayerMenus( val height = videoStream?.height val width = videoStream?.width - if (!mediaSource.isDownload && height != null && width != null) { - buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height) - } else { - qualityButton.isVisible = false + when (mediaSource) { + is LocalJellyfinMediaSource -> { + qualityButton.isVisible = false + } + is RemoteJellyfinMediaSource -> { + if (height != null && width != null) { + buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height) + } + } } val playMethod = context.getString(R.string.playback_info_play_method, mediaSource.playMethod) diff --git a/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt index 66b695d51..8a992eb14 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt @@ -11,7 +11,7 @@ import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.analytics.AnalyticsCollector -import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.mobile.utils.extensions.width import com.google.android.exoplayer2.audio.AudioAttributes as ExoPlayerAudioAttributes @@ -26,7 +26,7 @@ inline fun MediaSession.applyDefaultLocalAudioAttributes(contentType: Int) { setPlaybackToLocal(audioAttributes) } -fun JellyfinMediaSource.toMediaMetadata(): MediaMetadata = MediaMetadata.Builder().apply { +fun RemoteJellyfinMediaSource.toMediaMetadata(): MediaMetadata = MediaMetadata.Builder().apply { putString(MediaMetadata.METADATA_KEY_MEDIA_ID, itemId.toString()) putString(MediaMetadata.METADATA_KEY_TITLE, name) putLong(MediaMetadata.METADATA_KEY_DURATION, runTimeMs) diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt index a0936daff..1432b7cb6 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt @@ -29,7 +29,7 @@ import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.downloads.DownloadMethod import org.jellyfin.mobile.downloads.DownloadUtils import org.jellyfin.mobile.downloads.JellyfinDownloadService -import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.mobile.settings.ExternalPlayerPackage import org.jellyfin.mobile.webapp.WebViewFragment import org.koin.android.ext.android.get @@ -106,7 +106,7 @@ suspend fun MainActivity.requestDownload(uri: Uri, filename: String) { downloadUtils.download() } } -suspend fun MainActivity.removeDownload(download: JellyfinMediaSource, force: Boolean = false) { +suspend fun MainActivity.removeDownload(download: RemoteJellyfinMediaSource, force: Boolean = false) { if (!force) { val confirmation = suspendCancellableCoroutine { continuation -> AlertDialog.Builder(this) @@ -129,7 +129,7 @@ suspend fun MainActivity.removeDownload(download: JellyfinMediaSource, force: Bo } val downloadDao: DownloadDao = get() - val downloadEntity: DownloadEntity = downloadDao.get(download.id) + val downloadEntity: DownloadEntity = requireNotNull(downloadDao.get(download.id)) val downloadDir = File(downloadEntity.downloadFolderUri) downloadDao.delete(download.id) downloadDir?.deleteRecursively()