Merge branch 'main' into develop/jellyseerr

This commit is contained in:
Damontecres 2026-01-05 16:03:25 -05:00
commit 8ff530a4f9
No known key found for this signature in database
19 changed files with 415 additions and 67 deletions

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@ -11,22 +12,25 @@ import java.util.UUID
@Dao
interface ItemPlaybackDao {
fun getItem(
suspend fun getItem(
user: JellyfinUser,
itemId: UUID,
): ItemPlayback? = getItem(user.rowId, itemId)
@Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId")
fun getItem(
suspend fun getItem(
userId: Int,
itemId: UUID,
): ItemPlayback?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveItem(item: ItemPlayback): Long
suspend fun saveItem(item: ItemPlayback): Long
@Delete
suspend fun deleteItem(item: ItemPlayback)
@Query("SELECT * from ItemPlayback WHERE userId=:userId")
fun getItems(userId: Int): List<ItemPlayback>
suspend fun getItems(userId: Int): List<ItemPlayback>
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
suspend fun getTrackModifications(

View file

@ -25,6 +25,7 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val streamChoiceService: StreamChoiceService,
) {
suspend fun getSelectedTracks(
@ -204,6 +205,18 @@ class ItemPlaybackRepository
)
}
}
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams)
chosenStreams?.plc?.let {
Timber.d("Deleting %s", it)
playbackLanguageChoiceDao.delete(it)
}
chosenStreams?.itemPlayback?.let {
Timber.d("Deleting %s", it)
itemPlaybackDao.deleteItem(it)
}
}
}
data class ChosenStreams(

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@ -17,4 +18,7 @@ interface PlaybackLanguageChoiceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long
@Delete
fun delete(plc: PlaybackLanguageChoice)
}

View file

@ -199,14 +199,16 @@ class StreamChoiceService
}
}
val candidates =
candidates.sortedWith(
compareBy<MediaStream>(
{ it.isDefault },
{ !it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.isUnknown },
{ it.isForced },
),
candidates
.sortedWith(
compareByDescending<MediaStream> { it.isExternal }
.thenByDescending { it.isDefault }
.thenByDescending {
!it.isForced && it.language.equals(subtitleLanguage, true)
}.thenByDescending {
it.isForced && it.language.equals(subtitleLanguage, true)
}.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced },
)
return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
@ -232,7 +234,7 @@ class StreamChoiceService
SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else {
@ -240,7 +242,7 @@ class StreamChoiceService
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}
} else {
candidates.firstOrNull { it.isForced }
candidates.firstOrNull { it.isDefault }
}
}

View file

@ -78,12 +78,13 @@ inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
/**
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
*/
fun FocusRequester.tryRequestFocus(): Boolean =
fun FocusRequester.tryRequestFocus(tag: String? = null): Boolean =
try {
requestFocus()
tag?.let { Timber.v("Request focus tag=%s", tag) }
true
} catch (ex: IllegalStateException) {
Timber.w(ex, "Failed to request focus")
Timber.w(ex, "Failed to request focus, tag=%s", tag)
false
}

View file

@ -51,8 +51,10 @@ fun TabRow(
) {
val state = rememberLazyListState()
LaunchedEffect(selectedTabIndex) {
if (selectedTabIndex >= 0) {
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
}
}
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
var rowHasFocus by remember { mutableStateOf(false) }
LazyRow(

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
@ -29,6 +30,12 @@ data class MoreDialogActions(
var onClickAddPlaylist: (UUID) -> Unit,
)
enum class ClearChosenStreams {
NONE,
ITEM_AND_SERIES,
SERIES,
}
/**
* Build the [DialogItem]s when clicking "More"
*
@ -53,10 +60,12 @@ fun buildMoreDialogItems(
sourceId: UUID?,
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
onClearChosenStreams: () -> Unit,
): List<DialogItem> =
buildList {
add(
@ -172,6 +181,16 @@ fun buildMoreDialogItems(
},
)
}
if (canClearChosenStreams) {
add(
DialogItem(
context.getString(R.string.clear_track_choices),
Icons.Default.Delete,
) {
onClearChosenStreams()
},
)
}
add(
DialogItem(
context.getString(R.string.play_with_transcoding),

View file

@ -155,6 +155,7 @@ fun EpisodeDetails(
favorite = ep.data.userData?.isFavorite ?: false,
seriesId = ep.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -204,6 +205,9 @@ fun EpisodeDetails(
)
}
},
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
),
)
},

View file

@ -182,4 +182,19 @@ class EpisodeViewModel
release()
navigationManager.navigateTo(destination)
}
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
item.value?.let { item ->
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
this@EpisodeViewModel.chosenStreams.setValueOnMain(result)
}
}
}
}

View file

@ -193,6 +193,7 @@ fun MovieDetails(
favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -241,6 +242,9 @@ fun MovieDetails(
files = movie.data.mediaSources.orEmpty(),
)
},
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
),
)
},

View file

@ -247,4 +247,19 @@ class MovieViewModel
release()
navigationManager.navigateTo(destination)
}
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
item.value?.let { item ->
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
this@MovieViewModel.chosenStreams.setValueOnMain(result)
}
}
}
}

View file

@ -19,6 +19,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.map
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
@ -179,6 +180,7 @@ fun SeriesOverview(
fun buildMoreForEpisode(
ep: BaseItem,
chosenStreams: ChosenStreams?,
fromLongClick: Boolean,
): DialogParams =
DialogParams(
@ -192,6 +194,7 @@ fun SeriesOverview(
favorite = ep.data.userData?.isFavorite ?: false,
seriesId = series.id,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
@ -259,6 +262,9 @@ fun SeriesOverview(
files = ep.data.mediaSources.orEmpty(),
)
},
onClearChosenStreams = {
viewModel.clearChosenStreams(ep, chosenStreams)
},
),
)
@ -304,7 +310,7 @@ fun SeriesOverview(
)
},
onLongClick = { ep ->
moreDialog = buildMoreForEpisode(ep, true)
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
},
playOnClick = { resume ->
rowFocused = EPISODE_ROW
@ -333,7 +339,7 @@ fun SeriesOverview(
},
moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog = buildMoreForEpisode(ep, false)
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
}
},
overviewOnClick = {

View file

@ -161,7 +161,7 @@ class SeriesViewModel
} ?: 0
Timber.v("Got initial season index: $index")
position.update {
it.copy(seasonTabIndex = index)
it.copy(seasonTabIndex = index.coerceAtLeast(0))
}
}
}
@ -510,6 +510,16 @@ class SeriesViewModel
}
}
}
fun clearChosenStreams(
item: BaseItem,
chosenStreams: ChosenStreams?,
) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
lookUpChosenTracks(item.id, item)
}
}
}
sealed interface EpisodeList {
@ -547,7 +557,10 @@ private suspend fun findIndexOf(
val index =
if (targetId != null && (targetNum == null || targetNum !in pager.indices)) {
// No hint info, so have to check everything
pager.indexOfBlocking { equalsNotNull(it?.id, targetId) }
pager.indexOfBlocking {
equalsNotNull(it?.indexNumber, targetNum) ||
equalsNotNull(it?.id, targetId)
}
} else if (targetNum != null && targetNum in pager.indices) {
// Start searching from the season number and choose direction from there
val num = pager.getBlocking(targetNum)?.indexNumber

View file

@ -226,28 +226,34 @@ fun HomePageContent(
mutableStateOf(RowColumn(firstRow, 0))
}
val focusedItem =
remember(position) {
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
}
val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
var focused by rememberSaveable { mutableStateOf(false) }
var firstFocused by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(homeRows) {
if (!focused) {
if (!firstFocused) {
// Waiting for the first home row to load, then focus on it
homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
rowFocusRequesters[it].tryRequestFocus()
delay(50)
listState.animateScrollToItem(position.row)
focused = true
listState.scrollToItem(it)
firstFocused = true
}
} else {
rowFocusRequesters.getOrNull(position.row)?.tryRequestFocus()
}
}
LaunchedEffect(Unit) {
if (firstFocused) {
// After the first home row was loaded & focused, page recompositions should focus on the positioned row
val index = position.row
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
delay(50)
listState.scrollToItem(index)
}
}
LaunchedEffect(position) {

View file

@ -46,6 +46,7 @@ data class PlaybackSettings(
@Composable
fun PlaybackDialog(
enableSubtitleDelay: Boolean,
enableVideoScale: Boolean,
type: PlaybackDialogType,
settings: PlaybackSettings,
onDismissRequest: () -> Unit,
@ -117,7 +118,9 @@ fun PlaybackDialog(
buildList {
add(stringResource(R.string.audio))
add(stringResource(R.string.playback_speed))
if (enableVideoScale) {
add(stringResource(R.string.video_scale))
}
if (enableSubtitleDelay) {
add(stringResource(R.string.subtitle_delay))
}

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale
@ -152,7 +153,7 @@ fun PlaybackPage(
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect {
if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) {
scope.launch(Dispatchers.IO + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration,
density,
@ -161,7 +162,15 @@ fun PlaybackPage(
}
}
AmbientPlayerListener(player)
var contentScale by remember { mutableStateOf(prefs.globalContentScale.scale) }
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) }
@ -572,6 +581,7 @@ fun PlaybackPage(
onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer,
enableVideoScale = player !is MpvPlayer,
)
}
}

View file

@ -69,7 +69,8 @@ class MpvPlayer(
) : BasePlayer(),
MPVLib.EventObserver,
TrackSelector.InvalidationListener,
Handler.Callback {
Handler.Callback,
SurfaceHolder.Callback {
companion object {
private const val DEBUG = false
}
@ -467,26 +468,55 @@ class MpvPlayer(
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException()
private var surfaceHolder: SurfaceHolder? = null
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {
if (DEBUG) Timber.v("setVideoSurfaceView")
val surface = surfaceView?.holder?.surface
if (surfaceView != null) {
this.surfaceHolder?.removeCallback(this)
this.surfaceHolder = surfaceView.holder
if (surfaceView.holder != null) {
val surface = surfaceView.holder?.surface
surfaceView.holder.addCallback(this)
Timber.v("Got surface holder: isValid=${surface?.isValid}")
if (surface != null && surface.isValid) {
Timber.v("Queued attach")
sendCommand(MpvCommand.ATTACH_SURFACE, surface)
} else {
clearVideoSurfaceView(null)
return
}
}
}
clearVideoSurfaceView(null)
}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
if (surface == surfaceView?.holder?.surface) {
if (surface != null && surface == surfaceView?.holder?.surface) {
Timber.d("clearVideoSurfaceView")
sendCommand(MpvCommand.ATTACH_SURFACE, null)
} else {
Timber.w("clearVideoSurfaceView called with different surface")
Timber.w("clearVideoSurfaceView called with different surface: %s", surfaceView)
}
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int,
) {
Timber.v("surfaceChanged: format=$format, width=$width, height=$height")
}
override fun surfaceCreated(holder: SurfaceHolder) {
Timber.v("surfaceCreated")
sendCommand(MpvCommand.ATTACH_SURFACE, holder.surface)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Timber.v("surfaceDestroyed")
sendCommand(MpvCommand.ATTACH_SURFACE, null)
}
override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
@ -660,6 +690,7 @@ class MpvPlayer(
MPV_EVENT_VIDEO_RECONFIG -> {
Timber.d("event: MPV_EVENT_VIDEO_RECONFIG")
updateTracksAndNotify()
updateVideoSizeAndNotify()
}
MPV_EVENT_END_FILE -> {
@ -720,6 +751,19 @@ class MpvPlayer(
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) }
}
private fun updateVideoSizeAndNotify() {
val width = MPVLib.getPropertyInt("width")
val height = MPVLib.getPropertyInt("height")
val videoSize =
if (width != null && height != null) {
VideoSize(width, height)
} else {
VideoSize.UNKNOWN
}
playbackState.update { it.copy(videoSize = videoSize) }
notifyListeners(EVENT_VIDEO_SIZE_CHANGED) { onVideoSizeChanged(videoSize) }
}
private fun loadFile(media: MediaAndPosition) {
Timber.v("loadFile: media=$media")
playbackState.update {
@ -836,23 +880,34 @@ class MpvPlayer(
internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget()
}
private val queuedCommands = mutableListOf<Pair<MpvCommand, Any?>>()
override fun handleMessage(msg: Message): Boolean {
val cmd = MpvCommand.entries[msg.what]
Timber.d("handleMessage: cmd=$cmd")
if (isReleased && cmd != MpvCommand.DESTROY) {
Timber.w("Player is released, ignoring command %s", cmd)
return true
}
if (surface == null && !cmd.isLifecycle) {
// If libmpv isn't ready, re-enqueue the messages
// If libmpv isn't ready, ueue the messages
// Note: this means nothing will play until it is attached to a surface,
// so MpvPlayer can't be used for background audio/music playback
Timber.v("MPV is not initialized/attached yet, requeue cmd %s", cmd)
internalHandler.sendMessageDelayed(Message.obtain(msg), 50)
Timber.v("MPV is not initialized/attached yet, queue cmd %s", cmd)
queuedCommands.add(Pair(cmd, msg.obj))
} else {
handleCommand(cmd, msg.obj)
}
return true
}
private fun handleCommand(
cmd: MpvCommand,
obj: Any?,
) {
Timber.d("handleCommand: cmd=$cmd")
when (cmd) {
MpvCommand.PLAY_PAUSE -> {
val playWhenReady = msg.obj as Boolean
val playWhenReady = obj as Boolean
MPVLib.setPropertyBoolean("pause", !playWhenReady)
playbackState.update {
it.copy(isPaused = !playWhenReady)
@ -866,13 +921,13 @@ class MpvPlayer(
}
MpvCommand.SET_TRACK_SELECTION -> {
val (propertyName, trackId) = msg.obj as TrackSelection
val (propertyName, trackId) = obj as TrackSelection
MPVLib.setPropertyString(propertyName, trackId)
updateTracksAndNotify()
}
MpvCommand.SEEK -> {
val positionMs = msg.obj as Long
val positionMs = obj as Long
MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0)
playbackState.update {
it.copy(positionMs = positionMs)
@ -880,7 +935,7 @@ class MpvPlayer(
}
MpvCommand.SET_SPEED -> {
val value = msg.obj as Float
val value = obj as Float
MPVLib.setPropertyDouble("speed", value.toDouble())
playbackState.update {
it.copy(speed = value)
@ -888,7 +943,7 @@ class MpvPlayer(
}
MpvCommand.SET_SUBTITLE_DELAY -> {
val value = msg.obj as Double
val value = obj as Double
MPVLib.setPropertyDouble("sub-delay", value)
playbackState.update {
it.copy(subtitleDelay = value)
@ -896,11 +951,11 @@ class MpvPlayer(
}
MpvCommand.LOAD_FILE -> {
loadFile(msg.obj as MediaAndPosition)
loadFile(obj as MediaAndPosition)
}
MpvCommand.ATTACH_SURFACE -> {
val surface = msg.obj as Surface?
val surface = obj as Surface?
if (surface == null || (this.surface != null && this.surface != surface)) {
// If clearing or changing the surface
MPVLib.detachSurface()
@ -913,6 +968,13 @@ class MpvPlayer(
this.surface = surface
MPVLib.setOptionString("force-window", "yes")
Timber.d("Attached surface")
if (queuedCommands.isNotEmpty()) {
Timber.d("Processing queued commands")
while (queuedCommands.isNotEmpty()) {
val msg = queuedCommands.removeAt(0)
handleCommand(msg.first, msg.second)
}
}
}
}
@ -927,7 +989,6 @@ class MpvPlayer(
Timber.d("MPVLib destroyed")
}
}
return true
}
}
@ -1023,7 +1084,7 @@ private data class PlaybackState(
val EMPTY =
PlaybackState(
timestamp = C.TIME_UNSET,
isLoadingFile = false,
isLoadingFile = true,
media = null,
positionMs = C.TIME_UNSET,
durationMs = C.TIME_UNSET,

View file

@ -402,6 +402,7 @@
<string name="local">Local</string>
<string name="play_trailer">Play trailer</string>
<string name="no_trailers">No trailers</string>
<string name="clear_track_choices">Clear track choices</string>
<string name="discover">Discover</string>
<string name="request">Request</string>

View file

@ -104,16 +104,6 @@ class TestStreamChoiceServiceBasic(
),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
),
TestInput(
1,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", false),
),
),
)
}
}
@ -352,6 +342,31 @@ class TestStreamChoiceServiceSmart(
userSubtitleLang = "spa",
userAudioLang = "eng",
),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "spa",
userAudioLang = null,
),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "eng", true),
subtitle(2, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "eng",
userAudioLang = null,
),
)
}
}
@ -448,6 +463,156 @@ class TestStreamChoiceServiceOnlyForced(
}
}
@RunWith(Parameterized::class)
class TestStreamChoiceServiceMultipleChoices(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
0,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", false),
),
),
TestInput(
2,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userAudioLang = null,
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = null,
userAudioLang = null,
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "spa",
userAudioLang = null,
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "eng",
userAudioLang = null,
streamAudioLang = "spa",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "eng",
userAudioLang = "eng",
streamAudioLang = "eng",
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = "spa",
userAudioLang = "eng",
streamAudioLang = "spa",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "spa", forced = true, default = false),
subtitle(1, "spa", false),
subtitle(2, "spa", default = true),
),
userSubtitleLang = "spa",
userAudioLang = "eng",
streamAudioLang = "eng",
),
TestInput(
2,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "spa", forced = true, default = false),
subtitle(1, "spa", false),
subtitle(2, "spa", default = true),
),
userSubtitleLang = "spa",
userAudioLang = "",
streamAudioLang = "eng",
),
TestInput(
2,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = false),
subtitle(1, "eng", false),
subtitle(2, "eng", default = true),
),
userSubtitleLang = null,
userAudioLang = null,
),
)
}
}
data class TestInput(
val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?,