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

View file

@ -25,6 +25,7 @@ class ItemPlaybackRepository
constructor( constructor(
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao, val itemPlaybackDao: ItemPlaybackDao,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val streamChoiceService: StreamChoiceService, private val streamChoiceService: StreamChoiceService,
) { ) {
suspend fun getSelectedTracks( 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( data class ChosenStreams(

View file

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

View file

@ -199,15 +199,17 @@ class StreamChoiceService
} }
} }
val candidates = val candidates =
candidates.sortedWith( candidates
compareBy<MediaStream>( .sortedWith(
{ it.isDefault }, compareByDescending<MediaStream> { it.isExternal }
{ !it.isForced && it.language.equals(subtitleLanguage, true) }, .thenByDescending { it.isDefault }
{ it.isForced && it.language.equals(subtitleLanguage, true) }, .thenByDescending {
{ it.isForced && it.language.isUnknown }, !it.isForced && it.language.equals(subtitleLanguage, true)
{ it.isForced }, }.thenByDescending {
), it.isForced && it.language.equals(subtitleLanguage, true)
) }.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced },
)
return when (subtitleMode) { return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> { SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage.isNotNullOrBlank()) { if (subtitleLanguage.isNotNullOrBlank()) {
@ -232,7 +234,7 @@ class StreamChoiceService
SubtitlePlaybackMode.SMART -> { SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) { if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference val audioLanguage = userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) { if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage } candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown } ?: candidates.firstOrNull { it.language.isUnknown }
} else { } else {
@ -240,7 +242,7 @@ class StreamChoiceService
?: candidates.firstOrNull { it.isForced && it.language.isUnknown } ?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
} }
} else { } 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 * 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 { try {
requestFocus() requestFocus()
tag?.let { Timber.v("Request focus tag=%s", tag) }
true true
} catch (ex: IllegalStateException) { } catch (ex: IllegalStateException) {
Timber.w(ex, "Failed to request focus") Timber.w(ex, "Failed to request focus, tag=%s", tag)
false false
} }

View file

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

View file

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

View file

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

View file

@ -182,4 +182,19 @@ class EpisodeViewModel
release() release()
navigationManager.navigateTo(destination) 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, favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null, seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -241,6 +242,9 @@ fun MovieDetails(
files = movie.data.mediaSources.orEmpty(), files = movie.data.mediaSources.orEmpty(),
) )
}, },
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
), ),
) )
}, },

View file

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

View file

@ -161,7 +161,7 @@ class SeriesViewModel
} ?: 0 } ?: 0
Timber.v("Got initial season index: $index") Timber.v("Got initial season index: $index")
position.update { 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 { sealed interface EpisodeList {
@ -547,7 +557,10 @@ private suspend fun findIndexOf(
val index = val index =
if (targetId != null && (targetNum == null || targetNum !in pager.indices)) { if (targetId != null && (targetNum == null || targetNum !in pager.indices)) {
// No hint info, so have to check everything // 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) { } else if (targetNum != null && targetNum in pager.indices) {
// Start searching from the season number and choose direction from there // Start searching from the season number and choose direction from there
val num = pager.getBlocking(targetNum)?.indexNumber val num = pager.getBlocking(targetNum)?.indexNumber

View file

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

View file

@ -46,6 +46,7 @@ data class PlaybackSettings(
@Composable @Composable
fun PlaybackDialog( fun PlaybackDialog(
enableSubtitleDelay: Boolean, enableSubtitleDelay: Boolean,
enableVideoScale: Boolean,
type: PlaybackDialogType, type: PlaybackDialogType,
settings: PlaybackSettings, settings: PlaybackSettings,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
@ -117,7 +118,9 @@ fun PlaybackDialog(
buildList { buildList {
add(stringResource(R.string.audio)) add(stringResource(R.string.audio))
add(stringResource(R.string.playback_speed)) add(stringResource(R.string.playback_speed))
add(stringResource(R.string.video_scale)) if (enableVideoScale) {
add(stringResource(R.string.video_scale))
}
if (enableSubtitleDelay) { if (enableSubtitleDelay) {
add(stringResource(R.string.subtitle_delay)) 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.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
@ -152,7 +153,7 @@ fun PlaybackPage(
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) } var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
if (prefs.playerBackend == PlayerBackend.MPV) { if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) { scope.launch(Dispatchers.IO + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration, configuration,
density, density,
@ -161,7 +162,15 @@ fun PlaybackPage(
} }
} }
AmbientPlayerListener(player) 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) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
@ -572,6 +581,7 @@ fun PlaybackPage(
onPlaybackActionClick = onPlaybackActionClick, onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer, enableSubtitleDelay = player is MpvPlayer,
enableVideoScale = player !is MpvPlayer,
) )
} }
} }

View file

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

View file

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

View file

@ -104,16 +104,6 @@ class TestStreamChoiceServiceBasic(
), ),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), 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", userSubtitleLang = "spa",
userAudioLang = "eng", 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( data class TestInput(
val expectedIndex: Int?, val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?, val userSubtitleMode: SubtitlePlaybackMode?,