Add experimental MPV player backend (#161)

Experimental MPV player backend

Related to #14, #22, & #85

You can switch to MPV in advanced settings and toggle using hardware
decoding or not.

This uses code and buildscripts from
https://github.com/mpv-android/mpv-android, plus other third party
libraries to build MPV
This commit is contained in:
damontecres 2025-11-16 18:46:25 -05:00 committed by GitHub
parent d7b333e519
commit 6be2662d4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 3987 additions and 289 deletions

View file

@ -30,7 +30,10 @@ fun CircularProgress(modifier: Modifier = Modifier) {
* Fill the space with a loading indicator and take focus
*/
@Composable
fun LoadingPage(modifier: Modifier = Modifier) {
fun LoadingPage(
modifier: Modifier = Modifier,
focusEnabled: Boolean = true,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(
@ -39,7 +42,7 @@ fun LoadingPage(modifier: Modifier = Modifier) {
modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable(),
.focusable(focusEnabled),
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
@ -224,6 +225,11 @@ fun DebugPage(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "ABIs: ${Build.SUPPORTED_ABIS.toList()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
item {
@ -237,12 +243,12 @@ fun DebugPage(
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Current server: ${viewModel.serverRepository.currentServer}",
text = "Current server: ${viewModel.serverRepository.currentServer.value}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Current user: ${viewModel.serverRepository.currentUser}",
text = "Current user: ${viewModel.serverRepository.currentUser.value}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)

View file

@ -274,7 +274,7 @@ fun SeekBar(
modifier = Modifier.fillMaxWidth(),
interactionSource = interactionSource,
enabled = isEnabled,
durationMs = player.contentDuration,
durationMs = player.duration,
seekBack = seekBack,
seekForward = seekForward,
)

View file

@ -407,6 +407,12 @@ fun PlaybackOverlay(
.padding(16.dp)
.background(AppColors.TransparentBlack50),
) {
Text(
text = "Backend: ${currentPlayback?.backend}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
)
Text(
text = "Play method: ${currentPlayback?.playMethod?.serialName}",
style = MaterialTheme.typography.bodySmall,

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
@ -65,6 +66,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios
@ -72,12 +74,14 @@ import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.stringRes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.DeviceProfile
@ -117,6 +121,7 @@ fun PlaybackPage(
LoadingState.Success -> {
val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val player = viewModel.player
val title by viewModel.title.observeAsState(null)
@ -157,6 +162,13 @@ fun PlaybackPage(
OneTimeLaunchedEffect {
player.addListener(cueListener)
if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
density,
)
}
}
}
DisposableEffect(Unit) {
onDispose { player.removeListener(cueListener) }
@ -166,7 +178,7 @@ fun PlaybackPage(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val presentationState = rememberPresentationState(player)
val presentationState = rememberPresentationState(player, false)
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
@ -241,12 +253,15 @@ fun PlaybackPage(
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
val isLoading by rememberPlayerLoadingState(player)
Box(
Modifier
.matchParentSize()
.background(Color.Black),
) {
LoadingPage()
if (isLoading) {
LoadingPage(focusEnabled = false)
}
}
}
@ -365,7 +380,7 @@ fun PlaybackPage(
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playUpNextUp()
viewModel.playNextUp()
}
PlaybackAction.Previous -> {
@ -458,14 +473,14 @@ fun PlaybackPage(
if (autoPlayEnabled) {
LaunchedEffect(Unit) {
if (timeLeft == 0L) {
viewModel.playUpNextUp()
viewModel.playNextUp()
} else {
while (timeLeft > 0) {
delay(1.seconds)
timeLeft--
}
if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playUpNextUp()
viewModel.playNextUp()
}
}
}
@ -483,7 +498,7 @@ fun PlaybackPage(
?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
viewModel.playUpNextUp()
viewModel.playNextUp()
},
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier =

View file

@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -52,6 +53,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel
@ -77,6 +79,8 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
@ -209,7 +213,7 @@ class PlaybackViewModel
destination.forceTranscoding,
)
if (!played) {
playUpNextUp()
playNextUp()
}
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
@ -364,6 +368,7 @@ class PlaybackViewModel
enableDirectStream = !forceTranscoding,
)
player.prepare()
player.play()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
@ -378,29 +383,74 @@ class PlaybackViewModel
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?,
subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET,
positionMs: Long = 0,
userInitiated: Boolean,
enableDirectPlay: Boolean = true,
enableDirectStream: Boolean = true,
) = withContext(Dispatchers.IO) {
val itemId = item.id
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting
// Except for external subtitles
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play")
val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
applyTrackSelections(
player,
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
)
}
if (result.bothSelected) {
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
}
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentPlayback.value =
currentPlayback.copy(
tracks = checkForSupport(player.currentTracks),
)
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
}
return@withContext
}
} else {
Timber.v("changeStreams direct play, external subtitle was requested")
}
}
// TODO
// if (currentItemPlayback.let {
// it.itemId == itemId &&
// it.audioIndex == audioIndex &&
// it.subtitleIndex == subtitleIndex
// } == true
// ) {
// Timber.i("No change in playback for changeStreams")
// return@withContext
// }
Timber.d(
"changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " +
"enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream",
"enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream, positionMs=$positionMs",
)
// TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
@ -410,7 +460,12 @@ class PlaybackViewModel
itemId,
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile = deviceProfile,
deviceProfile =
if (playerBackend == PlayerBackend.EXO_PLAYER) {
deviceProfile
} else {
mpvDeviceProfile
},
maxAudioChannels = null,
audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex,
@ -453,6 +508,7 @@ class PlaybackViewModel
}
val transcodeType =
when {
// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY
source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
source.supportsTranscoding -> PlayMethod.TRANSCODE
@ -461,29 +517,27 @@ class PlaybackViewModel
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision")
val externalSubtitleCount =
source.mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
val externalSubtitleCount = source.externalSubtitlesCount
val externalSubtitle =
source.mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
?.let {
it.deliveryUrl?.let { deliveryUrl ->
var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration
.Builder(
api.createUrl(deliveryUrl).toUri(),
).setId("e:${it.index}")
.setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language)
.setSelectionFlags(flags)
.build()
}
source.findExternalSubtitle(subtitleIndex)?.let {
it.deliveryUrl?.let { deliveryUrl ->
var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration
.Builder(
api.createUrl(deliveryUrl).toUri(),
).setId("e:${it.index}")
.setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language)
.setLabel(it.title)
.setSelectionFlags(flags)
.build()
}
Timber.v("externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle")
}
Timber.v("subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle")
val mediaItem =
MediaItem
@ -497,9 +551,11 @@ class PlaybackViewModel
CurrentPlayback(
item = item,
tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend,
playMethod = transcodeType,
playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
mediaSourceInfo = source,
)
val itemPlayback =
currentItemPlayback.copy(
@ -536,7 +592,7 @@ class PlaybackViewModel
duration.value = source.runTimeTicks?.ticks
loading.value = LoadingState.Success
currentPlayback.value = playback
this@PlaybackViewModel.currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem(
mediaItem,
@ -548,15 +604,18 @@ class PlaybackViewModel
override fun onTracksChanged(tracks: Tracks) {
Timber.v("onTracksChanged: $tracks")
if (tracks.groups.isNotEmpty()) {
applyTrackSelections(
player,
source.supportsDirectPlay,
audioIndex,
subtitleIndex,
externalSubtitleCount,
externalSubtitle != null,
)
player.removeListener(this)
val result =
applyTrackSelections(
player,
playerBackend,
source.supportsDirectPlay,
audioIndex,
subtitleIndex,
source,
)
if (result.bothSelected) {
player.removeListener(this)
}
}
}
}
@ -727,7 +786,7 @@ class PlaybackViewModel
}
}
fun playUpNextUp() {
fun playNextUp() {
playlist.value?.let {
if (it.hasNext()) {
viewModelScope.launchIO {
@ -735,7 +794,7 @@ class PlaybackViewModel
val item = it.getAndAdvance()
val played = play(item, 0)
if (!played) {
playUpNextUp()
playNextUp()
}
}
}
@ -768,7 +827,7 @@ class PlaybackViewModel
if (toPlay != null) {
val played = play(toPlay, 0)
if (!played) {
playUpNextUp()
playNextUp()
}
} else {
// TODO
@ -837,7 +896,7 @@ class PlaybackViewModel
PlaystateCommand.PAUSE -> player.pause()
PlaystateCommand.UNPAUSE -> player.play()
PlaystateCommand.NEXT_TRACK -> playUpNextUp()
PlaystateCommand.NEXT_TRACK -> playNextUp()
PlaystateCommand.PREVIOUS_TRACK -> playPrevious()
PlaystateCommand.SEEK -> it.seekPositionTicks?.ticks?.let { player.seekTo(it.inWholeMilliseconds) }
PlaystateCommand.REWIND ->
@ -1013,9 +1072,11 @@ class PlaybackViewModel
data class CurrentPlayback(
val item: BaseItem,
val tracks: List<TrackSupport>,
val backend: PlayerBackend,
val playMethod: PlayMethod,
val playSessionId: String?,
val liveStreamId: String?,
val mediaSourceInfo: MediaSourceInfo,
)
sealed interface SubtitleSearch {
@ -1044,81 +1105,205 @@ val Format.idAsInt: Int?
}
}
/**
* Returns the number of external subtitle streams there are
*/
val MediaSourceInfo.externalSubtitlesCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
/**
* Returns the number of embedded subtitle streams there are
*/
val MediaSourceInfo.embeddedSubtitleCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && !it.isExternal } ?: 0
/**
* Returns the number of video streams there are
*/
val MediaSourceInfo.videoStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.VIDEO } ?: 0
/**
* Returns the number of audio streams there are
*/
val MediaSourceInfo.audioStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.AUDIO } ?: 0
/**
* Returns the [MediaStream] for the given subtitle index iff it is external
*/
fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
subtitleIndex?.let {
mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
}
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
data class TrackSelectionResult(
val audioSelected: Boolean,
val subtitleSelected: Boolean,
) {
val bothSelected: Boolean = audioSelected && subtitleSelected
}
@OptIn(UnstableApi::class)
private fun applyTrackSelections(
player: Player,
playerBackend: PlayerBackend,
supportsDirectPlay: Boolean,
audioIndex: Int?,
subtitleIndex: Int?,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
) {
if (subtitleIndex != null && subtitleIndex >= 0 && (subtitleIsExternal || supportsDirectPlay)) {
val chosenTrack =
if (subtitleIsExternal) {
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
source: MediaSourceInfo,
): TrackSelectionResult {
val videoStreamCount = source.videoStreamCount
val audioStreamCount = source.audioStreamCount
val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount
val paramsBuilder = player.trackSelectionParameters.buildUpon()
val tracks = player.currentTracks.groups
val subtitleSelected =
if (subtitleIndex != null && subtitleIndex >= 0) {
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
if (subtitleIsExternal || supportsDirectPlay) {
val chosenTrack =
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
}
} else {
val indexToFind =
calculateIndexToFind(
subtitleIndex,
MediaStreamType.SUBTITLE,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
subtitleIsExternal,
)
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
// subtitleIndex - externalSubtitleCount + 1
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
}
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
}
chosenTrack != null
} else {
val indexToFind = subtitleIndex - externalSubtitleCount + 1
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
false
}
} else {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
true
}
val audioSelected =
if (audioIndex != null && supportsDirectPlay) {
val indexToFind =
calculateIndexToFind(
audioIndex,
MediaStreamType.AUDIO,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
false,
)
val chosenTrack =
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
}
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
}
} else {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
}
if (audioIndex != null && supportsDirectPlay) {
val indexToFind =
audioIndex - externalSubtitleCount + 1
val chosenTrack =
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
)
}
chosenTrack != null
} else {
audioIndex == null
}
if (audioSelected && subtitleSelected) {
player.trackSelectionParameters = paramsBuilder.build()
}
return TrackSelectionResult(audioSelected, subtitleSelected)
}
/**
* Maps the server provided index to the track index based on the [PlayerBackend] and other stream information
*/
private fun calculateIndexToFind(
serverIndex: Int,
type: MediaStreamType,
playerBackend: PlayerBackend,
videoStreamCount: Int,
audioStreamCount: Int,
embeddedSubtitleCount: Int,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
): Int =
when (playerBackend) {
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
serverIndex - externalSubtitleCount + 1
}
// TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.MPV -> {
when (type) {
MediaStreamType.VIDEO -> serverIndex - externalSubtitleCount + 1
MediaStreamType.AUDIO -> serverIndex - externalSubtitleCount - videoStreamCount + 1
MediaStreamType.SUBTITLE -> {
if (subtitleIsExternal) {
serverIndex + embeddedSubtitleCount + 1
} else {
serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1
}
}
else -> throw UnsupportedOperationException("Cannot calculate index for $type")
}
}
}
}

View file

@ -0,0 +1,39 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.media3.common.Player
import androidx.media3.common.listen
import androidx.media3.common.util.UnstableApi
@UnstableApi
@Composable
fun rememberPlayerLoadingState(player: Player): PlayerLoadingState {
val state = remember(player) { PlayerLoadingState(player) }
LaunchedEffect(player) {
state.observe()
}
return state
}
@UnstableApi
class PlayerLoadingState(
private val player: Player,
) : State<Boolean> {
override var value by mutableStateOf(player.isLoading)
private set
suspend fun observe() {
value = player.isLoading
player.listen {
if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) {
value = player.isLoading
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import kotlinx.serialization.Serializable
/**
@ -10,6 +11,12 @@ import kotlinx.serialization.Serializable
data class PreferenceGroup(
@param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>,
val conditionalPreferences: List<ConditionalPreferences> = listOf(),
)
data class ConditionalPreferences(
val condition: (AppPreferences) -> Boolean,
val preferences: List<AppPreference<out Any?>>,
)
/**

View file

@ -43,9 +43,11 @@ import coil3.SingletonImageLoader
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.uiPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
@ -53,9 +55,11 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun PreferencesContent(
@ -112,6 +116,22 @@ fun PreferencesContent(
visible = true
}
LaunchedEffect(preferences.playbackPreferences.playerBackend) {
if (preferences.playbackPreferences.playerBackend == PlayerBackend.MPV) {
Timber.d("Checking for libmpv")
try {
System.loadLibrary("mpv")
System.loadLibrary("player")
} catch (ex: Exception) {
Timber.w(ex, "Could not load libmpv")
showToast(context, "MPV is not supported on this device")
viewModel.preferenceDataStore.updateData {
it.updatePlaybackPreferences { playerBackend = PlayerBackend.EXO_PLAYER }
}
}
}
}
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInHorizontally { it / 2 },
@ -179,7 +199,13 @@ fun PreferencesContent(
.padding(top = 8.dp, bottom = 4.dp),
)
}
group.preferences.forEachIndexed { prefIndex, pref ->
val groupPreferences =
group.preferences +
group.conditionalPreferences
.filter { it.condition.invoke(preferences) }
.map { it.preferences }
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any>
item {
val interactionSource = remember { MutableInteractionSource() }

View file

@ -4,6 +4,8 @@ import android.graphics.Typeface
import androidx.annotation.OptIn
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.CaptionStyleCompat
import com.github.damontecres.wholphin.R
@ -17,6 +19,8 @@ import com.github.damontecres.wholphin.preferences.SubtitlePreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.util.mpv.MPVLib
import com.github.damontecres.wholphin.util.mpv.setPropertyColor
object SubtitleSettings {
val FontSize =
@ -245,4 +249,56 @@ object SubtitleSettings {
},
)
}
fun SubtitlePreferences.applyToMpv(density: Density) {
val fo = (fontOpacity / 100.0 * 255).toInt().shl(24)
val fc = Color(fo.or(fontColor))
val bg = Color((backgroundOpacity / 100.0 * 255).toInt().shl(24).or(backgroundColor))
val edge = Color(fo.or(edgeColor))
// TODO weird, but seems to get the size to be very close to matching sizes between renderers
val fontSizePx = with(density) { fontSize.sp.toPx() * .8 }.toInt()
MPVLib.setPropertyInt("sub-font-size", fontSizePx)
MPVLib.setPropertyColor("sub-color", fc)
MPVLib.setPropertyColor("sub-outline-color", edge)
when (edgeStyle) {
EdgeStyle.EDGE_NONE,
EdgeStyle.UNRECOGNIZED,
-> {
MPVLib.setPropertyInt("sub-shadow-offset", 0)
MPVLib.setPropertyDouble("sub-outline-size", 0.0)
}
EdgeStyle.EDGE_SOLID -> {
MPVLib.setPropertyInt("sub-shadow-offset", 0)
MPVLib.setPropertyDouble("sub-outline-size", 1.15)
}
EdgeStyle.EDGE_SHADOW -> {
MPVLib.setPropertyInt("sub-shadow-offset", 4)
MPVLib.setPropertyDouble("sub-outline-size", 0.0)
}
}
// if (fontBold) {
// MPVLib.setPropertyString("sub-font", "Roboto Bold")
// } else {
// MPVLib.setPropertyString("sub-font", "Roboto Regular")
// }
MPVLib.setPropertyBoolean("sub-bold", fontBold)
MPVLib.setPropertyBoolean("sub-italic", fontItalic)
MPVLib.setPropertyColor("sub-back-color", bg)
val borderStyle =
when (backgroundStyle) {
BackgroundStyle.UNRECOGNIZED,
BackgroundStyle.BG_NONE,
-> "outline-and-shadow"
BackgroundStyle.BG_WRAP -> "opaque-box"
BackgroundStyle.BG_BOXED -> "background-box"
}
MPVLib.setPropertyString("sub-border-style", borderStyle)
}
}