feat: Transcoding stream in player selection /code: prep repo for next commit transcoding downloads
This commit is contained in:
parent
db79b50629
commit
ccc6788a02
8 changed files with 489 additions and 4 deletions
|
@ -33,6 +33,7 @@ import androidx.media3.ui.DefaultTimeBar
|
||||||
import androidx.media3.ui.PlayerControlView
|
import androidx.media3.ui.PlayerControlView
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.navigation.navArgs
|
import androidx.navigation.navArgs
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
|
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
|
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
|
||||||
|
@ -82,6 +83,10 @@ class PlayerActivity : BasePlayerActivity() {
|
||||||
|
|
||||||
binding = ActivityPlayerBinding.inflate(layoutInflater)
|
binding = ActivityPlayerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val changeQualityButton: ImageButton = findViewById(R.id.btnChangeQuality)
|
||||||
|
changeQualityButton.setOnClickListener {
|
||||||
|
showQualitySelectionDialog()
|
||||||
|
}
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
binding.playerView.player = viewModel.player
|
binding.playerView.player = viewModel.player
|
||||||
|
@ -342,6 +347,23 @@ class PlayerActivity : BasePlayerActivity() {
|
||||||
} catch (_: IllegalArgumentException) { }
|
} catch (_: IllegalArgumentException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showQualitySelectionDialog() {
|
||||||
|
val height = viewModel.getOriginalHeight() // TODO: rewrite getting height stuff I don't like that its only update after changing quality
|
||||||
|
val qualities = when (height) {
|
||||||
|
0 -> arrayOf("Auto", "Original - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
|
||||||
|
in 1001..1999 -> arrayOf("Auto", "Original (1080p) - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
|
||||||
|
in 2000..3000 -> arrayOf("Auto", "Original (4K) - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
|
||||||
|
else -> arrayOf("Auto", "Original - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle("Select Video Quality")
|
||||||
|
.setItems(qualities) { _, which ->
|
||||||
|
val selectedQuality = qualities[which]
|
||||||
|
viewModel.changeVideoQuality(selectedQuality)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPictureInPictureModeChanged(
|
override fun onPictureInPictureModeChanged(
|
||||||
isInPictureInPictureMode: Boolean,
|
isInPictureInPictureMode: Boolean,
|
||||||
newConfig: Configuration,
|
newConfig: Configuration,
|
||||||
|
|
|
@ -73,6 +73,24 @@
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<!--TODO: Content Desc to Strings-->
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnChangeQuality"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/transparent_circle_background"
|
||||||
|
android:contentDescription="Quality"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:src="@drawable/ic_quality"
|
||||||
|
android:layout_gravity="end"
|
||||||
|
app:tint="@android:color/white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="16dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_pip"
|
android:id="@+id/btn_pip"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|
35
core/src/main/res/drawable/ic_quality.xml
Normal file
35
core/src/main/res/drawable/ic_quality.xml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:pathData="M10,7.75a0.75,0.75 0,0 1,1.142 -0.638l3.664,2.249a0.75,0.75 0,0 1,0 1.278l-3.664,2.25a0.75,0.75 0,0 1,-1.142 -0.64z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M12,17v4"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M8,21h8"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M4,3L20,3A2,2 0,0 1,22 5L22,15A2,2 0,0 1,20 17L4,17A2,2 0,0 1,2 15L2,5A2,2 0,0 1,4 3z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
</vector>
|
|
@ -11,9 +11,13 @@ import dev.jdtech.jellyfin.models.FindroidSource
|
||||||
import dev.jdtech.jellyfin.models.Intro
|
import dev.jdtech.jellyfin.models.Intro
|
||||||
import dev.jdtech.jellyfin.models.SortBy
|
import dev.jdtech.jellyfin.models.SortBy
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.jellyfin.sdk.api.client.Response
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
|
@ -81,7 +85,7 @@ interface JellyfinRepository {
|
||||||
|
|
||||||
suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List<FindroidSource>
|
suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List<FindroidSource>
|
||||||
|
|
||||||
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String
|
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String? = null): String
|
||||||
|
|
||||||
suspend fun getIntroTimestamps(itemId: UUID): Intro?
|
suspend fun getIntroTimestamps(itemId: UUID): Intro?
|
||||||
|
|
||||||
|
@ -112,4 +116,18 @@ interface JellyfinRepository {
|
||||||
suspend fun getDownloads(): List<FindroidItem>
|
suspend fun getDownloads(): List<FindroidItem>
|
||||||
|
|
||||||
fun getUserId(): UUID
|
fun getUserId(): UUID
|
||||||
|
|
||||||
|
suspend fun getDeviceId(): String
|
||||||
|
|
||||||
|
suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int>
|
||||||
|
|
||||||
|
suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile
|
||||||
|
|
||||||
|
suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String
|
||||||
|
|
||||||
|
suspend fun getTranscodedVideoStream(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int): String
|
||||||
|
|
||||||
|
suspend fun getPostedPlaybackInfo(itemId: UUID, enableDirectStream: Boolean, deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse>
|
||||||
|
|
||||||
|
suspend fun stopEncodingProcess(playSessionId: String)
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,23 +28,35 @@ import io.ktor.util.toByteArray
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.Response
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.get
|
import org.jellyfin.sdk.api.client.extensions.get
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
|
||||||
import org.jellyfin.sdk.model.api.DeviceOptionsDto
|
import org.jellyfin.sdk.model.api.DeviceOptionsDto
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||||
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
import org.jellyfin.sdk.model.api.GeneralCommandType
|
import org.jellyfin.sdk.model.api.GeneralCommandType
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.ItemFilter
|
import org.jellyfin.sdk.model.api.ItemFilter
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
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.ProfileCondition
|
||||||
|
import org.jellyfin.sdk.model.api.ProfileConditionType
|
||||||
|
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
import org.jellyfin.sdk.model.api.SubtitleProfile
|
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||||
|
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
|
||||||
|
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -322,13 +334,14 @@ class JellyfinRepositoryImpl(
|
||||||
sources
|
sources
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String =
|
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String?): String =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
jellyfinApi.videosApi.getVideoStreamUrl(
|
jellyfinApi.videosApi.getVideoStreamUrl(
|
||||||
itemId,
|
itemId,
|
||||||
static = true,
|
static = true,
|
||||||
mediaSourceId = mediaSourceId,
|
mediaSourceId = mediaSourceId,
|
||||||
|
playSessionId = playSessionId
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
Timber.e(e)
|
||||||
|
@ -536,4 +549,165 @@ class JellyfinRepositoryImpl(
|
||||||
override fun getUserId(): UUID {
|
override fun getUserId(): UUID {
|
||||||
return jellyfinApi.userId!!
|
return jellyfinApi.userId!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
|
||||||
|
return when (transcodeResolution) {
|
||||||
|
1080 -> 8000000 to 384000 // Adjusted for personal can be other values
|
||||||
|
720 -> 2000000 to 384000 // 720p
|
||||||
|
480 -> 1000000 to 384000 // 480p
|
||||||
|
360 -> 800000 to 128000 // 360p
|
||||||
|
else -> 12000000 to 384000 // its adaptive but setting max here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile {
|
||||||
|
val deviceProfile = ClientCapabilitiesDto(
|
||||||
|
supportedCommands = emptyList(),
|
||||||
|
playableMediaTypes = emptyList(),
|
||||||
|
supportsMediaControl = true,
|
||||||
|
supportsPersistentIdentifier = true,
|
||||||
|
deviceProfile = DeviceProfile(
|
||||||
|
name = "AnanasUser",
|
||||||
|
id = getUserId().toString(),
|
||||||
|
maxStaticBitrate = maxBitrate,
|
||||||
|
maxStreamingBitrate = maxBitrate,
|
||||||
|
codecProfiles = emptyList(),
|
||||||
|
containerProfiles = listOf(),
|
||||||
|
directPlayProfiles = listOf(
|
||||||
|
DirectPlayProfile(type = DlnaProfileType.VIDEO),
|
||||||
|
DirectPlayProfile(type = DlnaProfileType.AUDIO),
|
||||||
|
),
|
||||||
|
transcodingProfiles = listOf(
|
||||||
|
TranscodingProfile(
|
||||||
|
container = container,
|
||||||
|
context = context,
|
||||||
|
protocol = MediaStreamProtocol.HLS,
|
||||||
|
audioCodec = "aac,ac3,eac3",
|
||||||
|
videoCodec = "hevc,h264",
|
||||||
|
type = DlnaProfileType.VIDEO,
|
||||||
|
conditions = listOf(
|
||||||
|
ProfileCondition(
|
||||||
|
condition = ProfileConditionType.LESS_THAN_EQUAL,
|
||||||
|
property = ProfileConditionValue.VIDEO_BITRATE,
|
||||||
|
value = "8000000",
|
||||||
|
isRequired = true,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
copyTimestamps = true,
|
||||||
|
enableSubtitlesInManifest = true,
|
||||||
|
transcodeSeekInfo = TranscodeSeekInfo.AUTO,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitleProfiles = listOf(
|
||||||
|
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return deviceProfile.deviceProfile!!
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override suspend fun getPostedPlaybackInfo(itemId: UUID ,enableDirectStream: Boolean ,deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse> {
|
||||||
|
val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
|
||||||
|
itemId = itemId,
|
||||||
|
PlaybackInfoDto(
|
||||||
|
userId = jellyfinApi.userId!!,
|
||||||
|
enableTranscoding = true,
|
||||||
|
enableDirectPlay = false,
|
||||||
|
enableDirectStream = enableDirectStream,
|
||||||
|
autoOpenLiveStream = true,
|
||||||
|
deviceProfile = deviceProfile,
|
||||||
|
allowAudioStreamCopy = true,
|
||||||
|
allowVideoStreamCopy = true,
|
||||||
|
maxStreamingBitrate = maxBitrate,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return playbackInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String {
|
||||||
|
val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl(
|
||||||
|
itemId,
|
||||||
|
static = false,
|
||||||
|
deviceId = deviceId,
|
||||||
|
mediaSourceId = mediaSourceId,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
videoBitRate = videoBitrate,
|
||||||
|
audioBitRate = 384000,
|
||||||
|
videoCodec = "hevc",
|
||||||
|
audioCodec = "aac,ac3,eac3",
|
||||||
|
container = container,
|
||||||
|
startTimeTicks = 0,
|
||||||
|
copyTimestamps = true,
|
||||||
|
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTranscodedVideoStream(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int): String {
|
||||||
|
val isAuto = videoBitrate == 12000000
|
||||||
|
val url = if (!isAuto) {
|
||||||
|
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
|
||||||
|
itemId,
|
||||||
|
static = false,
|
||||||
|
deviceId = deviceId,
|
||||||
|
mediaSourceId = mediaSourceId,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
videoBitRate = videoBitrate,
|
||||||
|
enableAdaptiveBitrateStreaming = false,
|
||||||
|
audioBitRate = 384000, //could also be passed with audioBitrate but i preferred not as its not much data anyways
|
||||||
|
videoCodec = "hevc,h264",
|
||||||
|
audioCodec = "aac,ac3,eac3",
|
||||||
|
startTimeTicks = 0,
|
||||||
|
copyTimestamps = true,
|
||||||
|
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
|
||||||
|
context = EncodingContext.STREAMING,
|
||||||
|
segmentContainer = "ts",
|
||||||
|
transcodeReasons = "ContainerBitrateExceedsLimit",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
|
||||||
|
itemId,
|
||||||
|
static = false,
|
||||||
|
deviceId = deviceId,
|
||||||
|
mediaSourceId = mediaSourceId,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
enableAdaptiveBitrateStreaming = true,
|
||||||
|
videoCodec = "hevc",
|
||||||
|
audioCodec = "aac,ac3,eac3",
|
||||||
|
startTimeTicks = 0,
|
||||||
|
copyTimestamps = true,
|
||||||
|
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
|
||||||
|
context = EncodingContext.STREAMING,
|
||||||
|
segmentContainer = "ts",
|
||||||
|
transcodeReasons = "ContainerBitrateExceedsLimit",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override suspend fun getDeviceId(): String {
|
||||||
|
val devices = jellyfinApi.devicesApi.getDevices(getUserId())
|
||||||
|
return devices.content.items?.firstOrNull()?.id!!
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopEncodingProcess(playSessionId: String) {
|
||||||
|
val deviceId = getDeviceId()
|
||||||
|
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
|
||||||
|
deviceId = deviceId,
|
||||||
|
playSessionId = playSessionId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -23,9 +23,13 @@ import dev.jdtech.jellyfin.models.toIntro
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.Response
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
|
@ -173,7 +177,7 @@ class JellyfinRepositoryOfflineImpl(
|
||||||
database.getSources(itemId).map { it.toFindroidSource(database) }
|
database.getSources(itemId).map { it.toFindroidSource(database) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String {
|
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String?): String {
|
||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -285,4 +289,54 @@ class JellyfinRepositoryOfflineImpl(
|
||||||
override fun getUserId(): UUID {
|
override fun getUserId(): UUID {
|
||||||
return jellyfinApi.userId!!
|
return jellyfinApi.userId!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getDeviceId(): String {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun buildDeviceProfile(
|
||||||
|
maxBitrate: Int,
|
||||||
|
container: String,
|
||||||
|
context: EncodingContext
|
||||||
|
): DeviceProfile {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getVideoStreambyContainerUrl(
|
||||||
|
itemId: UUID,
|
||||||
|
deviceId: String,
|
||||||
|
mediaSourceId: String,
|
||||||
|
playSessionId: String,
|
||||||
|
videoBitrate: Int,
|
||||||
|
container: String
|
||||||
|
): String {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTranscodedVideoStream(
|
||||||
|
itemId: UUID,
|
||||||
|
deviceId: String,
|
||||||
|
mediaSourceId: String,
|
||||||
|
playSessionId: String,
|
||||||
|
videoBitrate: Int
|
||||||
|
): String {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPostedPlaybackInfo(
|
||||||
|
itemId: UUID,
|
||||||
|
enableDirectStream: Boolean,
|
||||||
|
deviceProfile: DeviceProfile,
|
||||||
|
maxBitrate: Int
|
||||||
|
): Response<PlaybackInfoResponse> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopEncodingProcess(playSessionId: String) {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,10 @@ package dev.jdtech.jellyfin.viewmodels
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
@ -12,6 +14,7 @@ import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
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.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.TrackSelectionOverride
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.TrackSelectionParameters
|
import androidx.media3.common.TrackSelectionParameters
|
||||||
|
@ -20,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.AppPreferences
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||||
import dev.jdtech.jellyfin.models.Intro
|
import dev.jdtech.jellyfin.models.Intro
|
||||||
import dev.jdtech.jellyfin.models.PlayerChapter
|
import dev.jdtech.jellyfin.models.PlayerChapter
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
|
@ -38,6 +42,8 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -49,10 +55,12 @@ class PlayerActivityViewModel
|
||||||
constructor(
|
constructor(
|
||||||
private val application: Application,
|
private val application: Application,
|
||||||
private val jellyfinRepository: JellyfinRepository,
|
private val jellyfinRepository: JellyfinRepository,
|
||||||
|
private val jellyfinApi: JellyfinApi,
|
||||||
private val appPreferences: AppPreferences,
|
private val appPreferences: AppPreferences,
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel(), Player.Listener {
|
) : ViewModel(), Player.Listener {
|
||||||
val player: Player
|
val player: Player
|
||||||
|
private var originalHeight: Int = 0
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
UiState(
|
UiState(
|
||||||
|
@ -455,8 +463,141 @@ constructor(
|
||||||
super.onIsPlayingChanged(isPlaying)
|
super.onIsPlayingChanged(isPlaying)
|
||||||
eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying))
|
eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getTranscodeResolutions(preferredQuality: String): Int {
|
||||||
|
return when (preferredQuality) {
|
||||||
|
"1080p" -> 1080 // TODO: 1080p this logic is based on 1080p being original
|
||||||
|
"720p - 2Mbps" -> 720
|
||||||
|
"480p - 1Mbps" -> 480
|
||||||
|
"360p - 800kbps" -> 360
|
||||||
|
"Auto" -> 1
|
||||||
|
else -> 1080 //default to Original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeVideoQuality(quality: String) {
|
||||||
|
val mediaId = player.currentMediaItem?.mediaId ?: return
|
||||||
|
val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return
|
||||||
|
val currentPosition = player.currentPosition
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val transcodingResolution = getTranscodeResolutions(quality)
|
||||||
|
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
|
||||||
|
transcodingResolution
|
||||||
|
)
|
||||||
|
val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "mkv", EncodingContext.STREAMING)
|
||||||
|
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(currentItem.itemId,true,deviceProfile,videoBitRate)
|
||||||
|
val playSessionId = playbackInfo.content.playSessionId
|
||||||
|
if (playSessionId != null) {
|
||||||
|
jellyfinRepository.stopEncodingProcess(playSessionId)
|
||||||
|
}
|
||||||
|
val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true)
|
||||||
|
|
||||||
|
// TODO: can maybe tidy the sub stuff up
|
||||||
|
val externalSubtitles = currentItem.externalSubtitles.map { externalSubtitle ->
|
||||||
|
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
|
||||||
|
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
|
||||||
|
.setLanguage(externalSubtitle.language.ifBlank { "Unknown" })
|
||||||
|
.setMimeType(externalSubtitle.mimeType)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val embeddedSubtitles = mediaSources[currentMediaItemIndex].mediaStreams
|
||||||
|
.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal && it.path != null }
|
||||||
|
.map { mediaStream ->
|
||||||
|
val test = mediaStream.codec
|
||||||
|
Timber.d("Deliver: %s", test)
|
||||||
|
var deliveryUrl = mediaStream.path
|
||||||
|
Timber.d("Deliverurl: %s", deliveryUrl)
|
||||||
|
if (mediaStream.codec == "webvtt") {
|
||||||
|
deliveryUrl = deliveryUrl?.replace("Stream.srt", "Stream.vtt")}
|
||||||
|
MediaItem.SubtitleConfiguration.Builder(Uri.parse(deliveryUrl))
|
||||||
|
.setMimeType(
|
||||||
|
when (mediaStream.codec) {
|
||||||
|
"subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
"webvtt" -> MimeTypes.TEXT_VTT
|
||||||
|
"ssa" -> MimeTypes.TEXT_SSA
|
||||||
|
"pgs" -> MimeTypes.APPLICATION_PGS
|
||||||
|
"ass" -> MimeTypes.TEXT_SSA
|
||||||
|
"srt" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
"vtt" -> MimeTypes.TEXT_VTT
|
||||||
|
"ttml" -> MimeTypes.APPLICATION_TTML
|
||||||
|
"dfxp" -> MimeTypes.APPLICATION_TTML
|
||||||
|
"stl" -> MimeTypes.APPLICATION_TTML
|
||||||
|
"sbv" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
else -> MimeTypes.TEXT_UNKNOWN
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setLanguage(mediaStream.language.ifBlank { "Unknown" })
|
||||||
|
.setLabel("Embedded")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.toMutableList()
|
||||||
|
|
||||||
|
|
||||||
|
val allSubtitles =
|
||||||
|
if (transcodingResolution == 1080) {
|
||||||
|
externalSubtitles
|
||||||
|
}else {
|
||||||
|
embeddedSubtitles.apply { addAll(externalSubtitles) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = if (transcodingResolution == 1080){
|
||||||
|
jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId)
|
||||||
|
} else {
|
||||||
|
val mediaSourceId = mediaSources[currentMediaItemIndex].id
|
||||||
|
val deviceId = jellyfinRepository.getDeviceId()
|
||||||
|
val url = jellyfinRepository.getTranscodedVideoStream(currentItem.itemId, deviceId ,mediaSourceId, playSessionId!!, videoBitRate)
|
||||||
|
val uriBuilder = url.toUri().buildUpon()
|
||||||
|
val apiKey = jellyfinApi.api.accessToken // TODO: add in repo
|
||||||
|
uriBuilder.appendQueryParameter("api_key",apiKey )
|
||||||
|
val newUri = uriBuilder.build()
|
||||||
|
newUri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Timber.e("URI IS %s", url)
|
||||||
|
val mediaItemBuilder = MediaItem.Builder()
|
||||||
|
.setMediaId(currentItem.itemId.toString())
|
||||||
|
.setUri(url)
|
||||||
|
.setSubtitleConfigurations(allSubtitles)
|
||||||
|
.setMediaMetadata(
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle(currentItem.name)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
player.pause()
|
||||||
|
player.setMediaItem(mediaItemBuilder.build())
|
||||||
|
player.prepare()
|
||||||
|
player.seekTo(currentPosition)
|
||||||
|
playWhenReady = true
|
||||||
|
player.play()
|
||||||
|
|
||||||
|
val originalHeight = mediaSources[currentMediaItemIndex].mediaStreams
|
||||||
|
.filter { it.type == MediaStreamType.VIDEO }
|
||||||
|
.map {mediaStream -> mediaStream.height}.first() ?: 1080
|
||||||
|
|
||||||
|
|
||||||
|
// Store the original height
|
||||||
|
this@PlayerActivityViewModel.originalHeight = originalHeight
|
||||||
|
|
||||||
|
//isQualityChangeInProgress = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOriginalHeight(): Int {
|
||||||
|
return originalHeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
sealed interface PlayerEvents {
|
sealed interface PlayerEvents {
|
||||||
data object NavigateBack : PlayerEvents
|
data object NavigateBack : PlayerEvents
|
||||||
data class IsPlayingChanged(val isPlaying: Boolean) : PlayerEvents
|
data class IsPlayingChanged(val isPlaying: Boolean) : PlayerEvents
|
||||||
|
|
|
@ -136,7 +136,28 @@ class PlayerViewModel @Inject internal constructor(
|
||||||
} else {
|
} else {
|
||||||
mediaSources[mediaSourceIndex]
|
mediaSources[mediaSourceIndex]
|
||||||
}
|
}
|
||||||
val externalSubtitles = mediaSource.mediaStreams
|
// Embedded Sub externally for offline prep next commit
|
||||||
|
val externalSubtitles = if (mediaSource.type.toString() == "LOCAL" ) {
|
||||||
|
mediaSource.mediaStreams
|
||||||
|
.filter { mediaStream ->
|
||||||
|
mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
|
||||||
|
}
|
||||||
|
.map { mediaStream ->
|
||||||
|
ExternalSubtitle(
|
||||||
|
mediaStream.title,
|
||||||
|
mediaStream.language,
|
||||||
|
Uri.parse(mediaStream.path!!),
|
||||||
|
when (mediaStream.codec) {
|
||||||
|
"subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
"webvtt" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
"pgs" -> MimeTypes.APPLICATION_PGS
|
||||||
|
"ass" -> MimeTypes.TEXT_SSA
|
||||||
|
else -> MimeTypes.TEXT_UNKNOWN
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}else {
|
||||||
|
mediaSource.mediaStreams
|
||||||
.filter { mediaStream ->
|
.filter { mediaStream ->
|
||||||
mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
|
mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
|
||||||
}
|
}
|
||||||
|
@ -148,11 +169,13 @@ class PlayerViewModel @Inject internal constructor(
|
||||||
when (mediaStream.codec) {
|
when (mediaStream.codec) {
|
||||||
"subrip" -> MimeTypes.APPLICATION_SUBRIP
|
"subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
"webvtt" -> MimeTypes.APPLICATION_SUBRIP
|
"webvtt" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
"pgs" -> MimeTypes.APPLICATION_PGS
|
||||||
"ass" -> MimeTypes.TEXT_SSA
|
"ass" -> MimeTypes.TEXT_SSA
|
||||||
else -> MimeTypes.TEXT_UNKNOWN
|
else -> MimeTypes.TEXT_UNKNOWN
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
val trickplayInfo = when (this) {
|
val trickplayInfo = when (this) {
|
||||||
is FindroidSources -> {
|
is FindroidSources -> {
|
||||||
this.trickplayInfo?.get(mediaSource.id)?.let {
|
this.trickplayInfo?.get(mediaSource.id)?.let {
|
||||||
|
|
Loading…
Reference in a new issue