This commit is contained in:
Damontecres 2025-10-15 15:27:30 -04:00
parent aabd8462dc
commit d39ed721d5
No known key found for this signature in database
138 changed files with 738 additions and 738 deletions

View file

@ -0,0 +1,294 @@
package com.github.damontecres.wholphin.util
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.function.Predicate
/**
* Handles paging for an API request. You must call [init] prior to use.
*
* Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope].
* Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched.
*
* Finally, items are cached allow for backward and forward scrolling.
*/
class ApiRequestPager<T>(
val api: ApiClient,
val request: T,
val requestHandler: RequestHandler<T>,
private val scope: CoroutineScope,
private val pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
private val useSeriesForPrimary: Boolean = false,
) : AbstractList<BaseItem?>(),
BlockingList<BaseItem?> {
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
private val mutex = Mutex()
private val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, List<BaseItem>>()
suspend fun init(): ApiRequestPager<T> {
if (totalCount < 0) {
val newRequest = requestHandler.prepare(request, 0, 1, true)
val result = requestHandler.execute(api, newRequest).content
totalCount = result.totalRecordCount
}
return this
}
override operator fun get(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun getBlocking(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index).join()
return items[index]
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): Int {
init()
for (i in 0 until totalCount) {
val currentItem = getBlocking(i)
if (currentItem != null && predicate.test(currentItem)) {
return i
}
}
return -1
}
override val size: Int
get() = totalCount
private fun fetchPage(position: Int): Job =
scope.launch(ExceptionHandler() + Dispatchers.IO) {
mutex.withLock {
val pageNumber = position / pageSize
if (cachedPages.getIfPresent(pageNumber) == null) {
if (DEBUG) Timber.v("fetchPage: $pageNumber")
val newRequest =
requestHandler.prepare(
request,
pageNumber * pageSize,
pageSize,
false,
)
val result = requestHandler.execute(api, newRequest).content
val data = result.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
cachedPages.put(pageNumber, data)
items = ItemList(totalCount, pageSize, cachedPages.asMap())
}
}
}
companion object {
private const val DEBUG = false
}
class ItemList<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
operator fun get(position: Int): T? {
val page = position / pageSize
val data = pages[page]
if (data != null) {
val index = position % pageSize
if (index in data.indices) {
return data[index]
} else {
// This can happen when items are removed while scrolling
Timber.w(
"Index $index not in data: position=$position, data.size=${data.size}",
)
return null
}
} else {
return null
}
}
}
}
/**
* Specifies how the [ApiRequestPager] should prepare and execute API calls
*/
interface RequestHandler<T> {
/**
* Prepare the given request with the specified parameters (eg which page to fetch)
*/
fun prepare(
request: T,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): T
/**
* Execute the given request
*/
suspend fun execute(
api: ApiClient,
request: T,
): Response<BaseItemDtoQueryResult>
}
val GetItemsRequestHandler =
object : RequestHandler<GetItemsRequest> {
override fun prepare(
request: GetItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
}
val GetEpisodesRequestHandler =
object : RequestHandler<GetEpisodesRequest> {
override fun prepare(
request: GetEpisodesRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetEpisodesRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetEpisodesRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
}
val GetResumeItemsRequestHandler =
object : RequestHandler<GetResumeItemsRequest> {
override fun prepare(
request: GetResumeItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetResumeItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetResumeItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
}
val GetNextUpRequestHandler =
object : RequestHandler<GetNextUpRequest> {
override fun prepare(
request: GetNextUpRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetNextUpRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetNextUpRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
}
val GetSuggestionsRequestHandler =
object : RequestHandler<GetSuggestionsRequest> {
override fun prepare(
request: GetSuggestionsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetSuggestionsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
}
val GetPlaylistItemsRequestHandler =
object : RequestHandler<GetPlaylistItemsRequest> {
override fun prepare(
request: GetPlaylistItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPlaylistItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetPlaylistItemsRequest,
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}

View file

@ -0,0 +1,9 @@
package com.github.damontecres.wholphin.util
import java.util.function.Predicate
interface BlockingList<T> : List<T> {
suspend fun getBlocking(index: Int): T
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
}

View file

@ -0,0 +1,19 @@
package com.github.damontecres.wholphin.util
import androidx.media3.common.MimeTypes
import com.github.damontecres.wholphin.util.profile.Codec
val subtitleMimeTypes =
mapOf(
Codec.Subtitle.ASS to MimeTypes.TEXT_SSA,
Codec.Subtitle.DVBSUB to MimeTypes.APPLICATION_VOBSUB,
Codec.Subtitle.DVBSUB to MimeTypes.APPLICATION_VOBSUB,
Codec.Subtitle.PGS to MimeTypes.APPLICATION_PGS,
Codec.Subtitle.PGSSUB to MimeTypes.APPLICATION_PGS,
Codec.Subtitle.SRT to MimeTypes.APPLICATION_SUBRIP,
Codec.Subtitle.SSA to MimeTypes.TEXT_SSA,
Codec.Subtitle.SUBRIP to MimeTypes.APPLICATION_SUBRIP,
Codec.Subtitle.TTML to MimeTypes.APPLICATION_TTML,
Codec.Subtitle.VTT to MimeTypes.TEXT_VTT,
Codec.Subtitle.WEBVTT to MimeTypes.TEXT_VTT,
)

View file

@ -0,0 +1,32 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.ui.main.HomeSection
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
val supportedHomeSection =
setOf(
HomeSection.LATEST_MEDIA,
HomeSection.NEXT_UP,
HomeSection.RESUME,
)
val supportItemKinds =
setOf(
BaseItemKind.MOVIE,
BaseItemKind.EPISODE,
BaseItemKind.SERIES,
BaseItemKind.VIDEO,
BaseItemKind.SEASON,
BaseItemKind.COLLECTION_FOLDER,
BaseItemKind.USER_VIEW,
)
val supportedCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
CollectionType.PLAYLISTS,
CollectionType.BOXSETS,
)

View file

@ -0,0 +1,27 @@
package com.github.damontecres.wholphin.util
import androidx.lifecycle.MutableLiveData
/**
* A [MutableLiveData] that only notifies observers if the value does not equal the previous value
*/
class EqualityMutableLiveData<T> : MutableLiveData<T> {
constructor() : super()
constructor(value: T) : super(value)
override fun setValue(value: T?) {
if (value != getValue()) {
super.setValue(value)
}
}
fun setValueNoCheck(value: T?) {
super.setValue(value)
}
override fun postValue(value: T?) {
if (value != getValue()) {
super.postValue(value)
}
}
}

View file

@ -0,0 +1,43 @@
package com.github.damontecres.wholphin.util
import android.widget.Toast
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
/**
* A general [CoroutineExceptionHandler] which can optionally show a [Toast] when an exception is thrown
*
* @param autoToast automatically show a toast with the exception's message
*/
class ExceptionHandler(
private val autoToast: Boolean = false,
) : CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler
override fun handleException(
context: CoroutineContext,
exception: Throwable,
) {
if (exception is CancellationException) {
// Don't log/toast cancellations
return
}
Timber.e(exception, "Exception in coroutine")
if (autoToast) {
runBlocking {
showToast(
WholphinApplication.instance,
"Error: ${exception.message}",
Toast.LENGTH_LONG,
)
}
}
}
}

View file

@ -0,0 +1,9 @@
package com.github.damontecres.wholphin.util
import androidx.compose.ui.focus.FocusRequester
data class FocusPair(
val row: Int,
val column: Int,
val focusRequester: FocusRequester,
)

View file

@ -0,0 +1,62 @@
package com.github.damontecres.wholphin.util
import android.os.Build
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
/**
* Format a [LocalDateTime] as `Aug 24, 2000`
*/
fun formatDateTime(dateTime: LocalDateTime): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) {
dateTime.toString().substring(0, 10)
} else {
dateTime.toString()
}
/**
* If the item has season & episode info, format as `S# E#`
*/
val BaseItemDto.seasonEpisode: String?
get() =
if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
"S$parentIndexNumber E$indexNumber-E$indexNumberEnd"
} else if (parentIndexNumber != null && indexNumber != null) {
"S$parentIndexNumber E$indexNumber"
} else {
null
}
/**
* If the item has season & episode info, format padded as `S## E##`
*/
val BaseItemDto.seasonEpisodePadded: String?
get() =
if (parentIndexNumber != null && indexNumber != null) {
val season = parentIndexNumber?.toString()?.padStart(2, '0')
val episode = indexNumber?.toString()?.padStart(2, '0')
val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0')
if (endEpisode != null) {
"S${season}E$episode-E$endEpisode"
} else {
"S${season}E$episode"
}
} else {
null
}
fun formatSubtitleLang(mediaStreams: List<MediaStream>?): String? =
mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() }
?.mapNotNull { it.language }
?.distinct()
?.joinToString(", ") { languageName(it) }

View file

@ -0,0 +1,52 @@
package com.github.damontecres.wholphin.util
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
/**
* A [CoroutineExceptionHandler] that is aware of a [LoadingState] and will set it to error if an exception occurs in the coroutine.
*/
class LoadingExceptionHandler(
private val loadingState: MutableLiveData<LoadingState>,
private val errorMessage: String?,
private val autoToast: Boolean = false,
) : CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler
override fun handleException(
context: CoroutineContext,
exception: Throwable,
) {
if (exception is CancellationException) {
// Don't log/toast cancellations
return
}
Timber.e(exception, "Exception in coroutine")
runBlocking {
withContext(Dispatchers.Main) {
loadingState.value =
LoadingState.Error(
message = errorMessage,
exception = exception,
)
}
if (autoToast) {
showToast(
WholphinApplication.instance,
"Error: ${exception.message}",
Toast.LENGTH_LONG,
)
}
}
}
}

View file

@ -0,0 +1,19 @@
package com.github.damontecres.wholphin.util
/**
* Generic state for loading something from the API
*/
sealed interface LoadingState {
object Pending : LoadingState
object Loading : LoadingState
object Success : LoadingState
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : LoadingState {
constructor(exception: Throwable) : this(null, exception)
}
}

View file

@ -0,0 +1,154 @@
package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Listens to playback and periodically saves playback activity to the server
*/
@OptIn(UnstableApi::class)
class TrackActivityPlaybackListener(
private val api: ApiClient,
private val player: Player,
var playback: CurrentPlayback,
) : Player.Listener {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask
private var totalPlayDurationMilliseconds = AtomicLong(0)
private var currentDurationMilliseconds = AtomicLong(0)
private var isPlaying = AtomicBoolean(false)
private var incrementedPlayCount = AtomicBoolean(false)
init {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
canSeek = true,
itemId = playback.itemId,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false,
audioStreamIndex = playback.audioIndex,
subtitleStreamIndex = playback.subtitleIndex,
),
)
}
val saveActivityInterval = 10.seconds.inWholeMilliseconds
val delay = 1.seconds.inWholeMilliseconds
// Every x seconds, check if the video is playing
task =
object : TimerTask() {
private var timestamp = System.currentTimeMillis()
override fun run() {
try {
val now = System.currentTimeMillis()
if (isPlaying.get()) {
val diffTime = now - timestamp
// If it is playing, add the interval to currently tracked duration
val current = currentDurationMilliseconds.addAndGet(diffTime)
// TODO currentDuration.getAndUpdate would be better, but requires API 24+
if (current >= saveActivityInterval) {
// If the accumulated currently tracked duration > threshold, reset it and save activity
totalPlayDurationMilliseconds.addAndGet(current)
saveActivity(-1L)
}
}
timestamp = now
} catch (ex: Exception) {
Timber.Forest.w(ex, "Exception during track activity timer")
}
}
}
TIMER.schedule(task, delay, delay)
}
fun release() {
task.cancel()
TIMER.purge()
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = playback.itemId,
positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks },
failed = false,
playSessionId = playback.playSessionId,
),
)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
this.isPlaying.set(isPlaying)
if (!isPlaying) {
val diff = currentDurationMilliseconds.getAndSet(0)
if (diff > 0) {
totalPlayDurationMilliseconds.addAndGet(diff)
saveActivity(-1)
}
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
Timber.v("onPlaybackStateChanged STATE_ENDED")
saveActivity(player.duration)
}
}
private fun saveActivity(position: Long) {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
// val totalDuration = totalPlayDurationMilliseconds.get()
val calcPosition =
withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition).milliseconds
}
// Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = playback.itemId,
positionTicks = calcPosition.inWholeTicks,
canSeek = true,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
isMuted = false,
playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
playSessionId = playback.playSessionId,
audioStreamIndex = playback.audioIndex,
subtitleStreamIndex = playback.subtitleIndex,
),
)
}
}
companion object {
private const val TAG = "TrackActivityPlaybackListener"
private val TIMER by lazy { Timer("$TAG-timer", true) }
}
}

View file

@ -0,0 +1,148 @@
package com.github.damontecres.wholphin.util
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import timber.log.Timber
import java.util.Locale
/**
* Represents a track in a media file along with information about whether the device can handle it natively or not
*/
data class TrackSupport(
val id: String?,
val type: TrackType,
val supported: TrackSupportReason,
val selected: Boolean,
val labels: List<String>,
val codecs: String?,
val format: Format,
) {
@OptIn(UnstableApi::class)
fun displayString(context: Context): String =
if (labels.isNotEmpty()) {
labels.joinToString(", ")
} else {
val type =
when (codecs) {
MimeTypes.TEXT_VTT -> "vtt"
MimeTypes.APPLICATION_VOBSUB -> "vobsub"
MimeTypes.APPLICATION_SUBRIP -> "srt"
MimeTypes.TEXT_SSA -> "ssa"
MimeTypes.APPLICATION_PGS -> "pgs"
MimeTypes.APPLICATION_DVBSUBS -> "dvd"
MimeTypes.APPLICATION_TTML -> "ttml"
MimeTypes.TEXT_UNKNOWN -> "unknown"
null -> "unknown"
else -> {
val split = codecs.split("/")
if (split.size > 1) split[1] else codecs
}
}
val language = languageName(format.language)
"$language ($type)"
}
}
enum class TrackSupportReason {
HANDLED,
EXCEEDS_CAPABILITIES,
UNSUPPORTED_DRM,
UNSUPPORTED_SUBTYPE,
UNSUPPORTED_TYPE,
UNKNOWN,
;
companion object {
@OptIn(UnstableApi::class)
fun fromInt(
@C.FormatSupport value: Int,
): TrackSupportReason =
when (value) {
C.FORMAT_HANDLED -> HANDLED
C.FORMAT_EXCEEDS_CAPABILITIES -> EXCEEDS_CAPABILITIES
C.FORMAT_UNSUPPORTED_DRM -> UNSUPPORTED_DRM
C.FORMAT_UNSUPPORTED_SUBTYPE -> UNSUPPORTED_SUBTYPE
C.FORMAT_UNSUPPORTED_TYPE -> UNSUPPORTED_TYPE
else -> UNKNOWN
}
}
}
enum class TrackType {
UNKNOWN,
DEFAULT,
AUDIO,
VIDEO,
TEXT,
IMAGE,
METADATA,
CAMERA_MOTION,
NONE,
;
companion object {
@OptIn(UnstableApi::class)
fun fromInt(value: Int): TrackType =
when (value) {
C.TRACK_TYPE_UNKNOWN -> UNKNOWN
C.TRACK_TYPE_DEFAULT -> DEFAULT
C.TRACK_TYPE_AUDIO -> AUDIO
C.TRACK_TYPE_VIDEO -> VIDEO
C.TRACK_TYPE_TEXT -> TEXT
C.TRACK_TYPE_IMAGE -> IMAGE
C.TRACK_TYPE_METADATA -> METADATA
C.TRACK_TYPE_CAMERA_MOTION -> CAMERA_MOTION
C.TRACK_TYPE_NONE -> NONE
else -> UNKNOWN
}
}
}
@OptIn(UnstableApi::class)
fun checkForSupport(tracks: Tracks): List<TrackSupport> =
tracks.groups.flatMap {
buildList {
val type = TrackType.fromInt(it.type)
for (i in 0..<it.length) {
val format = it.getTrackFormat(i)
val labels =
format.labels
.map {
if (it.language != null) {
"${it.value} (${it.language})"
} else {
it.value
}
}
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
add(
TrackSupport(
format.id,
type,
reason,
it.isSelected,
labels,
format.codecs,
format,
),
)
}
}
}
fun languageName(code: String?): String =
if (code != null) {
try {
Locale(code).displayLanguage
} catch (ex: Exception) {
Timber.w(ex, "Error in locale for '$code'")
code.uppercase()
}
} else {
"Unknown"
}

View file

@ -0,0 +1,337 @@
package com.github.damontecres.wholphin.util
import android.Manifest
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import timber.log.Timber
import java.io.File
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class UpdateChecker
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
companion object {
// TODO apk names
private const val ASSET_NAME = "Wholphin.apk"
private const val DEBUG_ASSET_NAME = "Wholphin-debug.apk"
private const val RELEASE_ASSET_NAME = "Wholphin-release.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
}
suspend fun maybeShowUpdateToast(
updateUrl: String,
showNegativeToast: Boolean = false,
) {
val pref = PreferenceManager.getDefaultSharedPreferences(context)
val now = Date()
val lastUpdateCheckThreshold =
pref
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
.hours
val lastUpdateCheck =
pref.getLong(
context.getString(R.string.pref_key_update_last_check),
0,
)
val timeSince = (now.time - lastUpdateCheck).milliseconds
Timber.v("Last successful update check was $timeSince ago")
val installedVersion = getInstalledVersion()
val latestRelease = getLatestRelease(updateUrl)
if (latestRelease != null && latestRelease.version.isGreaterThan(installedVersion)) {
Timber.v("Update available $installedVersion => ${latestRelease.version}")
pref.edit {
putLong(context.getString(R.string.pref_key_update_last_check), now.time)
}
if (lastUpdateCheckThreshold >= timeSince) {
Timber.i(
"Skipping update notification, threshold is $lastUpdateCheckThreshold",
)
} else {
showToast(
context,
"Update available: $installedVersion => ${latestRelease.version}!",
Toast.LENGTH_LONG,
)
}
} else {
Timber.v("No update available for $installedVersion")
if (showNegativeToast) {
showToast(
context,
"No updates available, $installedVersion is the latest!",
Toast.LENGTH_LONG,
)
}
}
}
fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return Version.fromString(pkgInfo.versionName!!)
}
suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) {
val preferredAsset =
if (PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
) {
RELEASE_ASSET_NAME
} else {
DEBUG_ASSET_NAME
}
val request =
Request
.Builder()
.url(updateUrl)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.firstOrNull { asset ->
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
assetName == ASSET_NAME || assetName == preferredAsset
}?.jsonObject
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
listOf()
}
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else {
Timber.w("Update version parsing failed. name=$name")
}
} else {
Timber.w("Update check failed: ${it.message}")
}
return@use null
}
}
}
suspend fun installRelease(release: Release) {
val activity = context.findActivity()!!
withContext(Dispatchers.IO) {
cleanup()
val request =
Request
.Builder()
.url(release.downloadUrl!!)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS,
)
}
val resolver = context.contentResolver
val uri =
resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
contentValues,
)
if (uri != null) {
it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
input.copyTo(output!!)
}
}
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data = uri
context.startActivity(intent)
} else {
Timber.e("Resolver URI is null, trying fallback")
// showToast(context, "Unable to download the apk")
val targetFile = fallbackDownload(it)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data =
FileProvider.getUriForFile(
activity,
activity.packageName + ".provider",
targetFile,
)
activity.startActivity(intent)
}
} else {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_EXTERNAL_STORAGE,
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
activity,
arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
),
PERMISSION_REQUEST_CODE,
)
} else {
val targetFile = fallbackDownload(it)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.data =
FileProvider.getUriForFile(
activity,
activity.packageName + ".provider",
targetFile,
)
activity.startActivity(intent)
} else {
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(Uri.fromFile(targetFile), APK_MIME_TYPE)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
}
}
}
} else {
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
showToast(context, "Error downloading the apk: ${it.code}")
}
}
}
}
private fun fallbackDownload(response: Response): File {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output ->
response.body!!.byteStream().copyTo(output)
}
return targetFile
}
/**
* Delete previously downloaded APKs
*/
fun cleanup() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.contentResolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
),
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
null,
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val displayName = cursor.getString(1)
Timber.v("id=$id, displayName=$displayName")
}
}
val deletedRows =
context.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
)
Timber.i("Deleted $deletedRows rows")
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetFile = File(downloadDir, ASSET_NAME)
if (targetFile.exists()) {
targetFile.delete()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception during cleanup")
}
}
}
@Serializable
data class Release(
val version: Version,
val downloadUrl: String?,
val publishedAt: String?,
val body: String?,
val notes: List<String>,
)

View file

@ -0,0 +1,24 @@
package com.github.damontecres.wholphin.util
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.UUID
class UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("UUID", PrimitiveKind.LONG)
override fun deserialize(decoder: Decoder): UUID = UUID(decoder.decodeLong(), decoder.decodeLong())
override fun serialize(
encoder: Encoder,
value: UUID,
) {
encoder.encodeLong(value.mostSignificantBits)
encoder.encodeLong(value.leastSignificantBits)
}
}

View file

@ -0,0 +1,112 @@
package com.github.damontecres.wholphin.util
import kotlinx.serialization.Serializable
@Serializable
data class Version(
val major: Int,
val minor: Int,
val patch: Int,
val numCommits: Int? = null,
val hash: String? = null,
) {
/**
* Is this version at least the given version
*/
fun isAtLeast(version: Version): Boolean {
if (this.major > version.major) {
return true
} else if (this.major == version.major) {
if (this.minor > version.minor) {
return true
} else if (this.minor == version.minor) {
if (this.patch > version.patch) {
return true
} else if (this.patch == version.patch) {
if (this.compareNumCommits(version) >= 0) {
return true
}
}
}
}
return false
}
/**
* Is this greater than the given version (and not equal to!)
*/
fun isGreaterThan(version: Version): Boolean {
if (this.major > version.major) {
return true
} else if (this.major == version.major) {
if (this.minor > version.minor) {
return true
} else if (this.minor == version.minor) {
if (this.patch > version.patch) {
return true
} else if (this.patch == version.patch) {
if (this.compareNumCommits(version) > 0) {
return true
}
}
}
}
return false
}
/**
* Is this less than the given version (and not equal to!)
*/
fun isLessThan(version: Version): Boolean = this != version && isEqualOrBefore(version)
/**
* Is this equal to or before the specified version
*/
fun isEqualOrBefore(version: Version): Boolean = !isGreaterThan(version)
private fun compareNumCommits(version: Version): Int = (this.numCommits ?: 0) - (version.numCommits ?: 0)
override fun toString(): String =
if (numCommits != null && hash != null) {
"v$major.$minor.$patch-$numCommits-g$hash"
} else {
"v$major.$minor.$patch"
}
companion object {
private val VERSION_REGEX = Regex("v?(\\d+)\\.(\\d+)\\.(\\d+)(-(\\d+)-g([a-zA-Z0-9]+))?")
/**
* Parse a version string throwing if it is invalid
*/
fun fromString(version: String): Version {
val v = tryFromString(version)
if (v == null) {
throw IllegalArgumentException(version)
} else {
return v
}
}
/**
* Attempt to parse a version string or else return null
*/
fun tryFromString(version: String?): Version? {
if (version == null) {
return null
}
val m = VERSION_REGEX.matchEntire(version)
return if (m == null) {
null
} else {
val major = m.groups[1]!!.value.toInt()
val minor = m.groups[2]!!.value.toInt()
val patch = m.groups[3]!!.value.toInt()
// group 4 is the optional commit info
val numCommits = m.groups[5]?.value?.toInt()
val hash = m.groups[6]?.value
Version(major, minor, patch, numCommits, hash)
}
}
}
}

View file

@ -0,0 +1,89 @@
package com.github.damontecres.wholphin.util.profile
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/c775603df457862c495b010550ae0aee1a66c0bc/app/src/main/java/org/jellyfin/androidtv/constant/Codec.kt
object Codec {
object Container {
@Suppress("ObjectPropertyName", "ObjectPropertyNaming")
const val `3GP` = "3gp"
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val HLS = "hls"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
const val MOV = "mov"
const val MP3 = "mp3"
const val MP4 = "mp4"
const val MPEG = "mpeg"
const val MPEGTS = "mpegts"
const val MPG = "mpg"
const val OGM = "ogm"
const val OGV = "ogv"
const val TS = "ts"
const val VOB = "vob"
const val WEBM = "webm"
const val WMV = "wmv"
const val WTV = "wtv"
const val XVID = "xvid"
}
object Audio {
const val AAC = "aac"
const val AAC_LATM = "aac_latm"
const val AC3 = "ac3"
const val ALAC = "alac"
const val APE = "ape"
const val DCA = "dca"
const val DTS = "dts"
const val EAC3 = "eac3"
const val FLAC = "flac"
const val MLP = "mlp"
const val MP2 = "mp2"
const val MP3 = "mp3"
const val MPA = "mpa"
const val OGA = "oga"
const val OGG = "ogg"
const val OPUS = "opus"
const val PCM = "pcm"
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S20LE = "pcm_s20le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val VORBIS = "vorbis"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"
const val WMAV2 = "wmav2"
}
object Video {
const val H264 = "h264"
const val HEVC = "hevc"
const val MPEG = "mpeg"
const val MPEG2VIDEO = "mpeg2video"
const val VP8 = "vp8"
const val VP9 = "vp9"
const val AV1 = "av1"
}
object Subtitle {
const val ASS = "ass"
const val DVBSUB = "dvbsub"
const val DVDSUB = "dvdsub"
const val IDX = "idx"
const val PGS = "pgs"
const val PGSSUB = "pgssub"
const val SMI = "smi"
const val SRT = "srt"
const val SSA = "ssa"
const val SUB = "sub"
const val SUBRIP = "subrip"
const val VTT = "vtt"
const val SMIL = "smil"
const val TTML = "ttml"
const val WEBVTT = "webvtt"
}
}

View file

@ -0,0 +1,508 @@
package com.github.damontecres.wholphin.util.profile
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/c775603df457862c495b010550ae0aee1a66c0bc/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt
import android.content.Context
import androidx.media3.common.MimeTypes
import com.github.damontecres.wholphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
private val downmixSupportedAudioCodecs =
arrayOf(
Codec.Audio.AAC,
Codec.Audio.MP2,
Codec.Audio.MP3,
)
private val supportedAudioCodecs =
arrayOf(
Codec.Audio.AAC,
Codec.Audio.AAC_LATM,
Codec.Audio.AC3,
Codec.Audio.ALAC,
Codec.Audio.DCA,
Codec.Audio.DTS,
Codec.Audio.EAC3,
Codec.Audio.FLAC,
Codec.Audio.MLP,
Codec.Audio.MP2,
Codec.Audio.MP3,
Codec.Audio.OPUS,
Codec.Audio.PCM_ALAW,
Codec.Audio.PCM_MULAW,
Codec.Audio.PCM_S16LE,
Codec.Audio.PCM_S20LE,
Codec.Audio.PCM_S24LE,
Codec.Audio.TRUEHD,
Codec.Audio.VORBIS,
)
// TODO use preferences
fun createDeviceProfile(
context: Context,
userPreferences: UserPreferences,
disableDirectPlay: Boolean = false,
) = createDeviceProfile(
mediaTest = MediaCodecCapabilitiesTest(context),
maxBitrate = 100_000_000,
disableDirectPlay = disableDirectPlay,
isAC3Enabled = true,
downMixAudio = false,
assDirectPlay = true,
pgsDirectPlay = true,
)
fun createDeviceProfile(
mediaTest: MediaCodecCapabilitiesTest,
maxBitrate: Int,
disableDirectPlay: Boolean,
isAC3Enabled: Boolean,
downMixAudio: Boolean,
assDirectPlay: Boolean,
pgsDirectPlay: Boolean,
) = buildDeviceProfile {
val allowedAudioCodecs =
when {
downMixAudio -> downmixSupportedAudioCodecs
!isAC3Enabled ->
supportedAudioCodecs
.filterNot {
it == Codec.Audio.EAC3 ||
it == Codec.Audio.AC3
}.toTypedArray()
else -> supportedAudioCodecs
}
val supportsHevc = mediaTest.supportsHevc()
val supportsHevcMain10 = mediaTest.supportsHevcMain10()
val hevcMainLevel = mediaTest.getHevcMainLevel()
val hevcMain10Level = mediaTest.getHevcMain10Level()
val supportsAVC = mediaTest.supportsAVC()
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10()
val avcMainLevel = mediaTest.getAVCMainLevel()
val avcHigh10Level = mediaTest.getAVCHigh10Level()
val supportsAV1 = mediaTest.supportsAV1()
val supportsAV1Main10 = mediaTest.supportsAV1Main10()
val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264)
val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265)
val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1)
// / HDR capabilities
// Display
val supportsDolbyVisionDisplay = mediaTest.supportsDolbyVision()
val supportsHdr10Display = mediaTest.supportsHdr10()
val supportsHdr10PlusDisplay = mediaTest.supportsHdr10Plus()
// Codecs
// AV1
val supportsAV1DolbyVision = mediaTest.supportsAV1DolbyVision()
val supportsAV1HDR10 = mediaTest.supportsAV1HDR10()
val supportsAV1HDR10Plus = mediaTest.supportsAV1HDR10Plus()
// HEVC
val supportsHevcDolbyVision = mediaTest.supportsHevcDolbyVision()
val supportsHevcDolbyVisionEL = mediaTest.supportsHevcDolbyVisionEL()
val supportsHevcHDR10 = mediaTest.supportsHevcHDR10()
val supportsHevcHDR10Plus = mediaTest.supportsHevcHDR10Plus()
name = "AndroidTV-Default"
// / Bitrate
maxStaticBitrate = maxBitrate
maxStreamingBitrate = maxBitrate
// / Transcoding profiles
// Video
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
if (supportsHevc) videoCodec(Codec.Video.HEVC)
videoCodec(Codec.Video.H264)
audioCodec(*allowedAudioCodecs)
copyTimestamps = false
enableSubtitlesInManifest = true
}
// Audio
transcodingProfile {
type = DlnaProfileType.AUDIO
context = EncodingContext.STREAMING
container = Codec.Container.MP3
audioCodec(Codec.Audio.MP3)
}
// / Direct play profiles
if (!disableDirectPlay) {
// Video
directPlayProfile {
type = DlnaProfileType.VIDEO
container(
Codec.Container.ASF,
Codec.Container.HLS,
Codec.Container.M4V,
Codec.Container.MKV,
Codec.Container.MOV,
Codec.Container.MP4,
Codec.Container.OGM,
Codec.Container.OGV,
Codec.Container.TS,
Codec.Container.VOB,
Codec.Container.WEBM,
Codec.Container.WMV,
Codec.Container.XVID,
)
videoCodec(
Codec.Video.AV1,
Codec.Video.H264,
Codec.Video.HEVC,
Codec.Video.MPEG,
Codec.Video.MPEG2VIDEO,
Codec.Video.VP8,
Codec.Video.VP9,
)
audioCodec(*allowedAudioCodecs)
}
// Audio
directPlayProfile {
type = DlnaProfileType.AUDIO
audioCodec(*allowedAudioCodecs)
}
}
// / Codec profiles
// H264 profile
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
when {
!supportsAVC -> ProfileConditionValue.VIDEO_PROFILE equals "none"
else ->
ProfileConditionValue.VIDEO_PROFILE inCollection
listOfNotNull(
"high",
"main",
"baseline",
"constrained baseline",
if (supportsAVCHigh10) "main 10" else null,
)
}
}
}
if (supportsAVC) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcMainLevel
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE inCollection
listOf(
"high",
"main",
"baseline",
"constrained baseline",
)
}
}
}
if (supportsAVCHigh10) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcHigh10Level
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE equals "high 10"
}
}
}
// H264 ref frames profile
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 12
}
applyConditions {
ProfileConditionValue.WIDTH greaterThanOrEquals 1200
}
}
// H264 ref frames profile
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 4
}
applyConditions {
ProfileConditionValue.WIDTH greaterThanOrEquals 1900
}
}
// HEVC profiles
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
when {
!supportsHevc -> ProfileConditionValue.VIDEO_PROFILE equals "none"
else ->
ProfileConditionValue.VIDEO_PROFILE inCollection
listOfNotNull(
"main",
if (supportsHevcMain10) "main 10" else null,
)
}
}
}
if (supportsHevc) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMainLevel
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE equals "main"
}
}
}
if (supportsHevcMain10) {
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMain10Level
}
applyConditions {
ProfileConditionValue.VIDEO_PROFILE equals "main 10"
}
}
}
// AV1 profile
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
conditions {
when {
!supportsAV1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
!supportsAV1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10"
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
}
}
}
// Get max resolutions for common codecs
// AVC
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.H264
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAVC.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAVC.height
}
}
// HEVC
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionHevc.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionHevc.height
}
}
// AV1
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height
}
}
// / HDR exclude list
// TODO Use VideoRangeType enum with Jelylfin 10.11 based SDK
// Display
codecProfile {
type = CodecType.VIDEO
conditions {
if (!supportsDolbyVisionDisplay) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
// TODO values not supported on v10.10: https://github.com/jellyfin/jellyfin/blob/v10.10.7/Jellyfin.Data/Enums/VideoRangeType.cs
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithEL"
// if (!supportsHdr10PlusDisplay) {
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithHDR10Plus"
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithELHDR10Plus"
// }
if (!supportsHdr10Display) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10.serialName
}
}
if (!supportsHdr10PlusDisplay) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10_PLUS.serialName
if (!supportsHdr10Display) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
}
}
}
}.let {
// Remove codec profile if all HDR types are fully supported
if (it.conditions.isEmpty()) codecProfiles.remove(it)
}
// Codecs
// AV1
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
conditions {
if (supportsDolbyVisionDisplay && !supportsAV1DolbyVision) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
if (supportsHdr10Display && !supportsAV1HDR10) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10.serialName
}
if (supportsHdr10PlusDisplay && !supportsAV1HDR10Plus) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithHDR10Plus"
}
}
if (supportsHdr10PlusDisplay && !mediaTest.supportsAV1HDR10Plus()) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10_PLUS.serialName
if (supportsHdr10Display && !mediaTest.supportsAV1HDR10()) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
}
}
}
}.let {
// Remove codec profile if all HDR types are fully supported
if (it.conditions.isEmpty()) codecProfiles.remove(it)
}
// HEVC
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC
conditions {
if (supportsDolbyVisionDisplay && !supportsHevcDolbyVisionEL) {
// TODO
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithEL"
// if (supportsHdr10PlusDisplay && !supportsHevcHDR10Plus) {
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithELHDR10Plus"
// }
if (!supportsHevcDolbyVision) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
if (supportsHdr10Display && !supportsHevcHDR10) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10.serialName
}
// if (supportsHdr10PlusDisplay && !supportsHevcHDR10Plus) {
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithHDR10Plus"
// }
}
}
if (supportsHdr10PlusDisplay && !supportsHevcHDR10Plus) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10_PLUS.serialName
if (supportsHdr10Display && !supportsHevcHDR10) {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
}
}
}
}.let {
// Remove codec profile if all HDR types are fully supported
if (it.conditions.isEmpty()) codecProfiles.remove(it)
}
// Audio channel profile
codecProfile {
type = CodecType.VIDEO_AUDIO
conditions {
ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (downMixAudio) 2 else 8
}
}
// / Subtitle profiles
// Jellyfin server only supports WebVTT subtitles in HLS, other text subtitles will be converted to WebVTT
// which we do not want so only allow delivery over HLS for WebVTT subtitles
subtitleProfile(Codec.Subtitle.VTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.WEBVTT, embedded = true, hls = true, external = true)
subtitleProfile(Codec.Subtitle.SRT, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.SUBRIP, embedded = true, external = true)
subtitleProfile(Codec.Subtitle.TTML, embedded = true, external = true)
// Not all subtitles can be loaded standalone by the player
subtitleProfile(Codec.Subtitle.DVBSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.DVDSUB, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.IDX, embedded = true, encode = true)
subtitleProfile(Codec.Subtitle.PGS, embedded = pgsDirectPlay, encode = true)
subtitleProfile(Codec.Subtitle.PGSSUB, embedded = pgsDirectPlay, encode = true)
// ASS/SSA is supported via libass extension
subtitleProfile(Codec.Subtitle.ASS, encode = true, embedded = assDirectPlay, external = assDirectPlay)
subtitleProfile(Codec.Subtitle.SSA, encode = true, embedded = assDirectPlay, external = assDirectPlay)
}
// Little helper function to more easily define subtitle profiles
private fun DeviceProfileBuilder.subtitleProfile(
format: String,
embedded: Boolean = false,
external: Boolean = false,
hls: Boolean = false,
encode: Boolean = false,
) {
if (embedded) subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
if (external) subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
if (hls) subtitleProfile(format, SubtitleDeliveryMethod.HLS)
if (encode) subtitleProfile(format, SubtitleDeliveryMethod.ENCODE)
}

View file

@ -0,0 +1,352 @@
package com.github.damontecres.wholphin.util.profile
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/c775603df457862c495b010550ae0aee1a66c0bc/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt
import android.content.Context
import android.media.MediaCodecInfo.CodecProfileLevel
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
import android.util.Size
import android.view.Display
import androidx.core.content.ContextCompat
import timber.log.Timber
class MediaCodecCapabilitiesTest(
private val context: Context,
) {
private val display by lazy { ContextCompat.getDisplayOrDefault(context) }
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
@Suppress("DEPRECATION")
private val supportedHdrTypes by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
display.mode.supportedHdrTypes.toList()
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
display.hdrCapabilities.supportedHdrTypes.toList()
} else {
emptyList()
}
}
// Map common Dolby Vision Profiles to their corresponding CodecProfileLevel constant
private object DolbyVisionProfiles {
val Profile5: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
CodecProfileLevel.DolbyVisionProfileDvheStn
} else {
-1
}
}
val Profile7: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
CodecProfileLevel.DolbyVisionProfileDvheDtb
} else {
-1
}
}
val Profile8: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
CodecProfileLevel.DolbyVisionProfileDvheSt
} else {
-1
}
}
val Profile10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
CodecProfileLevel.DolbyVisionProfileDvav110
} else {
-1
}
}
}
// AVC levels as reported by ffprobe are multiplied by 10, e.g. level 4.1 is 41. Level 1b is set to 9
private val avcLevels =
listOf(
CodecProfileLevel.AVCLevel1b to 9,
CodecProfileLevel.AVCLevel1 to 10,
CodecProfileLevel.AVCLevel11 to 11,
CodecProfileLevel.AVCLevel12 to 12,
CodecProfileLevel.AVCLevel13 to 13,
CodecProfileLevel.AVCLevel2 to 20,
CodecProfileLevel.AVCLevel21 to 21,
CodecProfileLevel.AVCLevel22 to 22,
CodecProfileLevel.AVCLevel3 to 30,
CodecProfileLevel.AVCLevel31 to 31,
CodecProfileLevel.AVCLevel32 to 32,
CodecProfileLevel.AVCLevel4 to 40,
CodecProfileLevel.AVCLevel41 to 41,
CodecProfileLevel.AVCLevel42 to 42,
CodecProfileLevel.AVCLevel5 to 50,
CodecProfileLevel.AVCLevel51 to 51,
CodecProfileLevel.AVCLevel52 to 52,
)
// HEVC levels as reported by ffprobe are multiplied by 30, e.g. level 4.1 is 123
private val hevcLevels =
listOf(
CodecProfileLevel.HEVCMainTierLevel1 to 30,
CodecProfileLevel.HEVCMainTierLevel2 to 60,
CodecProfileLevel.HEVCMainTierLevel21 to 63,
CodecProfileLevel.HEVCMainTierLevel3 to 90,
CodecProfileLevel.HEVCMainTierLevel31 to 93,
CodecProfileLevel.HEVCMainTierLevel4 to 120,
CodecProfileLevel.HEVCMainTierLevel41 to 123,
CodecProfileLevel.HEVCMainTierLevel5 to 150,
CodecProfileLevel.HEVCMainTierLevel51 to 153,
CodecProfileLevel.HEVCMainTierLevel52 to 156,
CodecProfileLevel.HEVCMainTierLevel6 to 180,
CodecProfileLevel.HEVCMainTierLevel61 to 183,
CodecProfileLevel.HEVCMainTierLevel62 to 186,
)
fun supportsAV1(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AV1)
fun supportsAV1Main10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10,
CodecProfileLevel.AV1Level5,
)
fun supportsAV1DolbyVision(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
DolbyVisionProfiles.Profile10,
CodecProfileLevel.DolbyVisionLevelHd24,
)
fun supportsAV1HDR10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10HDR10,
CodecProfileLevel.AV1Level5,
)
fun supportsAV1HDR10Plus(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10HDR10Plus,
CodecProfileLevel.AV1Level5,
)
fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)
fun supportsAVCHigh10(): Boolean =
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AVC,
CodecProfileLevel.AVCProfileHigh10,
CodecProfileLevel.AVCLevel4,
)
fun getAVCMainLevel(): Int =
getAVCLevel(
CodecProfileLevel.AVCProfileMain,
)
fun getAVCHigh10Level(): Int =
getAVCLevel(
CodecProfileLevel.AVCProfileHigh10,
)
private fun getAVCLevel(profile: Int): Int {
val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile)
return avcLevels
.asReversed()
.find { item ->
level >= item.first
}?.second ?: 0
}
fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC)
fun supportsHevcMain10(): Boolean =
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10,
CodecProfileLevel.HEVCMainTierLevel4,
)
// Can safely assume Dolby Vision decoders support single-layer HEVC profiles
fun supportsHevcDolbyVision(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
// Checks for Dolby Vision Profile 7 (Enhancement Layer) and multi-instance HEVC support
fun supportsHevcDolbyVisionEL(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
DolbyVisionProfiles.Profile7,
CodecProfileLevel.DolbyVisionLevelHd24,
) &&
supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC)
fun supportsHevcHDR10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10HDR10,
CodecProfileLevel.HEVCMainTierLevel4,
)
fun supportsHevcHDR10Plus(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_HEVC,
CodecProfileLevel.HEVCProfileMain10HDR10Plus,
CodecProfileLevel.HEVCMainTierLevel4,
)
fun getHevcMainLevel(): Int =
getHevcLevel(
CodecProfileLevel.HEVCProfileMain,
)
fun getHevcMain10Level(): Int =
getHevcLevel(
CodecProfileLevel.HEVCProfileMain10,
)
private fun getHevcLevel(profile: Int): Int {
val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile)
return hevcLevels
.asReversed()
.find { item ->
level >= item.first
}?.second ?: 0
}
private fun getDecoderLevel(
mime: String,
profile: Int,
): Int {
var maxLevel = 0
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mime)
for (profileLevel in capabilities.profileLevels) {
if (profileLevel.profile == profile) {
maxLevel = maxOf(maxLevel, profileLevel.level)
}
}
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
return maxLevel
}
private fun hasDecoder(
mime: String,
profile: Int,
level: Int,
): Boolean {
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mime)
for (profileLevel in capabilities.profileLevels) {
if (profileLevel.profile != profile) continue
// H.263 levels are not completely ordered:
// Level45 support only implies Level10 support
if (mime.equals(MediaFormat.MIMETYPE_VIDEO_H263, ignoreCase = true)) {
if (profileLevel.level != level && profileLevel.level == CodecProfileLevel.H263Level45 &&
level > CodecProfileLevel.H263Level10
) {
continue
}
}
if (profileLevel.level >= level) return true
}
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
return false
}
private fun hasCodecForMime(mime: String): Boolean {
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
if (info.supportedTypes.any { it.equals(mime, ignoreCase = true) }) {
Timber.i("found codec %s for mime %s", info.name, mime)
return true
}
}
return false
}
private fun supportsMultiInstance(mime: String): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val types = info.getSupportedTypes()
if (!types.contains(mime)) continue
val capabilities = info.getCapabilitiesForType(mime)
if (capabilities.maxSupportedInstances > 1) return true
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
return false
}
fun getMaxResolution(mime: String): Size {
var maxWidth = 0
var maxHeight = 0
for (info in mediaCodecList.codecInfos) {
if (info.isEncoder) continue
try {
val capabilities = info.getCapabilitiesForType(mime)
val videoCapabilities = capabilities.videoCapabilities ?: continue
val supportedWidth = videoCapabilities.supportedWidths?.upper ?: continue
val supportedHeight = videoCapabilities.supportedHeights?.upper ?: continue
maxWidth = maxOf(maxWidth, supportedWidth)
maxHeight = maxOf(maxHeight, supportedHeight)
} catch (_: IllegalArgumentException) {
// Decoder not supported - ignore
}
}
Timber.d("Computed max resolution for %s: %dx%d", mime, maxWidth, maxHeight)
return Size(maxWidth, maxHeight)
}
fun supportsDolbyVision(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
fun supportsHdr10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_HDR10)
fun supportsHdr10Plus(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS)
}