feat: select transcoding codec in network settings / code: clean up, refactors & rework alot of transcoding stuff / bugfixes: mainly deviceId

This commit is contained in:
nomadics9 2024-07-21 04:12:36 +03:00
commit b5d31a6c72
20 changed files with 387 additions and 374 deletions

View file

@ -47,6 +47,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import com.nomadics9.ananas.core.R as CoreR
import com.nomadics9.ananas.models.VideoQuality
var isControlsLocked: Boolean = false
@ -86,12 +87,10 @@ class PlayerActivity : BasePlayerActivity() {
binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
val changeQualityButton: ImageButton = findViewById(R.id.btnChangeQuality)
changeQualityButton.setOnClickListener {
showQualitySelectionDialog()
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding.playerView.player = viewModel.player
@ -356,12 +355,11 @@ class PlayerActivity : BasePlayerActivity() {
if (appPreferences.playerTrickplay) {
val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview)
previewScrubListener =
PreviewScrubListener(
imagePreview,
timeBar,
viewModel.player,
)
previewScrubListener = PreviewScrubListener(
imagePreview,
timeBar,
viewModel.player,
)
timeBar.addListener(previewScrubListener!!)
}
@ -439,50 +437,32 @@ class PlayerActivity : BasePlayerActivity() {
try {
enterPictureInPictureMode(pipParams())
} catch (_: IllegalArgumentException) {
}
} catch (_: IllegalArgumentException) { }
}
private var selectedIndex = 1 // Default to "Original" (index 1)
private fun showQualitySelectionDialog() {
val height = viewModel.getOriginalHeight()
val originalResolution = viewModel.getOriginalResolution() ?: 0
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries).toList()
val qualityValues = resources.getStringArray(CoreR.array.quality_values).toList()
// Map entries to values
val qualityMap = qualityEntries.zip(qualityValues).toMap()
val qualities: List<String> =
when (height) {
0 -> qualityEntries
in 1001..1999 ->
listOf(
qualityEntries[0],
"${qualityEntries[1]} (1080p)",
qualityEntries[2],
qualityEntries[3],
qualityEntries[4],
qualityEntries[5],
)
in 2000..3000 ->
listOf(
qualityEntries[0],
"${qualityEntries[1]} (4K)",
qualityEntries[2],
qualityEntries[3],
qualityEntries[4],
qualityEntries[5],
)
else -> qualityEntries
}
val qualities = qualityEntries.toMutableList()
val closestQuality = VideoQuality.entries
.filter { it != VideoQuality.Auto && it != VideoQuality.Original }
.minByOrNull { kotlin.math.abs(it.height*it.width - originalResolution) }
if (closestQuality != null) {
qualities[1] = "${qualities[1]} (${closestQuality})"
}
MaterialAlertDialogBuilder(this)
.setTitle("Select Video Quality")
.setItems(qualities.toTypedArray()) { _, which ->
val selectedQualityEntry = qualities[which]
val selectedQualityValue =
qualityMap.entries.find { it.key.contains(selectedQualityEntry.split(" ")[0]) }?.value ?: selectedQualityEntry
.setTitle(CoreR.string.select_quality)
.setSingleChoiceItems(qualities.toTypedArray(), selectedIndex) { dialog, which ->
selectedIndex = which
val selectedQualityValue = qualityValues[which]
viewModel.changeVideoQuality(selectedQualityValue)
}.show()
dialog.dismiss()
}
.show()
}
override fun onPictureInPictureModeChanged(

View file

@ -172,11 +172,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog()
} else {
download()
startDownload()
}
}
private fun download(){
private fun startDownload(){
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true
@ -413,8 +413,8 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}
private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
val qualityEntries = resources.getStringArray(CoreR.array.download_quality_entries)
val qualityValues = resources.getStringArray(CoreR.array.download_quality_values)
val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality)
var selectedQuality = quality
@ -428,7 +428,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality
dialog.dismiss()
download()
startDownload()
}
builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()

View file

@ -209,11 +209,11 @@ class MovieFragment : Fragment() {
} else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog()
} else {
download()
startDownload()
}
}
private fun download() {
private fun startDownload() {
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true
@ -506,8 +506,8 @@ class MovieFragment : Fragment() {
}
private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries)
val qualityValues = resources.getStringArray(CoreR.array.quality_values)
val qualityEntries = resources.getStringArray(CoreR.array.download_quality_entries)
val qualityValues = resources.getStringArray(CoreR.array.download_quality_values)
val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality)
var selectedQuality = quality
@ -520,7 +520,7 @@ class MovieFragment : Fragment() {
}
builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality
download()
startDownload()
dialog.dismiss()
}
builder.setNegativeButton("Cancel") { dialog, _ ->

View file

@ -73,6 +73,7 @@
android:layout_height="0dp"
android:layout_weight="1" />
<!--TODO: Content Desc to Strings-->
<ImageButton
android:id="@+id/btnChangeQuality"
android:layout_width="wrap_content"
@ -80,7 +81,7 @@
android:background="@drawable/transparent_circle_background"
android:contentDescription="Quality"
android:padding="16dp"
android:src="@drawable/ic_quality"
android:src="@drawable/ic_monitor_play"
android:layout_gravity="end"
app:tint="@android:color/white"
/>

View file

@ -79,15 +79,8 @@ class DownloaderImpl(
),
)
}
val qualityPreference = appPreferences.downloadQuality!!
Timber.d("Quality preference: $qualityPreference")
return if (qualityPreference != "Original") {
Timber.d("Handling Transcoding download for item: ${item.id}")
handleTranscodeDownload(item, source, storageIndex, trickplayInfo, segments, path, qualityPreference)
} else {
Timber.d("Handling original download for item: ${item.id}")
downloadOriginalItem(item, source, storageIndex, trickplayInfo, segments, path)
}
handleDownload(item, source, storageIndex, trickplayInfo, segments, path)
return Pair(-1, null)
} catch (e: Exception) {
try {
val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId }
@ -108,79 +101,7 @@ class DownloaderImpl(
}
}
private suspend fun handleTranscodeDownload(
item: FindroidItem,
source: FindroidSource,
storageIndex: Int,
trickplayInfo: FindroidTrickplayInfo?,
segments: List<FindroidSegment>?,
path: Uri,
quality: String,
): Pair<Long, UiText?> {
val transcodingUrl = getTranscodedUrl(item.id, quality)
when (item) {
is FindroidMovie -> {
database.insertMovie(item.toFindroidMovieDto(appPreferences.currentServer!!))
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex)
downloadEmbeddedMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, source.id, trickplayInfo)
}
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id))
}
val request =
DownloadManager
.Request(transcodingUrl)
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
is FindroidEpisode -> {
database.insertShow(
jellyfinRepository
.getShow(item.seriesId)
.toFindroidShowDto(appPreferences.currentServer!!),
)
database.insertSeason(
jellyfinRepository.getSeason(item.seasonId).toFindroidSeasonDto(),
)
database.insertEpisode(item.toFindroidEpisodeDto(appPreferences.currentServer!!))
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex)
downloadEmbeddedMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, source.id, trickplayInfo)
}
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id))
}
val request =
DownloadManager
.Request(transcodingUrl)
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
}
return Pair(-1, null)
}
private suspend fun downloadOriginalItem(
private suspend fun handleDownload(
item: FindroidItem,
source: FindroidSource,
storageIndex: Int,
@ -200,17 +121,34 @@ class DownloaderImpl(
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id))
}
val request =
DownloadManager
.Request(source.path.toUri())
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
if (appPreferences.downloadQuality != VideoQuality.Original.toString()) {
downloadEmbeddedMediaStreams(item, source, storageIndex)
val transcodingUrl =
getTranscodedUrl(item.id, appPreferences.downloadQuality!!)
val request =
DownloadManager
.Request(transcodingUrl)
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
} else {
val request =
DownloadManager
.Request(source.path.toUri())
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
}
is FindroidEpisode -> {
@ -232,17 +170,34 @@ class DownloaderImpl(
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id))
}
val request =
DownloadManager
.Request(source.path.toUri())
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
if (appPreferences.downloadQuality != VideoQuality.Original.toString()) {
downloadEmbeddedMediaStreams(item, source, storageIndex)
val transcodingUrl =
getTranscodedUrl(item.id, appPreferences.downloadQuality!!)
val request =
DownloadManager
.Request(transcodingUrl)
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
} else {
val request =
DownloadManager
.Request(source.path.toUri())
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
}
}
return Pair(-1, null)
@ -483,8 +438,8 @@ class DownloaderImpl(
mediaSourceId,
playSessionId,
VideoQuality.getBitrate(videoQuality),
VideoQuality.getQualityInt(videoQuality),
"mkv",
VideoQuality.getHeight(videoQuality),
)
return downloadUrl.toUri()

View 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>

View file

@ -1,2 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="add_server_error_outdated">اصدار الخادم قديم: %1$s. الرجاء تحديث الخادم</string>
<string name="add_server_error_not_jellyfin">ليس خادم جيلي فن:%1$s</string>
<string name="add_server_error_version">اصدار الخادم غير مدعوم: %1$s. الرجاء تحديث الخادم</string>
<string name="add_server_error_slow">رد الخادم بطيء: %1$s</string>
<string name="login">تسجيل دخول</string>
<string name="login_error_wrong_username_password">اسم المستخدم او الكلمه السريه غير صحيحه</string>
<string name="select_server">اختر الخادم</string>
<string name="edit_text_server_address_hint">عنوان الخادم</string>
<string name="edit_text_username_hint">اسم المستخدم</string>
<string name="edit_text_password_hint">الكلمه السريه</string>
<string name="button_connect">اتصل</string>
<string name="button_login">تسجيل دخول</string>
<string name="remove_server">حذف الخادم</string>
<string name="add_server">اضافه خادم</string>
<string name="add_server_error_not_found">الخادم غير موجود</string>
<string name="add_server_error_empty_address">عنوان الخادم فاضي</string>
<string name="add_server_error_no_id">الخادم غير معرف بالid , يبدو انه هناك خلل في الخادم</string>
</resources>

View file

@ -26,12 +26,12 @@
<item>opensles</item>
</string-array>
<string-array name="quality_entries">
<item>Auto</item>
<item>Original</item>
<item>1080p - 8Mbps</item>
<item>720p - 2Mbps</item>
<item>480p - 1Mbps</item>
<item>360p - 800Kbps</item>
<item>@string/quality_auto</item>
<item>@string/quality_original</item>
<item>@string/quality_1080p</item>
<item>@string/quality_720p</item>
<item>@string/quality_480p</item>
<item>@string/quality_360p</item>
</string-array>
<string-array name="quality_values">
<item>Auto</item>
@ -41,4 +41,22 @@
<item>480p</item>
<item>360p</item>
</string-array>
<string-array name="download_quality_entries">
<item>@string/quality_original</item>
<item>@string/quality_1080p</item>
<item>@string/quality_720p</item>
<item>@string/quality_480p</item>
<item>@string/quality_360p</item>
</string-array>
<string-array name="download_quality_values">
<item>Original</item>
<item>1080p</item>
<item>720p</item>
<item>480p</item>
<item>360p</item>
</string-array>
<string-array name="codecs">
<item>h264</item>
<item>hevc</item>
</string-array>
</resources>

View file

@ -139,6 +139,7 @@
<string name="settings_request_timeout">Request timeout (ms)</string>
<string name="settings_connect_timeout">Connect timeout (ms)</string>
<string name="settings_socket_timeout">Socket timeout (ms)</string>
<string name="settings_quality_codec">Transcoding codec</string>
<string name="users">Users</string>
<string name="add_user">Add user</string>
<string name="pref_player_mpv_hwdec">Hardware decoding</string>
@ -193,6 +194,15 @@
<string name="unmark_as_played">Unmark as played</string>
<string name="add_to_favorites">Add to favorites</string>
<string name="remove_from_favorites">Remove from favorites</string>
<string name="quality_default">Default to selected download quality</string>
<string name="download_quality">Download Quality</string>
<string name="select_quality">Select Video Quality</string>
<string name="quality_auto">Auto</string>
<string name="quality_original">Original</string>
<string name="quality_1080p">1080p - 8Mbps</string>
<string name="quality_720p">720p - 3Mbps</string>
<string name="quality_480p">480p - 1.5Mbps</string>
<string name="quality_360p">360p - 0.8Mbps</string>
<string name="alaskarTV_requests">AlaskarTV Requests</string>
<string name="pref_player_trickplay_gesture">Trick Play in seek gesture</string>
<string name="pref_player_trickplay_gesture_summary">Requires \'Seek gesture\' and \'Trick Play\'</string>

View file

@ -11,15 +11,13 @@
app:title="@string/download_roaming" />
<ListPreference
android:key="pref_downloads_quality"
android:title="Download Quality"
android:defaultValue="Original"
android:entries="@array/quality_entries"
android:entryValues="@array/quality_values"
android:title="@string/download_quality"
android:defaultValue="@string/quality_original"
android:entries="@array/download_quality_entries"
android:entryValues="@array/download_quality_values"
android:summary="%s" />
<SwitchPreferenceCompat
android:defaultValue="false"
app:key="pref_downloads_quality_default"
app:summary="Default to picked Download Quality" />
app:summary="@string/quality_default" />
</PreferenceScreen>

View file

@ -20,4 +20,11 @@
android:defaultValue="true"
app:key="pref_auto_offline"
app:title="@string/turn_on_offline_mode_automatically" />
<ListPreference
app:defaultValue="hevc"
app:key="pref_network_codec"
app:title="@string/settings_quality_codec"
app:useSimpleSummaryProvider="true"
app:entries="@array/codecs"
app:entryValues="@array/codecs" />
</PreferenceScreen>

View file

@ -2,24 +2,30 @@ package com.nomadics9.ananas.models
enum class VideoQuality(
val bitrate: Int,
val qualityString: String,
val qualityInt: Int,
val height: Int,
val width: Int,
val isOriginalQuality: Boolean,
) {
PAuto(1, "Auto", 1080),
POriginal(1000000000, "Original", 1080),
P1080(8000000, "1080p", 1080),
P720(2000000, "720p", 720),
P480(1000000, "480p", 480),
P360(700000, "360p", 360),
;
Auto(10000000, 1080, 1920, false),
Original(1000000000, 1080, 1920, true),
P3840(12000000,3840, 2160, false), // Here for future proofing and to calculate original resolution only
P1080(8000000, 1080, 1920, false),
P720(3000000, 720, 1280, false),
P480(1500000, 480, 854, false),
P360(800000, 360, 640, false);
override fun toString(): String = when (this) {
Auto -> "Auto"
Original -> "Original"
P3840 -> "4K"
else -> "${height}p"
}
companion object {
fun fromString(quality: String): VideoQuality? = entries.find { it.qualityString == quality }
fun fromString(quality: String): VideoQuality? = entries.find { it.toString() == quality }
fun getBitrate(quality: VideoQuality): Int = quality.bitrate
fun getQualityString(quality: VideoQuality): String = quality.qualityString
fun getQualityInt(quality: VideoQuality): Int = quality.qualityInt
fun getHeight(quality: VideoQuality): Int = quality.height
fun getWidth(quality: VideoQuality): Int = quality.width
fun getIsOriginalQuality(quality: VideoQuality): Boolean = quality.isOriginalQuality
}
}
}

View file

@ -88,40 +88,21 @@ interface JellyfinRepository {
offline: Boolean = false,
): List<FindroidEpisode>
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,
playSessionId: String? = null,
): String
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String? = null): String
suspend fun getSegmentsTimestamps(itemId: UUID): List<FindroidSegment>?
suspend fun getTrickplayData(
itemId: UUID,
width: Int,
index: Int,
): ByteArray?
suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray?
suspend fun postCapabilities()
suspend fun postPlaybackStart(itemId: UUID)
suspend fun postPlaybackStop(
itemId: UUID,
positionTicks: Long,
playedPercentage: Int,
)
suspend fun postPlaybackStop(itemId: UUID, positionTicks: Long, playedPercentage: Int)
suspend fun postPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
)
suspend fun postPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean)
suspend fun markAsFavorite(itemId: UUID)
@ -155,8 +136,8 @@ interface JellyfinRepository {
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
maxHeight: Int,
container: String,
maxHeight: Int,
): String
suspend fun getTranscodedVideoStream(
@ -175,4 +156,6 @@ interface JellyfinRepository {
): Response<PlaybackInfoResponse>
suspend fun stopEncodingProcess(playSessionId: String)
suspend fun getAccessToken(): String?
}

View file

@ -384,7 +384,7 @@ class JellyfinRepositoryImpl(
playSessionId: String?,
): String =
withContext(Dispatchers.IO) {
// val deviceId = getDeviceId()
val deviceId = getDeviceId()
try {
val url =
if (playSessionId != null) {
@ -393,7 +393,7 @@ class JellyfinRepositoryImpl(
static = true,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
// deviceId = deviceId,
deviceId = deviceId,
context = EncodingContext.STREAMING,
)
} else {
@ -401,7 +401,7 @@ class JellyfinRepositoryImpl(
itemId,
static = true,
mediaSourceId = mediaSourceId,
// deviceId = deviceId,
deviceId = deviceId,
)
}
url
@ -752,8 +752,8 @@ class JellyfinRepositoryImpl(
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
maxHeight: Int,
container: String,
maxHeight: Int,
): String {
val url =
jellyfinApi.videosApi.getVideoStreamByContainerUrl(
@ -764,9 +764,9 @@ class JellyfinRepositoryImpl(
playSessionId = playSessionId,
videoBitRate = videoBitrate,
maxHeight = maxHeight,
audioBitRate = 128000,
videoCodec = "hevc",
audioCodec = "aac",
audioBitRate = 328000,
videoCodec = appPreferences.transcodeCodec,
audioCodec = "aac,ac3,eac3",
container = container,
startTimeTicks = 0,
copyTimestamps = true,
@ -782,7 +782,7 @@ class JellyfinRepositoryImpl(
playSessionId: String,
videoBitrate: Int,
): String {
val isAuto = videoBitrate == VideoQuality.getBitrate(VideoQuality.PAuto)
val isAuto = videoBitrate == VideoQuality.getBitrate(VideoQuality.Auto)
val url: String
try {
url =
@ -795,9 +795,9 @@ class JellyfinRepositoryImpl(
playSessionId = playSessionId,
videoBitRate = videoBitrate,
enableAdaptiveBitrateStreaming = false,
audioBitRate = 128000,
videoCodec = "hevc",
audioCodec = "aac",
audioBitRate = 328000,
videoCodec = appPreferences.transcodeCodec,
audioCodec = "aac,ac3,eac3",
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
@ -813,8 +813,8 @@ class JellyfinRepositoryImpl(
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
enableAdaptiveBitrateStreaming = true,
videoCodec = "hevc",
audioCodec = "aac",
videoCodec = appPreferences.transcodeCodec,
audioCodec = "aac,ac3,eac3",
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
@ -839,4 +839,8 @@ class JellyfinRepositoryImpl(
playSessionId = playSessionId,
)
}
override suspend fun getAccessToken(): String? {
return jellyfinApi.api.accessToken
}
}

View file

@ -336,8 +336,8 @@ class JellyfinRepositoryOfflineImpl(
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
maxHeight: Int,
container: String,
maxHeight: Int,
): String {
TODO("Not yet implemented")
}
@ -364,4 +364,8 @@ class JellyfinRepositoryOfflineImpl(
override suspend fun stopEncodingProcess(playSessionId: String) {
TODO("Not yet implemented")
}
override suspend fun getAccessToken(): String? {
TODO("Not yet implemented")
}
}

View file

@ -0,0 +1,20 @@
package com.nomadics9.ananas
import androidx.media3.common.MimeTypes
public fun setSubtitlesMimeTypes(codec: String): String {
return when (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
}
}

View file

@ -31,6 +31,7 @@ import com.nomadics9.ananas.models.VideoQuality
import com.nomadics9.ananas.mpv.MPVPlayer
import com.nomadics9.ananas.player.video.R
import com.nomadics9.ananas.repository.JellyfinRepository
import com.nomadics9.ananas.setSubtitlesMimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@ -57,12 +58,11 @@ class PlayerActivityViewModel
private val application: Application,
private val jellyfinRepository: JellyfinRepository,
private val appPreferences: AppPreferences,
private val jellyfinApi: JellyfinApi,
private val savedStateHandle: SavedStateHandle,
) : ViewModel(),
Player.Listener {
val player: Player
private var originalHeight: Int = 0
private var originalResolution: Int? = null
private val _uiState =
MutableStateFlow(
@ -191,6 +191,21 @@ class PlayerActivityViewModel
).setSubtitleConfigurations(mediaSubtitles)
.build()
mediaItems.add(mediaItem)
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
val videoSize = player.videoSize
val initialHeight = videoSize.height
val initialWidth = videoSize.width
originalResolution = initialHeight * initialWidth
Timber.d("Initial video size: $initialWidth x $initialHeight")
player.removeListener(this)
}
}
})
}
} catch (e: Exception) {
Timber.e(e)
@ -532,143 +547,105 @@ class PlayerActivityViewModel
eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying))
}
fun changeVideoQuality(quality: String) {
val mediaId = player.currentMediaItem?.mediaId ?: return
val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return
val currentPosition = player.currentPosition
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 {
viewModelScope.launch {
try {
val videoQuality = VideoQuality.fromString(quality)!!
try {
val deviceProfile =
jellyfinRepository.buildDeviceProfile(
VideoQuality.getBitrate(videoQuality),
"ts",
EncodingContext.STREAMING,
)
val playbackInfo =
jellyfinRepository.getPostedPlaybackInfo(
currentItem.itemId,
true,
deviceProfile,
VideoQuality.getBitrate(videoQuality),
)
val playSessionId = playbackInfo.content.playSessionId
if (playSessionId != null) {
jellyfinRepository.stopEncodingProcess(playSessionId)
}
val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true)
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 // ASS is a subtitle format that is essentially an extension of SSA
"srt" -> MimeTypes.APPLICATION_SUBRIP // SRT is another common name for SubRip
"vtt" -> MimeTypes.TEXT_VTT // VTT is a common extension for WebVTT
"ttml" -> MimeTypes.APPLICATION_TTML // TTML (Timed Text Markup Language)
"dfxp" -> MimeTypes.APPLICATION_TTML // DFXP is a profile of TTML
"stl" -> MimeTypes.APPLICATION_TTML // EBU STL (Subtitling Data Exchange Format)
"sbv" -> MimeTypes.APPLICATION_SUBRIP // YouTube's SBV format is similar to SubRip
else -> MimeTypes.TEXT_UNKNOWN
},
).setLanguage(mediaStream.language.ifBlank { "Unknown" })
.setLabel("Embedded")
.build()
}.toMutableList()
val allSubtitles = embeddedSubtitles.apply { addAll(externalSubtitles) }
val url =
if (VideoQuality.getQualityString(videoQuality) == "Original") {
jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId)
} else {
val mediaSourceId = mediaSources[currentMediaItemIndex].id
val deviceId = jellyfinApi.api.deviceInfo.id
Timber.d("deviceid = %s", deviceId)
val url =
jellyfinRepository.getTranscodedVideoStream(
currentItem.itemId,
deviceId,
mediaSourceId,
playSessionId!!,
VideoQuality.getBitrate(videoQuality),
)
val uriBuilder = url.toUri().buildUpon()
val apiKey = jellyfinApi.api.accessToken
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)
val deviceProfile = jellyfinRepository.buildDeviceProfile(VideoQuality.getBitrate(videoQuality), "mkv", EncodingContext.STREAMING)
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(currentItem.itemId,true,deviceProfile,VideoQuality.getBitrate(videoQuality))
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 ->
var deliveryUrl = mediaStream.path
Timber.d("Deliverurl: %s", deliveryUrl)
// Not sure if still needed
if (mediaStream.codec == "webvtt") {
deliveryUrl = deliveryUrl?.replace("Stream.srt", "Stream.vtt")}
MediaItem.SubtitleConfiguration.Builder(Uri.parse(deliveryUrl))
.setMimeType(setSubtitlesMimeTypes(mediaStream.codec))
.setLanguage(mediaStream.language.ifBlank { "Unknown" })
.setLabel("Embedded")
.build()
}
.toMutableList()
val allSubtitles =
if (VideoQuality.getIsOriginalQuality(videoQuality)) {
externalSubtitles
}else {
embeddedSubtitles.apply { addAll(externalSubtitles) }
}
val url = if (VideoQuality.getIsOriginalQuality(videoQuality)){
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!!, VideoQuality.getBitrate(videoQuality))
val uriBuilder = url.toUri().buildUpon()
val apiKey = jellyfinRepository.getAccessToken()
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()
//isQualityChangeInProgress = true
} catch (e: Exception) {
Timber.e(e)
}
}
fun getOriginalHeight(): Int = originalHeight
}
fun getOriginalResolution(): Int? {
return originalResolution
}
}
sealed interface PlayerEvents {
data object NavigateBack : PlayerEvents

View file

@ -18,6 +18,7 @@ import com.nomadics9.ananas.models.PlayerChapter
import com.nomadics9.ananas.models.PlayerItem
import com.nomadics9.ananas.models.TrickplayInfo
import com.nomadics9.ananas.repository.JellyfinRepository
import com.nomadics9.ananas.setSubtitlesMimeTypes
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@ -136,6 +137,7 @@ class PlayerViewModel @Inject internal constructor(
} else {
mediaSources[mediaSourceIndex]
}
// Embedded Sub externally for offline playback
val externalSubtitles = if (mediaSource.type.toString() == "LOCAL" ) {
mediaSource.mediaStreams
.filter { mediaStream ->
@ -146,13 +148,7 @@ class PlayerViewModel @Inject internal constructor(
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
},
setSubtitlesMimeTypes(mediaStream.codec),
)
}
}else {
@ -165,13 +161,7 @@ class PlayerViewModel @Inject internal constructor(
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
},
setSubtitlesMimeTypes(mediaStream.codec)
)
}
}

View file

@ -121,6 +121,11 @@ constructor(
Constants.NETWORK_DEFAULT_SOCKET_TIMEOUT.toString(),
)!!.toLongOrNull() ?: Constants.NETWORK_DEFAULT_SOCKET_TIMEOUT
val transcodeCodec get() = sharedPreferences.getString(
Constants.PREF_NETWORK_CODEC,
Constants.NETWORK_DEFAULT_CODEC,
)
// Cache
val imageCache get() = sharedPreferences.getBoolean(
Constants.PREF_IMAGE_CACHE,

View file

@ -43,6 +43,7 @@ object Constants {
const val PREF_NETWORK_REQUEST_TIMEOUT = "pref_network_request_timeout"
const val PREF_NETWORK_CONNECT_TIMEOUT = "pref_network_connect_timeout"
const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout"
const val PREF_NETWORK_CODEC = "pref_network_codec"
const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data"
const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming"
const val PREF_DOWNLOADS_QUALITY = "pref_downloads_quality"
@ -63,6 +64,7 @@ object Constants {
const val NETWORK_DEFAULT_REQUEST_TIMEOUT = 30_000L
const val NETWORK_DEFAULT_CONNECT_TIMEOUT = 6_000L
const val NETWORK_DEFAULT_SOCKET_TIMEOUT = 10_000L
const val NETWORK_DEFAULT_CODEC = "h264"
// sorting
// This values must correspond to a SortString from [SortBy]