mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add setting to automatically switch between ExoPlayer & MPV (#736)
## Description The MPV backend is no longer considered experimental. This PR adds a setting for player backend "Prefer MPV" which uses MPV for all playback unless the video is HDR which then uses ExoPlayer. Switching occurs per video. So if there's a playlist with both SDR & HDR, the player will switch back and forth as needed. ### Related issues Closes #253 Related to #235
This commit is contained in:
parent
16ac02a3fd
commit
0639a7a1da
13 changed files with 892 additions and 648 deletions
|
|
@ -746,16 +746,29 @@ sealed interface AppPreference<Pref, T> {
|
||||||
val PlayerBackendPref =
|
val PlayerBackendPref =
|
||||||
AppChoicePreference<AppPreferences, PlayerBackend>(
|
AppChoicePreference<AppPreferences, PlayerBackend>(
|
||||||
title = R.string.player_backend,
|
title = R.string.player_backend,
|
||||||
defaultValue = PlayerBackend.EXO_PLAYER,
|
defaultValue = PlayerBackend.PREFER_MPV,
|
||||||
getter = { it.playbackPreferences.playerBackend },
|
getter = { it.playbackPreferences.playerBackend },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updatePlaybackPreferences { playerBackend = value }
|
prefs.updatePlaybackPreferences { playerBackend = value }
|
||||||
},
|
},
|
||||||
displayValues = R.array.player_backend_options,
|
displayValues = R.array.player_backend_options,
|
||||||
|
subtitles = R.array.player_backend_options_subtitles,
|
||||||
indexToValue = { PlayerBackend.forNumber(it) },
|
indexToValue = { PlayerBackend.forNumber(it) },
|
||||||
valueToIndex = { it.number },
|
valueToIndex = { it.number },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ExoPlayerSettings =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.exoplayer_options,
|
||||||
|
destination = Destination.Settings(PreferenceScreenOption.EXO_PLAYER),
|
||||||
|
)
|
||||||
|
|
||||||
|
val MpvSettings =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.mpv_options,
|
||||||
|
destination = Destination.Settings(PreferenceScreenOption.MPV),
|
||||||
|
)
|
||||||
|
|
||||||
val MpvHardwareDecoding =
|
val MpvHardwareDecoding =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.mpv_hardware_decoding,
|
title = R.string.mpv_hardware_decoding,
|
||||||
|
|
@ -958,6 +971,40 @@ val basicPreferences =
|
||||||
|
|
||||||
val uiPreferences = listOf<PreferenceGroup>()
|
val uiPreferences = listOf<PreferenceGroup>()
|
||||||
|
|
||||||
|
private val ExoPlayerSettings =
|
||||||
|
listOf(
|
||||||
|
AppPreference.FfmpegPreference,
|
||||||
|
AppPreference.DownMixStereo,
|
||||||
|
AppPreference.Ac3Supported,
|
||||||
|
AppPreference.DirectPlayAss,
|
||||||
|
AppPreference.DirectPlayPgs,
|
||||||
|
AppPreference.DirectPlayDoviProfile7,
|
||||||
|
AppPreference.DecodeAv1,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ExoPlayerPreferences =
|
||||||
|
listOf(
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.exoplayer_options,
|
||||||
|
preferences = ExoPlayerSettings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val MpvSettings =
|
||||||
|
listOf(
|
||||||
|
AppPreference.MpvHardwareDecoding,
|
||||||
|
AppPreference.MpvGpuNext,
|
||||||
|
AppPreference.MpvConfFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
val MpvPreferences =
|
||||||
|
listOf(
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.mpv_options,
|
||||||
|
preferences = MpvSettings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
val advancedPreferences =
|
val advancedPreferences =
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
add(
|
||||||
|
|
@ -1008,22 +1055,17 @@ val advancedPreferences =
|
||||||
listOf(
|
listOf(
|
||||||
ConditionalPreferences(
|
ConditionalPreferences(
|
||||||
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
|
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
|
||||||
listOf(
|
ExoPlayerSettings,
|
||||||
AppPreference.FfmpegPreference,
|
|
||||||
AppPreference.DownMixStereo,
|
|
||||||
AppPreference.Ac3Supported,
|
|
||||||
AppPreference.DirectPlayAss,
|
|
||||||
AppPreference.DirectPlayPgs,
|
|
||||||
AppPreference.DirectPlayDoviProfile7,
|
|
||||||
AppPreference.DecodeAv1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ConditionalPreferences(
|
ConditionalPreferences(
|
||||||
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV },
|
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV },
|
||||||
|
MpvSettings,
|
||||||
|
),
|
||||||
|
ConditionalPreferences(
|
||||||
|
{ it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV },
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.MpvHardwareDecoding,
|
AppPreference.ExoPlayerSettings,
|
||||||
AppPreference.MpvGpuNext,
|
AppPreference.MpvSettings,
|
||||||
AppPreference.MpvConfFile,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1108,6 +1150,7 @@ data class AppChoicePreference<Pref, T>(
|
||||||
override val getter: (prefs: Pref) -> T,
|
override val getter: (prefs: Pref) -> T,
|
||||||
override val setter: (prefs: Pref, value: T) -> Pref,
|
override val setter: (prefs: Pref, value: T) -> Pref,
|
||||||
@param:StringRes val summary: Int? = null,
|
@param:StringRes val summary: Int? = null,
|
||||||
|
@param:ArrayRes val subtitles: Int? = null,
|
||||||
) : AppPreference<Pref, T>
|
) : AppPreference<Pref, T>
|
||||||
|
|
||||||
data class AppMultiChoicePreference<Pref, T>(
|
data class AppMultiChoicePreference<Pref, T>(
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,26 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.update
|
import com.github.damontecres.wholphin.preferences.update
|
||||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||||
|
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||||
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -197,4 +202,11 @@ suspend fun upgradeApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV }
|
||||||
|
}
|
||||||
|
showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,14 @@ import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.lang.reflect.Constructor
|
import java.lang.reflect.Constructor
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -53,7 +56,9 @@ class PlayerFactory
|
||||||
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
|
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
|
||||||
val newPlayer =
|
val newPlayer =
|
||||||
when (backend) {
|
when (backend) {
|
||||||
PlayerBackend.MPV -> {
|
PlayerBackend.PREFER_MPV,
|
||||||
|
PlayerBackend.MPV,
|
||||||
|
-> {
|
||||||
val enableHardwareDecoding =
|
val enableHardwareDecoding =
|
||||||
prefs?.mpvOptions?.enableHardwareDecoding
|
prefs?.mpvOptions?.enableHardwareDecoding
|
||||||
?: AppPreference.MpvHardwareDecoding.defaultValue
|
?: AppPreference.MpvHardwareDecoding.defaultValue
|
||||||
|
|
@ -94,6 +99,53 @@ class PlayerFactory
|
||||||
currentPlayer = newPlayer
|
currentPlayer = newPlayer
|
||||||
return newPlayer
|
return newPlayer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun createVideoPlayer(
|
||||||
|
backend: PlayerBackend,
|
||||||
|
prefs: PlaybackPreferences,
|
||||||
|
): Player {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (currentPlayer?.isReleased == false) {
|
||||||
|
Timber.w("Player was not released before trying to create a new one!")
|
||||||
|
currentPlayer?.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newPlayer =
|
||||||
|
when (backend) {
|
||||||
|
PlayerBackend.PREFER_MPV,
|
||||||
|
PlayerBackend.MPV,
|
||||||
|
-> {
|
||||||
|
val enableHardwareDecoding = prefs.mpvOptions.enableHardwareDecoding
|
||||||
|
val useGpuNext = prefs.mpvOptions.useGpuNext
|
||||||
|
MpvPlayer(context, enableHardwareDecoding, useGpuNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBackend.EXO_PLAYER,
|
||||||
|
PlayerBackend.UNRECOGNIZED,
|
||||||
|
-> {
|
||||||
|
val extensions = prefs.overrides.mediaExtensionsEnabled
|
||||||
|
val decodeAv1 = prefs.overrides.decodeAv1
|
||||||
|
Timber.v("extensions=$extensions")
|
||||||
|
val rendererMode =
|
||||||
|
when (extensions) {
|
||||||
|
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||||
|
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
}
|
||||||
|
ExoPlayer
|
||||||
|
.Builder(context)
|
||||||
|
.setRenderersFactory(
|
||||||
|
WholphinRenderersFactory(context, decodeAv1)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
.setExtensionRendererMode(rendererMode),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentPlayer = newPlayer
|
||||||
|
return newPlayer
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val Player.isReleased: Boolean
|
val Player.isReleased: Boolean
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
|
|
||||||
data class CurrentMediaInfo(
|
data class CurrentMediaInfo(
|
||||||
|
val sourceId: String?,
|
||||||
|
val videoStream: SimpleVideoStream?,
|
||||||
val audioStreams: List<SimpleMediaStream>,
|
val audioStreams: List<SimpleMediaStream>,
|
||||||
val subtitleStreams: List<SimpleMediaStream>,
|
val subtitleStreams: List<SimpleMediaStream>,
|
||||||
val chapters: List<Chapter>,
|
val chapters: List<Chapter>,
|
||||||
val trickPlayInfo: TrickplayInfo?,
|
val trickPlayInfo: TrickplayInfo?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null)
|
val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import androidx.media3.ui.compose.PlayerSurface
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
|
|
@ -66,7 +67,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
|
|
@ -99,16 +99,16 @@ fun PlaybackPage(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlaybackViewModel = hiltViewModel(),
|
viewModel: PlaybackViewModel =
|
||||||
|
hiltViewModel<PlaybackViewModel, PlaybackViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(destination) },
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
LifecycleStartEffect(destination) {
|
LifecycleStartEffect(destination) {
|
||||||
onStopOrDispose {
|
onStopOrDispose {
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(destination) {
|
|
||||||
viewModel.init(destination, preferences)
|
|
||||||
}
|
|
||||||
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
when (val st = loading) {
|
when (val st = loading) {
|
||||||
|
|
@ -123,467 +123,487 @@ fun PlaybackPage(
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
val prefs = preferences.appPreferences.playbackPreferences
|
val playerState by viewModel.currentPlayer.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
PlaybackPageContent(
|
||||||
val configuration = LocalConfiguration.current
|
player = playerState!!.player,
|
||||||
val density = LocalDensity.current
|
playerBackend = playerState!!.backend,
|
||||||
|
preferences = preferences,
|
||||||
val player = viewModel.player
|
destination = destination,
|
||||||
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
|
viewModel = viewModel,
|
||||||
val userDto by viewModel.currentUserDto.observeAsState()
|
modifier = modifier,
|
||||||
|
|
||||||
val currentPlayback by viewModel.currentPlayback.collectAsState()
|
|
||||||
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
|
||||||
ItemPlayback(
|
|
||||||
userId = -1,
|
|
||||||
itemId = UUID.randomUUID(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
}
|
||||||
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
@OptIn(UnstableApi::class)
|
||||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
@Composable
|
||||||
|
fun PlaybackPageContent(
|
||||||
|
player: Player,
|
||||||
|
playerBackend: PlayerBackend,
|
||||||
|
preferences: UserPreferences,
|
||||||
|
destination: Destination,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: PlaybackViewModel,
|
||||||
|
) {
|
||||||
|
val prefs = preferences.appPreferences.playbackPreferences
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
|
||||||
|
val userDto by viewModel.currentUserDto.observeAsState()
|
||||||
|
|
||||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
val currentPlayback by viewModel.currentPlayback.collectAsState()
|
||||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
||||||
|
ItemPlayback(
|
||||||
|
userId = -1,
|
||||||
|
itemId = UUID.randomUUID(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
||||||
|
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
||||||
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||||
OneTimeLaunchedEffect {
|
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||||
if (prefs.playerBackend == PlayerBackend.MPV) {
|
|
||||||
scope.launch(Dispatchers.IO + ExceptionHandler()) {
|
|
||||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
|
|
||||||
configuration,
|
|
||||||
density,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AmbientPlayerListener(player)
|
|
||||||
var contentScale by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
if (prefs.playerBackend == PlayerBackend.MPV) {
|
|
||||||
ContentScale.FillBounds
|
|
||||||
} else {
|
|
||||||
prefs.globalContentScale.scale
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
|
||||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
|
||||||
|
|
||||||
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
||||||
LaunchedEffect(subtitleDelay) {
|
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
||||||
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
|
||||||
}
|
|
||||||
|
|
||||||
val presentationState = rememberPresentationState(player, false)
|
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
||||||
val scaledModifier =
|
LaunchedEffect(player) {
|
||||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
if (playerBackend == PlayerBackend.MPV) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
scope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
val playPauseState = rememberPlayPauseButtonState(player)
|
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
|
||||||
val seekBarState = rememberSeekBarState(player, scope)
|
configuration,
|
||||||
|
density,
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
focusRequester.tryRequestFocus()
|
|
||||||
}
|
|
||||||
val controllerViewState = remember { viewModel.controllerViewState }
|
|
||||||
|
|
||||||
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
|
|
||||||
LaunchedEffect(controllerViewState.controlsVisible) {
|
|
||||||
// If controller shows/hides, immediately cancel the skip indicator
|
|
||||||
skipIndicatorDuration = 0L
|
|
||||||
}
|
|
||||||
var skipPosition by remember { mutableLongStateOf(0L) }
|
|
||||||
val updateSkipIndicator = { delta: Long ->
|
|
||||||
if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) {
|
|
||||||
skipIndicatorDuration = 0
|
|
||||||
}
|
|
||||||
skipIndicatorDuration += delta
|
|
||||||
skipPosition = player.currentPosition
|
|
||||||
}
|
|
||||||
val keyHandler =
|
|
||||||
PlaybackKeyHandler(
|
|
||||||
player = player,
|
|
||||||
controlsEnabled = nextUp == null,
|
|
||||||
skipWithLeftRight = true,
|
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
|
||||||
controllerViewState = controllerViewState,
|
|
||||||
updateSkipIndicator = updateSkipIndicator,
|
|
||||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
|
||||||
onInteraction = viewModel::reportInteraction,
|
|
||||||
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
|
|
||||||
onStop = {
|
|
||||||
player.stop()
|
|
||||||
viewModel.navigationManager.goBack()
|
|
||||||
},
|
|
||||||
onPlaybackDialogTypeClick = { playbackDialog = it },
|
|
||||||
)
|
|
||||||
|
|
||||||
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
|
|
||||||
when (it) {
|
|
||||||
is PlaybackAction.PlaybackSpeed -> {
|
|
||||||
playbackSpeed = it.value
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlaybackAction.Scale -> {
|
|
||||||
contentScale = it.scale
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackAction.ShowDebug -> {
|
|
||||||
showDebugInfo = !showDebugInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackAction.ShowPlaylist -> {
|
|
||||||
TODO()
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackAction.ShowVideoFilterDialog -> {
|
|
||||||
TODO()
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlaybackAction.ToggleAudio -> {
|
|
||||||
viewModel.changeAudioStream(it.index)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlaybackAction.ToggleCaptions -> {
|
|
||||||
viewModel.changeSubtitleStream(it.index)
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackAction.SearchCaptions -> {
|
|
||||||
controllerViewState.hideControls()
|
|
||||||
viewModel.searchForSubtitles()
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackAction.Next -> {
|
|
||||||
// TODO focus is lost
|
|
||||||
viewModel.playNextUp()
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackAction.Previous -> {
|
|
||||||
val pos = player.currentPosition
|
|
||||||
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
|
|
||||||
viewModel.playPrevious()
|
|
||||||
} else {
|
|
||||||
player.seekToPrevious()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val showSegment =
|
|
||||||
!segmentCancelled && currentSegment != null &&
|
|
||||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
|
||||||
BackHandler(showSegment) {
|
|
||||||
segmentCancelled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier
|
|
||||||
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
|
||||||
) {
|
|
||||||
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize(playerSize)
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.onKeyEvent(keyHandler::onKeyEvent)
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.focusable(),
|
|
||||||
) {
|
|
||||||
PlayerSurface(
|
|
||||||
player = player,
|
|
||||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
|
||||||
modifier = scaledModifier,
|
|
||||||
)
|
|
||||||
if (presentationState.coverSurface) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(Color.Black),
|
|
||||||
) {
|
|
||||||
LoadingPage(focusEnabled = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If D-pad skipping, show the amount skipped in an animation
|
|
||||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
|
||||||
SkipIndicator(
|
|
||||||
durationMs = skipIndicatorDuration,
|
|
||||||
onFinish = {
|
|
||||||
skipIndicatorDuration = 0L
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 70.dp),
|
|
||||||
)
|
|
||||||
// Show a small progress bar along the bottom of the screen
|
|
||||||
val showSkipProgress = true // TODO get from preferences
|
|
||||||
if (showSkipProgress) {
|
|
||||||
val percent = skipPosition.toFloat() / player.duration.toFloat()
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.background(MaterialTheme.colorScheme.border)
|
|
||||||
.clip(RectangleShape)
|
|
||||||
.height(3.dp)
|
|
||||||
.fillMaxWidth(percent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The playback controls
|
|
||||||
AnimatedVisibility(
|
|
||||||
controllerViewState.controlsVisible,
|
|
||||||
Modifier,
|
|
||||||
slideInVertically { it },
|
|
||||||
slideOutVertically { it },
|
|
||||||
) {
|
|
||||||
PlaybackOverlay(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(WindowInsets.systemBars.asPaddingValues())
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Transparent),
|
|
||||||
item = currentPlayback?.item,
|
|
||||||
playerControls = player,
|
|
||||||
controllerViewState = controllerViewState,
|
|
||||||
showPlay = playPauseState.showPlay,
|
|
||||||
previousEnabled = true,
|
|
||||||
nextEnabled = playlist.hasNext(),
|
|
||||||
seekEnabled = true,
|
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
|
||||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
|
||||||
onClickPlaybackDialogType = { playbackDialog = it },
|
|
||||||
onSeekBarChange = seekBarState::onValueChange,
|
|
||||||
showDebugInfo = showDebugInfo,
|
|
||||||
currentPlayback = currentPlayback,
|
|
||||||
chapters = mediaInfo?.chapters ?: listOf(),
|
|
||||||
trickplayInfo = mediaInfo?.trickPlayInfo,
|
|
||||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
|
||||||
playlist = playlist,
|
|
||||||
onClickPlaylist = {
|
|
||||||
viewModel.playItemInPlaylist(it)
|
|
||||||
},
|
|
||||||
currentSegment = currentSegment,
|
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtitles
|
|
||||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
|
||||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
|
||||||
SubtitleView(context).apply {
|
|
||||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
|
||||||
setStyle(it.toSubtitleStyle())
|
|
||||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
|
||||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
it.setCues(cues)
|
|
||||||
Media3SubtitleOverride(
|
|
||||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
|
||||||
.calculateEdgeSize(density),
|
|
||||||
).apply(it)
|
|
||||||
},
|
|
||||||
onReset = {
|
|
||||||
it.setCues(null)
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize(maxSize)
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.background(Color.Transparent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask to skip intros, etc button
|
|
||||||
AnimatedVisibility(
|
|
||||||
showSegment,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(40.dp)
|
|
||||||
.align(Alignment.BottomEnd),
|
|
||||||
) {
|
|
||||||
currentSegment?.let { segment ->
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
focusRequester.tryRequestFocus()
|
|
||||||
delay(10.seconds)
|
|
||||||
segmentCancelled = true
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
stringRes = segment.type.skipStringRes,
|
|
||||||
onClick = {
|
|
||||||
segmentCancelled = true
|
|
||||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
|
||||||
},
|
|
||||||
modifier = Modifier.focusRequester(focusRequester),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next up episode
|
|
||||||
BackHandler(nextUp != null) {
|
|
||||||
if (player.isPlaying) {
|
|
||||||
scope.launch(ExceptionHandler()) {
|
|
||||||
viewModel.cancelUpNextEpisode()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
viewModel.navigationManager.goBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AnimatedVisibility(
|
|
||||||
nextUp != null,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter),
|
|
||||||
) {
|
|
||||||
nextUp?.let {
|
|
||||||
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
|
|
||||||
var timeLeft by remember {
|
|
||||||
mutableLongStateOf(
|
|
||||||
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
BackHandler(timeLeft > 0 && autoPlayEnabled) {
|
|
||||||
timeLeft = -1
|
|
||||||
autoPlayEnabled = false
|
|
||||||
}
|
|
||||||
if (autoPlayEnabled) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (timeLeft == 0L) {
|
|
||||||
viewModel.playNextUp()
|
|
||||||
} else {
|
|
||||||
while (timeLeft > 0) {
|
|
||||||
delay(1.seconds)
|
|
||||||
timeLeft--
|
|
||||||
}
|
|
||||||
if (timeLeft == 0L && autoPlayEnabled) {
|
|
||||||
viewModel.playNextUp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NextUpEpisode(
|
|
||||||
title =
|
|
||||||
listOfNotNull(
|
|
||||||
it.data.seasonEpisode,
|
|
||||||
it.name,
|
|
||||||
).joinToString(" - "),
|
|
||||||
description = it.data.overview,
|
|
||||||
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
|
|
||||||
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
|
||||||
onClick = {
|
|
||||||
viewModel.reportInteraction()
|
|
||||||
controllerViewState.hideControls()
|
|
||||||
viewModel.playNextUp()
|
|
||||||
},
|
|
||||||
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
// .height(128.dp)
|
|
||||||
.fillMaxHeight(1 - playerSize)
|
|
||||||
.fillMaxWidth(.66f)
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subtitleSearch?.let { state ->
|
|
||||||
val wasPlaying = remember { player.isPlaying }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
val onDismissRequest = {
|
|
||||||
if (wasPlaying) {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
viewModel.cancelSubtitleSearch()
|
|
||||||
}
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
properties =
|
|
||||||
DialogProperties(
|
|
||||||
usePlatformDefaultWidth = false,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
DownloadSubtitlesContent(
|
|
||||||
state = state,
|
|
||||||
language = subtitleSearchLanguage,
|
|
||||||
onSearch = { lang ->
|
|
||||||
viewModel.searchForSubtitles(lang)
|
|
||||||
},
|
|
||||||
onClickDownload = {
|
|
||||||
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
|
|
||||||
},
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.widthIn(max = 640.dp)
|
|
||||||
.heightIn(max = 400.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playbackDialog?.let { type ->
|
|
||||||
PlaybackDialog(
|
|
||||||
type = type,
|
|
||||||
settings =
|
|
||||||
PlaybackSettings(
|
|
||||||
showDebugInfo = showDebugInfo,
|
|
||||||
audioIndex = currentItemPlayback?.audioIndex,
|
|
||||||
audioStreams = mediaInfo?.audioStreams.orEmpty(),
|
|
||||||
subtitleIndex = currentItemPlayback?.subtitleIndex,
|
|
||||||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
|
||||||
playbackSpeed = playbackSpeed,
|
|
||||||
contentScale = contentScale,
|
|
||||||
subtitleDelay = subtitleDelay,
|
|
||||||
hasSubtitleDownloadPermission =
|
|
||||||
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
|
||||||
),
|
|
||||||
onDismissRequest = {
|
|
||||||
playbackDialog = null
|
|
||||||
if (controllerViewState.controlsVisible) {
|
|
||||||
controllerViewState.pulseControls()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onControllerInteraction = {
|
|
||||||
controllerViewState.pulseControls(Long.MAX_VALUE)
|
|
||||||
},
|
|
||||||
onClickPlaybackDialogType = {
|
|
||||||
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
|
|
||||||
// Hide controls so subtitles are fully visible
|
|
||||||
controllerViewState.hideControls()
|
|
||||||
}
|
|
||||||
playbackDialog = it
|
|
||||||
},
|
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
|
||||||
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
|
||||||
enableSubtitleDelay = player is MpvPlayer,
|
|
||||||
enableVideoScale = player !is MpvPlayer,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AmbientPlayerListener(player)
|
||||||
|
var contentScale by remember(playerBackend) {
|
||||||
|
mutableStateOf(
|
||||||
|
if (playerBackend == PlayerBackend.MPV) {
|
||||||
|
ContentScale.FillBounds
|
||||||
|
} else {
|
||||||
|
prefs.globalContentScale.scale
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
|
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||||
|
|
||||||
|
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||||
|
LaunchedEffect(subtitleDelay) {
|
||||||
|
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
val presentationState = rememberPresentationState(player, false)
|
||||||
|
val scaledModifier =
|
||||||
|
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val playPauseState = rememberPlayPauseButtonState(player)
|
||||||
|
val seekBarState = rememberSeekBarState(player, scope)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
val controllerViewState = remember { viewModel.controllerViewState }
|
||||||
|
|
||||||
|
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
|
||||||
|
LaunchedEffect(controllerViewState.controlsVisible) {
|
||||||
|
// If controller shows/hides, immediately cancel the skip indicator
|
||||||
|
skipIndicatorDuration = 0L
|
||||||
|
}
|
||||||
|
var skipPosition by remember { mutableLongStateOf(0L) }
|
||||||
|
val updateSkipIndicator = { delta: Long ->
|
||||||
|
if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) {
|
||||||
|
skipIndicatorDuration = 0
|
||||||
|
}
|
||||||
|
skipIndicatorDuration += delta
|
||||||
|
skipPosition = player.currentPosition
|
||||||
|
}
|
||||||
|
val keyHandler =
|
||||||
|
PlaybackKeyHandler(
|
||||||
|
player = player,
|
||||||
|
controlsEnabled = nextUp == null,
|
||||||
|
skipWithLeftRight = true,
|
||||||
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
updateSkipIndicator = updateSkipIndicator,
|
||||||
|
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||||
|
onInteraction = viewModel::reportInteraction,
|
||||||
|
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
|
||||||
|
onStop = {
|
||||||
|
player.stop()
|
||||||
|
viewModel.navigationManager.goBack()
|
||||||
|
},
|
||||||
|
onPlaybackDialogTypeClick = { playbackDialog = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
|
||||||
|
when (it) {
|
||||||
|
is PlaybackAction.PlaybackSpeed -> {
|
||||||
|
playbackSpeed = it.value
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.Scale -> {
|
||||||
|
contentScale = it.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.ShowDebug -> {
|
||||||
|
showDebugInfo = !showDebugInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.ShowPlaylist -> {
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.ShowVideoFilterDialog -> {
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.ToggleAudio -> {
|
||||||
|
viewModel.changeAudioStream(it.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.ToggleCaptions -> {
|
||||||
|
viewModel.changeSubtitleStream(it.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.SearchCaptions -> {
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
viewModel.searchForSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.Next -> {
|
||||||
|
// TODO focus is lost
|
||||||
|
viewModel.playNextUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.Previous -> {
|
||||||
|
val pos = player.currentPosition
|
||||||
|
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
|
||||||
|
viewModel.playPrevious()
|
||||||
|
} else {
|
||||||
|
player.seekToPrevious()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val showSegment =
|
||||||
|
!segmentCancelled && currentSegment != null &&
|
||||||
|
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||||
|
BackHandler(showSegment) {
|
||||||
|
segmentCancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
|
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(playerSize)
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.onKeyEvent(keyHandler::onKeyEvent)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusable(),
|
||||||
|
) {
|
||||||
|
PlayerSurface(
|
||||||
|
player = player,
|
||||||
|
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||||
|
modifier = scaledModifier,
|
||||||
|
)
|
||||||
|
if (presentationState.coverSurface) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
LoadingPage(focusEnabled = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If D-pad skipping, show the amount skipped in an animation
|
||||||
|
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
||||||
|
SkipIndicator(
|
||||||
|
durationMs = skipIndicatorDuration,
|
||||||
|
onFinish = {
|
||||||
|
skipIndicatorDuration = 0L
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 70.dp),
|
||||||
|
)
|
||||||
|
// Show a small progress bar along the bottom of the screen
|
||||||
|
val showSkipProgress = true // TODO get from preferences
|
||||||
|
if (showSkipProgress) {
|
||||||
|
val percent = skipPosition.toFloat() / player.duration.toFloat()
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.background(MaterialTheme.colorScheme.border)
|
||||||
|
.clip(RectangleShape)
|
||||||
|
.height(3.dp)
|
||||||
|
.fillMaxWidth(percent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The playback controls
|
||||||
|
AnimatedVisibility(
|
||||||
|
controllerViewState.controlsVisible,
|
||||||
|
Modifier,
|
||||||
|
slideInVertically { it },
|
||||||
|
slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
PlaybackOverlay(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Transparent),
|
||||||
|
item = currentPlayback?.item,
|
||||||
|
playerControls = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
showPlay = playPauseState.showPlay,
|
||||||
|
previousEnabled = true,
|
||||||
|
nextEnabled = playlist.hasNext(),
|
||||||
|
seekEnabled = true,
|
||||||
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
onClickPlaybackDialogType = { playbackDialog = it },
|
||||||
|
onSeekBarChange = seekBarState::onValueChange,
|
||||||
|
showDebugInfo = showDebugInfo,
|
||||||
|
currentPlayback = currentPlayback,
|
||||||
|
chapters = mediaInfo?.chapters ?: listOf(),
|
||||||
|
trickplayInfo = mediaInfo?.trickPlayInfo,
|
||||||
|
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||||
|
playlist = playlist,
|
||||||
|
onClickPlaylist = {
|
||||||
|
viewModel.playItemInPlaylist(it)
|
||||||
|
},
|
||||||
|
currentSegment = currentSegment,
|
||||||
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitles
|
||||||
|
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||||
|
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
SubtitleView(context).apply {
|
||||||
|
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
||||||
|
setStyle(it.toSubtitleStyle())
|
||||||
|
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||||
|
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = {
|
||||||
|
it.setCues(cues)
|
||||||
|
Media3SubtitleOverride(
|
||||||
|
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||||
|
.calculateEdgeSize(density),
|
||||||
|
).apply(it)
|
||||||
|
},
|
||||||
|
onReset = {
|
||||||
|
it.setCues(null)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(maxSize)
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.background(Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask to skip intros, etc button
|
||||||
|
AnimatedVisibility(
|
||||||
|
showSegment,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(40.dp)
|
||||||
|
.align(Alignment.BottomEnd),
|
||||||
|
) {
|
||||||
|
currentSegment?.let { segment ->
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
delay(10.seconds)
|
||||||
|
segmentCancelled = true
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
stringRes = segment.type.skipStringRes,
|
||||||
|
onClick = {
|
||||||
|
segmentCancelled = true
|
||||||
|
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next up episode
|
||||||
|
BackHandler(nextUp != null) {
|
||||||
|
if (player.isPlaying) {
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
viewModel.cancelUpNextEpisode()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModel.navigationManager.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
nextUp != null,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter),
|
||||||
|
) {
|
||||||
|
nextUp?.let {
|
||||||
|
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
|
||||||
|
var timeLeft by remember {
|
||||||
|
mutableLongStateOf(
|
||||||
|
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BackHandler(timeLeft > 0 && autoPlayEnabled) {
|
||||||
|
timeLeft = -1
|
||||||
|
autoPlayEnabled = false
|
||||||
|
}
|
||||||
|
if (autoPlayEnabled) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (timeLeft == 0L) {
|
||||||
|
viewModel.playNextUp()
|
||||||
|
} else {
|
||||||
|
while (timeLeft > 0) {
|
||||||
|
delay(1.seconds)
|
||||||
|
timeLeft--
|
||||||
|
}
|
||||||
|
if (timeLeft == 0L && autoPlayEnabled) {
|
||||||
|
viewModel.playNextUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NextUpEpisode(
|
||||||
|
title =
|
||||||
|
listOfNotNull(
|
||||||
|
it.data.seasonEpisode,
|
||||||
|
it.name,
|
||||||
|
).joinToString(" - "),
|
||||||
|
description = it.data.overview,
|
||||||
|
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
|
||||||
|
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
||||||
|
onClick = {
|
||||||
|
viewModel.reportInteraction()
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
viewModel.playNextUp()
|
||||||
|
},
|
||||||
|
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
// .height(128.dp)
|
||||||
|
.fillMaxHeight(1 - playerSize)
|
||||||
|
.fillMaxWidth(.66f)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleSearch?.let { state ->
|
||||||
|
val wasPlaying = remember { player.isPlaying }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
val onDismissRequest = {
|
||||||
|
if (wasPlaying) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
viewModel.cancelSubtitleSearch()
|
||||||
|
}
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties =
|
||||||
|
DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DownloadSubtitlesContent(
|
||||||
|
state = state,
|
||||||
|
language = subtitleSearchLanguage,
|
||||||
|
onSearch = { lang ->
|
||||||
|
viewModel.searchForSubtitles(lang)
|
||||||
|
},
|
||||||
|
onClickDownload = {
|
||||||
|
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.heightIn(max = 400.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackDialog?.let { type ->
|
||||||
|
PlaybackDialog(
|
||||||
|
type = type,
|
||||||
|
settings =
|
||||||
|
PlaybackSettings(
|
||||||
|
showDebugInfo = showDebugInfo,
|
||||||
|
audioIndex = currentItemPlayback?.audioIndex,
|
||||||
|
audioStreams = mediaInfo?.audioStreams.orEmpty(),
|
||||||
|
subtitleIndex = currentItemPlayback?.subtitleIndex,
|
||||||
|
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
|
contentScale = contentScale,
|
||||||
|
subtitleDelay = subtitleDelay,
|
||||||
|
hasSubtitleDownloadPermission =
|
||||||
|
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
||||||
|
),
|
||||||
|
onDismissRequest = {
|
||||||
|
playbackDialog = null
|
||||||
|
if (controllerViewState.controlsVisible) {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onControllerInteraction = {
|
||||||
|
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||||
|
},
|
||||||
|
onClickPlaybackDialogType = {
|
||||||
|
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
|
||||||
|
// Hide controls so subtitles are fully visible
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
}
|
||||||
|
playbackDialog = it
|
||||||
|
},
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||||
|
enableSubtitleDelay = player is MpvPlayer,
|
||||||
|
enableVideoScale = player !is MpvPlayer,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -58,7 +59,6 @@ import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||||
import com.github.damontecres.wholphin.util.checkForSupport
|
import com.github.damontecres.wholphin.util.checkForSupport
|
||||||
|
|
@ -66,6 +66,9 @@ import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
|
||||||
import com.github.damontecres.wholphin.util.profile.Codec
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
import com.github.damontecres.wholphin.util.subtitleMimeTypes
|
import com.github.damontecres.wholphin.util.subtitleMimeTypes
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
@ -100,13 +103,14 @@ import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
|
import org.jellyfin.sdk.model.api.VideoRange
|
||||||
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -114,10 +118,10 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
/**
|
/**
|
||||||
* This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes)
|
* This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes)
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = PlaybackViewModel.Factory::class)
|
||||||
@OptIn(markerClass = [UnstableApi::class])
|
@OptIn(markerClass = [UnstableApi::class])
|
||||||
class PlaybackViewModel
|
class PlaybackViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext internal val context: Context,
|
@param:ApplicationContext internal val context: Context,
|
||||||
internal val api: ApiClient,
|
internal val api: ApiClient,
|
||||||
|
|
@ -132,12 +136,20 @@ class PlaybackViewModel
|
||||||
private val deviceProfileService: DeviceProfileService,
|
private val deviceProfileService: DeviceProfileService,
|
||||||
private val refreshRateService: RefreshRateService,
|
private val refreshRateService: RefreshRateService,
|
||||||
val streamChoiceService: StreamChoiceService,
|
val streamChoiceService: StreamChoiceService,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
@Assisted private val destination: Destination,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
AnalyticsListener {
|
AnalyticsListener {
|
||||||
val player by lazy {
|
@AssistedFactory
|
||||||
playerFactory.createVideoPlayer()
|
interface Factory {
|
||||||
|
fun create(destination: Destination): PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentPlayer = MutableStateFlow<PlayerState?>(null)
|
||||||
|
|
||||||
|
internal lateinit var player: Player
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
internal val mutex = Mutex()
|
internal val mutex = Mutex()
|
||||||
|
|
||||||
|
|
@ -174,7 +186,14 @@ class PlaybackViewModel
|
||||||
val currentUserDto = serverRepository.currentUserDto
|
val currentUserDto = serverRepository.currentUserDto
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addCloseable {
|
viewModelScope.launchIO {
|
||||||
|
addCloseable { disconnectPlayer() }
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnectPlayer() {
|
||||||
|
if (this@PlaybackViewModel::player.isInitialized) {
|
||||||
player.removeListener(this@PlaybackViewModel)
|
player.removeListener(this@PlaybackViewModel)
|
||||||
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)
|
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)
|
||||||
|
|
||||||
|
|
@ -182,26 +201,62 @@ class PlaybackViewModel
|
||||||
it.release()
|
it.release()
|
||||||
player.removeListener(it)
|
player.removeListener(it)
|
||||||
}
|
}
|
||||||
jobs.forEach { it.cancel() }
|
|
||||||
player.release()
|
player.release()
|
||||||
mediaSession?.release()
|
mediaSession?.release()
|
||||||
}
|
}
|
||||||
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
jobs.forEach { it.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createPlayer(isHdr: Boolean) {
|
||||||
|
val playerBackend =
|
||||||
|
when (preferences.appPreferences.playbackPreferences.playerBackend) {
|
||||||
|
PlayerBackend.UNRECOGNIZED,
|
||||||
|
PlayerBackend.EXO_PLAYER,
|
||||||
|
-> PlayerBackend.EXO_PLAYER
|
||||||
|
|
||||||
|
PlayerBackend.MPV -> PlayerBackend.MPV
|
||||||
|
|
||||||
|
PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.i("Selected backend: %s", playerBackend)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
disconnectPlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
player =
|
||||||
|
playerFactory.createVideoPlayer(
|
||||||
|
playerBackend,
|
||||||
|
preferences.appPreferences.playbackPreferences,
|
||||||
|
)
|
||||||
|
currentPlayer.update {
|
||||||
|
PlayerState(player, playerBackend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configurePlayer() {
|
||||||
player.addListener(this)
|
player.addListener(this)
|
||||||
(player as? ExoPlayer)?.addAnalyticsListener(this)
|
(player as? ExoPlayer)?.addAnalyticsListener(this)
|
||||||
jobs.add(subscribe())
|
jobs.add(subscribe())
|
||||||
jobs.add(listenForTranscodeReason())
|
jobs.add(listenForTranscodeReason())
|
||||||
|
val sessionPlayer =
|
||||||
|
MediaSessionPlayer(
|
||||||
|
player,
|
||||||
|
controllerViewState,
|
||||||
|
preferences.appPreferences.playbackPreferences,
|
||||||
|
)
|
||||||
|
mediaSession =
|
||||||
|
MediaSession
|
||||||
|
.Builder(context, sessionPlayer)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize from the UI to start playback
|
* Initialize from the UI to start playback
|
||||||
*/
|
*/
|
||||||
fun init(
|
private suspend fun init() {
|
||||||
destination: Destination,
|
nextUp.setValueOnMain(null)
|
||||||
preferences: UserPreferences,
|
this.preferences = userPreferencesService.getCurrent()
|
||||||
) {
|
|
||||||
nextUp.value = null
|
|
||||||
this.preferences = preferences
|
|
||||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||||
addCloseable { refreshRateService.resetRefreshRate() }
|
addCloseable { refreshRateService.resetRefreshRate() }
|
||||||
}
|
}
|
||||||
|
|
@ -234,82 +289,64 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.itemId = itemId
|
this.itemId = itemId
|
||||||
viewModelScope.launch(
|
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||||
Dispatchers.IO +
|
val base =
|
||||||
LoadingExceptionHandler(
|
if (queriedItem.type.playable) {
|
||||||
loading,
|
queriedItem
|
||||||
"Error preparing for playback for $itemId",
|
} else if (destination is Destination.PlaybackList) {
|
||||||
),
|
isPlaylist = true
|
||||||
) {
|
val playlistResult =
|
||||||
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
playlistCreator.createFrom(
|
||||||
val base =
|
item = queriedItem,
|
||||||
if (queriedItem.type.playable) {
|
startIndex = destination.startIndex ?: 0,
|
||||||
queriedItem
|
sortAndDirection = destination.sortAndDirection,
|
||||||
} else if (destination is Destination.PlaybackList) {
|
shuffled = destination.shuffle,
|
||||||
isPlaylist = true
|
recursive = destination.recursive,
|
||||||
val playlistResult =
|
filter = destination.filter,
|
||||||
playlistCreator.createFrom(
|
)
|
||||||
item = queriedItem,
|
when (val r = playlistResult) {
|
||||||
startIndex = destination.startIndex ?: 0,
|
is PlaylistCreationResult.Error -> {
|
||||||
sortAndDirection = destination.sortAndDirection,
|
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
|
||||||
shuffled = destination.shuffle,
|
return
|
||||||
recursive = destination.recursive,
|
}
|
||||||
filter = destination.filter,
|
|
||||||
)
|
is PlaylistCreationResult.Success -> {
|
||||||
when (val r = playlistResult) {
|
if (r.playlist.items.isEmpty()) {
|
||||||
is PlaylistCreationResult.Error -> {
|
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
|
||||||
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
|
navigationManager.goBack()
|
||||||
return@launch
|
return
|
||||||
}
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
is PlaylistCreationResult.Success -> {
|
this@PlaybackViewModel.playlist.value = r.playlist
|
||||||
if (r.playlist.items.isEmpty()) {
|
}
|
||||||
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
|
r.playlist.items
|
||||||
navigationManager.goBack()
|
.first()
|
||||||
return@launch
|
.data
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
this@PlaybackViewModel.playlist.value = r.playlist
|
|
||||||
}
|
|
||||||
r.playlist.items
|
|
||||||
.first()
|
|
||||||
.data
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
val sessionPlayer =
|
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
||||||
MediaSessionPlayer(
|
|
||||||
player,
|
|
||||||
controllerViewState,
|
|
||||||
preferences.appPreferences.playbackPreferences,
|
|
||||||
)
|
|
||||||
mediaSession =
|
|
||||||
MediaSession
|
|
||||||
.Builder(context, sessionPlayer)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val item = BaseItem.from(base, api)
|
|
||||||
|
|
||||||
val played =
|
|
||||||
play(
|
|
||||||
item,
|
|
||||||
positionMs,
|
|
||||||
itemPlayback,
|
|
||||||
forceTranscoding,
|
|
||||||
)
|
|
||||||
if (!played) {
|
|
||||||
playNextUp()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlaylist) {
|
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
||||||
val result = playlistCreator.createFrom(queriedItem)
|
|
||||||
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
val item = BaseItem.from(base, api)
|
||||||
withContext(Dispatchers.Main) {
|
val played =
|
||||||
this@PlaybackViewModel.playlist.value = result.playlist
|
play(
|
||||||
}
|
item,
|
||||||
|
positionMs,
|
||||||
|
itemPlayback,
|
||||||
|
forceTranscoding,
|
||||||
|
)
|
||||||
|
if (!played) {
|
||||||
|
playNextUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlaylist) {
|
||||||
|
val result = playlistCreator.createFrom(queriedItem)
|
||||||
|
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@PlaybackViewModel.playlist.value = result.playlist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -380,6 +417,20 @@ class PlaybackViewModel
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val videoStream =
|
||||||
|
mediaSource.mediaStreams
|
||||||
|
?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||||
|
?.let {
|
||||||
|
val isHdr =
|
||||||
|
it.videoRange == VideoRange.HDR ||
|
||||||
|
(it.videoRangeType != VideoRangeType.SDR && it.videoRangeType != VideoRangeType.UNKNOWN)
|
||||||
|
SimpleVideoStream(it.index, isHdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the correct player for the media
|
||||||
|
createPlayer(videoStream?.hdr == true)
|
||||||
|
configurePlayer()
|
||||||
|
|
||||||
val subtitleStreams =
|
val subtitleStreams =
|
||||||
mediaSource.mediaStreams
|
mediaSource.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
|
@ -450,6 +501,8 @@ class PlaybackViewModel
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
|
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
|
||||||
updateCurrentMedia {
|
updateCurrentMedia {
|
||||||
CurrentMediaInfo(
|
CurrentMediaInfo(
|
||||||
|
sourceId = mediaSource.id,
|
||||||
|
videoStream = videoStream,
|
||||||
audioStreams = audioStreams,
|
audioStreams = audioStreams,
|
||||||
subtitleStreams = subtitleStreams,
|
subtitleStreams = subtitleStreams,
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
|
|
@ -489,71 +542,18 @@ class PlaybackViewModel
|
||||||
enableDirectStream: Boolean = !this.forceTranscoding,
|
enableDirectStream: Boolean = !this.forceTranscoding,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
val itemId = item.id
|
val itemId = item.id
|
||||||
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
|
|
||||||
|
|
||||||
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
|
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
|
||||||
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
|
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
|
||||||
// If direct playing, can try to switch tracks without playback restarting
|
val wasSuccessful =
|
||||||
// Except for external subtitles
|
changeStreamsDirectPlay(
|
||||||
// TODO there's probably no reason why we can't add external subtitles?
|
currentPlayback = currentPlayback,
|
||||||
Timber.v("changeStreams direct play")
|
currentItemPlayback = currentItemPlayback,
|
||||||
|
audioIndex = audioIndex,
|
||||||
val source = currentPlayback.mediaSourceInfo
|
subtitleIndex = subtitleIndex,
|
||||||
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
|
userInitiated = userInitiated,
|
||||||
|
)
|
||||||
if (externalSubtitle == null) {
|
if (wasSuccessful) return@withContext
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
TrackSelectionUtils.createTrackSelections(
|
|
||||||
onMain { player.trackSelectionParameters },
|
|
||||||
onMain { player.currentTracks },
|
|
||||||
playerBackend,
|
|
||||||
true,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (result.bothSelected) {
|
|
||||||
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
|
|
||||||
// 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,
|
|
||||||
// Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
|
|
||||||
subtitleIndex =
|
|
||||||
if (currentItemPlayback.subtitleIndex < 0) {
|
|
||||||
currentItemPlayback.subtitleIndex
|
|
||||||
} else {
|
|
||||||
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.update {
|
|
||||||
(it ?: currentPlayback).copy(
|
|
||||||
tracks = checkForSupport(player.currentTracks),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
|
||||||
}
|
|
||||||
loadSubtitleDelay()
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Timber.v("changeStreams direct play, external subtitle was requested")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.d(
|
Timber.d(
|
||||||
|
|
@ -571,7 +571,7 @@ class PlaybackViewModel
|
||||||
PlaybackInfoDto(
|
PlaybackInfoDto(
|
||||||
startTimeTicks = null,
|
startTimeTicks = null,
|
||||||
deviceProfile =
|
deviceProfile =
|
||||||
if (playerBackend == PlayerBackend.EXO_PLAYER) {
|
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
|
||||||
deviceProfileService.getOrCreateDeviceProfile(
|
deviceProfileService.getOrCreateDeviceProfile(
|
||||||
preferences.appPreferences.playbackPreferences,
|
preferences.appPreferences.playbackPreferences,
|
||||||
serverRepository.currentServer.value?.serverVersion,
|
serverRepository.currentServer.value?.serverVersion,
|
||||||
|
|
@ -676,7 +676,7 @@ class PlaybackViewModel
|
||||||
CurrentPlayback(
|
CurrentPlayback(
|
||||||
item = item,
|
item = item,
|
||||||
tracks = listOf(),
|
tracks = listOf(),
|
||||||
backend = preferences.appPreferences.playbackPreferences.playerBackend,
|
backend = currentPlayer.value!!.backend,
|
||||||
playMethod = transcodeType,
|
playMethod = transcodeType,
|
||||||
playSessionId = response.playSessionId,
|
playSessionId = response.playSessionId,
|
||||||
liveStreamId = source.liveStreamId,
|
liveStreamId = source.liveStreamId,
|
||||||
|
|
@ -727,7 +727,7 @@ class PlaybackViewModel
|
||||||
TrackSelectionUtils.createTrackSelections(
|
TrackSelectionUtils.createTrackSelections(
|
||||||
player.trackSelectionParameters,
|
player.trackSelectionParameters,
|
||||||
player.currentTracks,
|
player.currentTracks,
|
||||||
playerBackend,
|
currentPlayer.value!!.backend,
|
||||||
source.supportsDirectPlay,
|
source.supportsDirectPlay,
|
||||||
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
|
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
|
|
@ -748,6 +748,81 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If direct playing, can try to switch tracks without playback restarting
|
||||||
|
* Except for external subtitles
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private suspend fun changeStreamsDirectPlay(
|
||||||
|
currentPlayback: CurrentPlayback,
|
||||||
|
currentItemPlayback: ItemPlayback,
|
||||||
|
audioIndex: Int?,
|
||||||
|
subtitleIndex: Int?,
|
||||||
|
userInitiated: Boolean,
|
||||||
|
): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// 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) {
|
||||||
|
TrackSelectionUtils.createTrackSelections(
|
||||||
|
onMain { player.trackSelectionParameters },
|
||||||
|
onMain { player.currentTracks },
|
||||||
|
currentPlayer.value!!.backend,
|
||||||
|
true,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result.bothSelected) {
|
||||||
|
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
|
||||||
|
// 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,
|
||||||
|
// Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
|
||||||
|
subtitleIndex =
|
||||||
|
if (currentItemPlayback.subtitleIndex < 0) {
|
||||||
|
currentItemPlayback.subtitleIndex
|
||||||
|
} else {
|
||||||
|
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.update {
|
||||||
|
(it ?: currentPlayback).copy(
|
||||||
|
tracks = checkForSupport(player.currentTracks),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
||||||
|
}
|
||||||
|
loadSubtitleDelay()
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.v("changeStreams direct play, external subtitle was requested")
|
||||||
|
}
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
fun changeAudioStream(index: Int) {
|
fun changeAudioStream(index: Int) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
Timber.d("Changing audio track to %s", index)
|
Timber.d("Changing audio track to %s", index)
|
||||||
|
|
@ -1114,9 +1189,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
Timber.v("release")
|
Timber.v("release")
|
||||||
activityListener?.release()
|
disconnectPlayer()
|
||||||
player.release()
|
|
||||||
mediaSession?.release()
|
|
||||||
activityListener = null
|
activityListener = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1292,3 +1365,8 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class PlayerState(
|
||||||
|
val player: Player,
|
||||||
|
val backend: PlayerBackend,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,8 @@ data class SimpleMediaStream(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SimpleVideoStream(
|
||||||
|
val index: Int,
|
||||||
|
val hdr: Boolean,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,9 @@ object TrackSelectionUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO MPV could use literal indexes because they are stored in the track format ID
|
// TODO MPV could use literal indexes because they are stored in the track format ID
|
||||||
PlayerBackend.MPV -> {
|
PlayerBackend.PREFER_MPV,
|
||||||
|
PlayerBackend.MPV,
|
||||||
|
-> {
|
||||||
when (type) {
|
when (type) {
|
||||||
MediaStreamType.VIDEO -> {
|
MediaStreamType.VIDEO -> {
|
||||||
serverIndex - externalSubtitleCount + 1
|
serverIndex - externalSubtitleCount + 1
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
|
@ -37,6 +38,7 @@ import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -171,6 +173,8 @@ fun <T> ComposablePreference(
|
||||||
|
|
||||||
is AppChoicePreference -> {
|
is AppChoicePreference -> {
|
||||||
val values = stringArrayResource(preference.displayValues).toList()
|
val values = stringArrayResource(preference.displayValues).toList()
|
||||||
|
val subtitles =
|
||||||
|
preference.subtitles?.let { stringArrayResource(preference.subtitles).toList() }
|
||||||
val summary =
|
val summary =
|
||||||
preference.summary?.let { stringResource(it) }
|
preference.summary?.let { stringResource(it) }
|
||||||
?: preference.summary(context, value)
|
?: preference.summary(context, value)
|
||||||
|
|
@ -188,24 +192,31 @@ fun <T> ComposablePreference(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
items =
|
items =
|
||||||
values.mapIndexed { index, it ->
|
values.mapIndexed { index, it ->
|
||||||
if (index == selectedIndex) {
|
DialogItem(
|
||||||
DialogItem(
|
headlineContent = {
|
||||||
text = it,
|
Text(it)
|
||||||
icon = Icons.Default.Done,
|
},
|
||||||
onClick = {
|
leadingContent = {
|
||||||
onValueChange(preference.indexToValue(index))
|
if (index == selectedIndex) {
|
||||||
dialogParams = null
|
Icon(
|
||||||
},
|
imageVector = Icons.Default.Done,
|
||||||
)
|
contentDescription = "selected",
|
||||||
} else {
|
)
|
||||||
DialogItem(
|
}
|
||||||
text = it,
|
},
|
||||||
onClick = {
|
supportingContent = {
|
||||||
onValueChange(preference.indexToValue(index))
|
subtitles?.let {
|
||||||
dialogParams = null
|
val text = subtitles[index]
|
||||||
},
|
if (text.isNotNullOrBlank()) {
|
||||||
)
|
Text(text)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onValueChange(preference.indexToValue(index))
|
||||||
|
dialogParams = null
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ enum class PreferenceScreenOption {
|
||||||
ADVANCED,
|
ADVANCED,
|
||||||
USER_INTERFACE,
|
USER_INTERFACE,
|
||||||
SUBTITLES,
|
SUBTITLES,
|
||||||
|
EXO_PLAYER,
|
||||||
|
MPV,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||||
|
|
@ -125,6 +127,8 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.ADVANCED -> advancedPreferences
|
PreferenceScreenOption.ADVANCED -> advancedPreferences
|
||||||
PreferenceScreenOption.USER_INTERFACE -> uiPreferences
|
PreferenceScreenOption.USER_INTERFACE -> uiPreferences
|
||||||
PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
|
PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
|
||||||
|
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
||||||
|
PreferenceScreenOption.MPV -> MpvPreferences
|
||||||
}
|
}
|
||||||
val screenTitle =
|
val screenTitle =
|
||||||
when (preferenceScreenOption) {
|
when (preferenceScreenOption) {
|
||||||
|
|
@ -132,6 +136,8 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
|
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
|
||||||
PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface
|
PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface
|
||||||
PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style
|
PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style
|
||||||
|
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
||||||
|
PreferenceScreenOption.MPV -> R.string.mpv_options
|
||||||
}
|
}
|
||||||
|
|
||||||
var visible by remember { mutableStateOf(false) }
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
|
@ -527,6 +533,8 @@ fun PreferencesPage(
|
||||||
PreferenceScreenOption.BASIC,
|
PreferenceScreenOption.BASIC,
|
||||||
PreferenceScreenOption.ADVANCED,
|
PreferenceScreenOption.ADVANCED,
|
||||||
PreferenceScreenOption.USER_INTERFACE,
|
PreferenceScreenOption.USER_INTERFACE,
|
||||||
|
PreferenceScreenOption.EXO_PLAYER,
|
||||||
|
PreferenceScreenOption.MPV,
|
||||||
-> {
|
-> {
|
||||||
PreferencesContent(
|
PreferencesContent(
|
||||||
initialPreferences,
|
initialPreferences,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ enum MediaExtensionStatus{
|
||||||
enum PlayerBackend{
|
enum PlayerBackend{
|
||||||
EXO_PLAYER = 0;
|
EXO_PLAYER = 0;
|
||||||
MPV = 1;
|
MPV = 1;
|
||||||
|
PREFER_MPV = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MpvOptions{
|
message MpvOptions{
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,7 @@
|
||||||
<string name="upcoming_tv">Upcoming TV Shows</string>
|
<string name="upcoming_tv">Upcoming TV Shows</string>
|
||||||
<string name="request_4k">Request in 4K</string>
|
<string name="request_4k">Request in 4K</string>
|
||||||
<string name="software_decoding_av1">AV1 software decoding</string>
|
<string name="software_decoding_av1">AV1 software decoding</string>
|
||||||
|
<string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string>
|
||||||
|
|
||||||
<string-array name="theme_song_volume">
|
<string-array name="theme_song_volume">
|
||||||
<item>Disabled</item>
|
<item>Disabled</item>
|
||||||
|
|
@ -532,8 +533,15 @@
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string-array name="player_backend_options">
|
<string-array name="player_backend_options">
|
||||||
<item>ExoPlayer (default)</item>
|
<item>ExoPlayer</item>
|
||||||
<item>MPV (Experimental)</item>
|
<item>MPV</item>
|
||||||
|
<item>Prefer MPV</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="player_backend_options_subtitles">
|
||||||
|
<item />
|
||||||
|
<item />
|
||||||
|
<item>Use ExoPlayer for HDR playback</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string-array name="aspect_ratios">
|
<string-array name="aspect_ratios">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue