mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Rebrand
This commit is contained in:
parent
aabd8462dc
commit
d39ed721d5
138 changed files with 738 additions and 738 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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) }
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue