mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
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:
parent
d7b333e519
commit
6be2662d4e
84 changed files with 3987 additions and 289 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ fun SeekBar(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
interactionSource = interactionSource,
|
||||
enabled = isEnabled,
|
||||
durationMs = player.contentDuration,
|
||||
durationMs = player.duration,
|
||||
seekBack = seekBack,
|
||||
seekForward = seekForward,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?>>,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue