mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/jellyseerr
This commit is contained in:
commit
8ff530a4f9
19 changed files with 415 additions and 67 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue