mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Support playing multiple file versions (#30)
Adds support for playing different versions of a media item. Additionally, you can pick the audio or subtitle tracks for the chosen version. The file with the highest resolution is the default, but you can switch versions or tracks in the "More" menu. These choices are persisted across playbacks, just as in #26.
This commit is contained in:
parent
e2db5ab4f3
commit
f57fd25f8e
15 changed files with 771 additions and 196 deletions
|
|
@ -1,8 +1,13 @@
|
||||||
package com.github.damontecres.wholphin.data
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
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.UUID
|
import java.util.UUID
|
||||||
|
|
@ -24,6 +29,16 @@ class ItemPlaybackRepository
|
||||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||||
if (itemPlayback != null) {
|
if (itemPlayback != null) {
|
||||||
Timber.v("Got itemPlayback for %s", itemId)
|
Timber.v("Got itemPlayback for %s", itemId)
|
||||||
|
getChosenItemFromPlayback(item, itemPlayback)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChosenItemFromPlayback(
|
||||||
|
item: BaseItem,
|
||||||
|
itemPlayback: ItemPlayback,
|
||||||
|
): ChosenStreams? {
|
||||||
val source =
|
val source =
|
||||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
|
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
|
|
@ -39,23 +54,68 @@ class ItemPlaybackRepository
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
ChosenStreams(
|
return ChosenStreams(
|
||||||
itemId,
|
itemPlayback,
|
||||||
|
item.id,
|
||||||
|
source.id?.toUUIDOrNull(),
|
||||||
audioStream,
|
audioStream,
|
||||||
subtitleStream,
|
subtitleStream,
|
||||||
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
|
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
return null
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun savePlayVersion(
|
||||||
|
itemId: UUID,
|
||||||
|
sourceId: UUID,
|
||||||
|
): ItemPlayback? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
serverRepository.currentUser?.let { user ->
|
||||||
|
val itemPlayback =
|
||||||
|
ItemPlayback(
|
||||||
|
userId = user.rowId,
|
||||||
|
itemId = itemId,
|
||||||
|
sourceId = sourceId,
|
||||||
|
)
|
||||||
|
Timber.v("Saving play version %s", itemPlayback)
|
||||||
|
saveItemPlayback(itemPlayback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveTrackSelection(
|
||||||
|
item: BaseItem,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
trackIndex: Int,
|
||||||
|
type: MediaStreamType,
|
||||||
|
) = serverRepository.currentUser?.let { user ->
|
||||||
|
var toSave =
|
||||||
|
itemPlayback ?: ItemPlayback(
|
||||||
|
userId = user.rowId,
|
||||||
|
itemId = item.id,
|
||||||
|
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
toSave =
|
||||||
|
when (type) {
|
||||||
|
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
|
||||||
|
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
|
||||||
|
else -> toSave
|
||||||
|
}
|
||||||
|
Timber.v("Saving track selection %s", toSave)
|
||||||
|
saveItemPlayback(toSave)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveItemPlayback(itemPlayback: ItemPlayback): ItemPlayback {
|
||||||
|
val rowId = itemPlaybackDao.saveItem(itemPlayback)
|
||||||
|
return itemPlayback.copy(rowId = rowId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ChosenStreams(
|
data class ChosenStreams(
|
||||||
|
val itemPlayback: ItemPlayback,
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
|
val sourceId: UUID?,
|
||||||
val audioStream: MediaStream?,
|
val audioStream: MediaStream?,
|
||||||
val subtitleStream: MediaStream?,
|
val subtitleStream: MediaStream?,
|
||||||
val subtitlesDisabled: Boolean,
|
val subtitlesDisabled: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,18 @@ import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.github.damontecres.wholphin.data.JellyfinUser
|
import com.github.damontecres.wholphin.data.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.util.UuidSerializer
|
import com.github.damontecres.wholphin.util.UuidSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|
@ -39,6 +48,119 @@ data class ItemPlayback(
|
||||||
@Transient val subtitleIndexEnabled = subtitleIndex >= 0
|
@Transient val subtitleIndexEnabled = subtitleIndex >= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the [MediaSourceInfo] with the highest video resolution
|
||||||
|
*/
|
||||||
|
fun chooseSource(sources: List<MediaSourceInfo>?) =
|
||||||
|
sources?.letNotEmpty { sources ->
|
||||||
|
val result =
|
||||||
|
sources.maxByOrNull { s ->
|
||||||
|
s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
|
||||||
|
(video.width ?: 0) * (video.height ?: 0)
|
||||||
|
} ?: 0
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
|
||||||
|
*/
|
||||||
|
fun chooseSource(
|
||||||
|
dto: BaseItemDto,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
|
||||||
|
?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
|
||||||
|
|
||||||
|
fun chooseStream(
|
||||||
|
dto: BaseItemDto,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
type: MediaStreamType,
|
||||||
|
prefs: UserPreferences,
|
||||||
|
): MediaStream? {
|
||||||
|
val source = chooseSource(dto, itemPlayback)
|
||||||
|
return source?.mediaStreams?.letNotEmpty { streams ->
|
||||||
|
val candidates = streams.filter { it.type == type }
|
||||||
|
when (type) {
|
||||||
|
MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs)
|
||||||
|
MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs)
|
||||||
|
else -> candidates.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chooseAudioStream(
|
||||||
|
candidates: List<MediaStream>,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
prefs: UserPreferences,
|
||||||
|
): MediaStream? =
|
||||||
|
if (itemPlayback?.audioIndexEnabled == true) {
|
||||||
|
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
|
||||||
|
} else {
|
||||||
|
// TODO audio selection based on channel layout or preferences or default
|
||||||
|
val audioLanguage = prefs.userConfig.audioLanguagePreference
|
||||||
|
if (audioLanguage.isNotNullOrBlank()) {
|
||||||
|
val sorted =
|
||||||
|
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
||||||
|
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
|
||||||
|
?: sorted.firstOrNull { it.language == audioLanguage }
|
||||||
|
?: sorted.firstOrNull { it.isDefault }
|
||||||
|
?: sorted.firstOrNull()
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isDefault }
|
||||||
|
?: candidates.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chooseSubtitleStream(
|
||||||
|
candidates: List<MediaStream>,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
prefs: UserPreferences,
|
||||||
|
): MediaStream? {
|
||||||
|
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||||
|
return null
|
||||||
|
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
||||||
|
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||||
|
} else {
|
||||||
|
val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference
|
||||||
|
return when (prefs.userConfig.subtitleMode) {
|
||||||
|
SubtitlePlaybackMode.ALWAYS -> {
|
||||||
|
if (subtitleLanguage != null) {
|
||||||
|
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED ->
|
||||||
|
if (subtitleLanguage != null) {
|
||||||
|
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isForced }
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlePlaybackMode.SMART -> {
|
||||||
|
val audioLanguage = prefs.userConfig.audioLanguagePreference
|
||||||
|
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
|
||||||
|
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlePlaybackMode.DEFAULT -> {
|
||||||
|
// TODO check for language?
|
||||||
|
(
|
||||||
|
candidates.firstOrNull { it.isDefault && it.isForced }
|
||||||
|
?: candidates.firstOrNull { it.isDefault }
|
||||||
|
?: candidates.firstOrNull { it.isForced }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlePlaybackMode.NONE -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object TrackIndex {
|
object TrackIndex {
|
||||||
/**
|
/**
|
||||||
* The user has not explicitly specified a track to use
|
* The user has not explicitly specified a track to use
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,15 @@ 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
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for rendering a [DialogPopup]
|
* Parameters for rendering a [DialogPopup]
|
||||||
|
|
@ -79,6 +84,25 @@ data class DialogItem(
|
||||||
val trailingContent: @Composable (() -> Unit)? = null,
|
val trailingContent: @Composable (() -> Unit)? = null,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
) : DialogItemEntry {
|
) : DialogItemEntry {
|
||||||
|
constructor(
|
||||||
|
@StringRes text: Int,
|
||||||
|
@StringRes iconStringRes: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) : this(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(text),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = iconStringRes),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
text: String,
|
text: String,
|
||||||
@StringRes iconStringRes: Int,
|
@StringRes iconStringRes: Int,
|
||||||
|
|
@ -413,3 +437,65 @@ fun ConfirmDialogContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun chooseVersionParams(
|
||||||
|
sources: List<MediaSourceInfo>,
|
||||||
|
onClick: (Int) -> Unit,
|
||||||
|
): DialogParams =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = "Play Version",
|
||||||
|
items =
|
||||||
|
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
|
||||||
|
val videoStream =
|
||||||
|
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||||
|
val title = source.name ?: source.path ?: source.id ?: ""
|
||||||
|
DialogItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = title)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
videoStream?.displayTitle?.let { Text(text = it) }
|
||||||
|
},
|
||||||
|
onClick = { onClick.invoke(index) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun chooseStream(
|
||||||
|
streams: List<MediaStream>,
|
||||||
|
type: MediaStreamType,
|
||||||
|
onClick: (Int) -> Unit,
|
||||||
|
): DialogParams =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = "Choose ${type.serialName}", // TODO
|
||||||
|
items =
|
||||||
|
buildList {
|
||||||
|
if (type == MediaStreamType.SUBTITLE) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = "None")
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
},
|
||||||
|
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
addAll(
|
||||||
|
streams.filter { it.type == type }.mapIndexed { index, stream ->
|
||||||
|
val title = stream.displayTitle ?: stream.title ?: ""
|
||||||
|
DialogItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = title)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
},
|
||||||
|
onClick = { onClick.invoke(stream.index) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
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.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the [DialogItem]s when clicking "More"
|
||||||
|
*
|
||||||
|
* If there are multiple versions, adds an option to pick one
|
||||||
|
*
|
||||||
|
* If there is more than one (ie two or more) audio track, adds an option to pick one
|
||||||
|
*
|
||||||
|
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
|
||||||
|
*
|
||||||
|
* @param item the media item to build for, typically an Episode or Movie
|
||||||
|
* @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
|
||||||
|
* @param sourceId the item's media source UUID
|
||||||
|
* @param navigateTo a function to trigger a navigation
|
||||||
|
* @param onChooseVersion callback to pick a version of the item
|
||||||
|
* @param onChooseTracks callback to pick a track for the given type of the item
|
||||||
|
*/
|
||||||
|
fun buildMoreDialogItems(
|
||||||
|
item: BaseItem,
|
||||||
|
series: BaseItem?,
|
||||||
|
sourceId: UUID?,
|
||||||
|
watched: Boolean,
|
||||||
|
navigateTo: (Destination) -> Unit,
|
||||||
|
onClickWatch: (Boolean) -> Unit,
|
||||||
|
onChooseVersion: () -> Unit,
|
||||||
|
onChooseTracks: (MediaStreamType) -> Unit,
|
||||||
|
): List<DialogItem> =
|
||||||
|
buildList {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
"Play",
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
item.id,
|
||||||
|
item.resumeMs ?: 0L,
|
||||||
|
item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
|
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
||||||
|
) {
|
||||||
|
onClickWatch.invoke(!watched)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
series?.let {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
"Go to series",
|
||||||
|
Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
) {
|
||||||
|
navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
series.id,
|
||||||
|
BaseItemKind.SERIES,
|
||||||
|
series,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item.data.mediaSources?.letNotEmpty { sources ->
|
||||||
|
if (sources.size > 1) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
"Choose Version",
|
||||||
|
Icons.Default.ArrowForward,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
onChooseVersion.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val source =
|
||||||
|
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } }
|
||||||
|
?: sources.firstOrNull()
|
||||||
|
source?.mediaStreams?.letNotEmpty { streams ->
|
||||||
|
val audioCount = streams.count { it.type == MediaStreamType.AUDIO }
|
||||||
|
val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
if (audioCount > 1) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
"Choose audio",
|
||||||
|
Icons.Default.Settings,
|
||||||
|
) {
|
||||||
|
onChooseTracks.invoke(MediaStreamType.AUDIO)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (subtitleCount > 0) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
"Choose subtitles",
|
||||||
|
Icons.Default.Settings,
|
||||||
|
) {
|
||||||
|
onChooseTracks.invoke(MediaStreamType.SUBTITLE)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||||
|
|
@ -46,6 +47,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
|
@ -328,6 +330,46 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePlayVersion(
|
||||||
|
item: BaseItem,
|
||||||
|
sourceId: UUID,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
|
val chosen =
|
||||||
|
result?.let {
|
||||||
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chosenStreams.value = chosen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveTrackSelection(
|
||||||
|
item: BaseItem,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
trackIndex: Int,
|
||||||
|
type: MediaStreamType,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result =
|
||||||
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
|
item = item,
|
||||||
|
itemPlayback = itemPlayback,
|
||||||
|
trackIndex = trackIndex,
|
||||||
|
type = type,
|
||||||
|
)
|
||||||
|
val chosen =
|
||||||
|
result?.let {
|
||||||
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chosenStreams.value = chosen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ItemListAndMapping(
|
data class ItemListAndMapping(
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -40,19 +38,23 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.Chapter
|
import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.cards.ChapterRow
|
import com.github.damontecres.wholphin.ui.cards.ChapterRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
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.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||||
|
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
|
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
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.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
|
@ -68,7 +70,9 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -119,6 +123,46 @@ class MovieViewModel
|
||||||
}
|
}
|
||||||
init(itemId, null)
|
init(itemId, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePlayVersion(
|
||||||
|
item: BaseItem,
|
||||||
|
sourceId: UUID,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
|
val chosen =
|
||||||
|
result?.let {
|
||||||
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chosenStreams.value = chosen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveTrackSelection(
|
||||||
|
item: BaseItem,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
trackIndex: Int,
|
||||||
|
type: MediaStreamType,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result =
|
||||||
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
|
item = item,
|
||||||
|
itemPlayback = itemPlayback,
|
||||||
|
trackIndex = trackIndex,
|
||||||
|
type = type,
|
||||||
|
)
|
||||||
|
val chosen =
|
||||||
|
result?.let {
|
||||||
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chosenStreams.value = chosen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -139,6 +183,7 @@ fun MovieDetails(
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> ErrorMessage(state)
|
is LoadingState.Error -> ErrorMessage(state)
|
||||||
|
|
@ -179,30 +224,44 @@ fun MovieDetails(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = movie.name + " (${movie.data.productionYear ?: ""})",
|
title = movie.name + " (${movie.data.productionYear ?: ""})",
|
||||||
items =
|
items =
|
||||||
listOf(
|
buildMoreDialogItems(
|
||||||
DialogItem(
|
item = movie,
|
||||||
"Play",
|
watched = movie.data.userData?.played ?: false,
|
||||||
Icons.Default.PlayArrow,
|
series = null,
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
sourceId = chosenStreams?.sourceId,
|
||||||
) {
|
navigateTo = viewModel.navigationManager::navigateTo,
|
||||||
viewModel.navigationManager.navigateTo(
|
onClickWatch = viewModel::setWatched,
|
||||||
Destination.Playback(movie),
|
onChooseVersion = {
|
||||||
|
chooseVersion =
|
||||||
|
chooseVersionParams(movie.data.mediaSources!!) { idx ->
|
||||||
|
val source = movie.data.mediaSources!![idx]
|
||||||
|
viewModel.savePlayVersion(
|
||||||
|
movie,
|
||||||
|
source.id!!.toUUID(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
moreDialog = null
|
||||||
|
},
|
||||||
|
onChooseTracks = { type ->
|
||||||
|
chooseSource(
|
||||||
|
movie.data,
|
||||||
|
chosenStreams?.itemPlayback,
|
||||||
|
)?.let { source ->
|
||||||
|
chooseVersion =
|
||||||
|
chooseStream(
|
||||||
|
streams = source.mediaStreams.orEmpty(),
|
||||||
|
type = type,
|
||||||
|
onClick = { trackIndex ->
|
||||||
|
viewModel.saveTrackSelection(
|
||||||
|
movie,
|
||||||
|
chosenStreams?.itemPlayback,
|
||||||
|
trackIndex,
|
||||||
|
type,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// DialogItem(
|
)
|
||||||
// "Playback Settings",
|
}
|
||||||
// Icons.Default.Settings,
|
},
|
||||||
// // iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
// ) {
|
|
||||||
// // TODO choose audio or subtitle tracks?
|
|
||||||
// },
|
|
||||||
// DialogItem(
|
|
||||||
// "Play Version",
|
|
||||||
// Icons.Default.PlayArrow,
|
|
||||||
// iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
// ) {
|
|
||||||
// // TODO only show for multiple files
|
|
||||||
// },
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -230,6 +289,16 @@ fun MovieDetails(
|
||||||
waitToLoad = params.fromLongClick,
|
waitToLoad = params.fromLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
chooseVersion?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { chooseVersion = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -300,6 +369,7 @@ fun MovieDetailsContent(
|
||||||
.padding(bottom = 56.dp),
|
.padding(bottom = 56.dp),
|
||||||
) {
|
) {
|
||||||
MovieDetailsHeader(
|
MovieDetailsHeader(
|
||||||
|
preferences = preferences,
|
||||||
movie = movie,
|
movie = movie,
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
bringIntoViewRequester = bringIntoViewRequester,
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
|
@ -27,6 +26,8 @@ import androidx.tv.material3.Text
|
||||||
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.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseStream
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.StarRating
|
import com.github.damontecres.wholphin.ui.components.StarRating
|
||||||
|
|
@ -43,6 +44,7 @@ import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieDetailsHeader(
|
fun MovieDetailsHeader(
|
||||||
|
preferences: UserPreferences,
|
||||||
movie: BaseItem,
|
movie: BaseItem,
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
bringIntoViewRequester: BringIntoViewRequester,
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
|
|
@ -141,15 +143,16 @@ fun MovieDetailsHeader(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
dto.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.displayTitle?.let {
|
chooseStream(dto, chosenStreams?.itemPlayback, MediaStreamType.VIDEO, preferences)
|
||||||
|
?.displayTitle
|
||||||
|
?.let {
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
stringResource(R.string.video),
|
stringResource(R.string.video),
|
||||||
it,
|
it,
|
||||||
modifier = Modifier.widthIn(max = 200.dp),
|
modifier = Modifier.widthIn(max = 200.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val audioDisplay =
|
val audioDisplay = getAudioDisplay(movie.data, chosenStreams, preferences)
|
||||||
remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) }
|
|
||||||
audioDisplay
|
audioDisplay
|
||||||
?.let {
|
?.let {
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
|
|
@ -165,7 +168,7 @@ fun MovieDetailsHeader(
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
"Subtitles",
|
"Subtitles",
|
||||||
it,
|
it,
|
||||||
modifier = Modifier.widthIn(max = 120.dp),
|
modifier = Modifier.widthIn(max = 200.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import androidx.compose.ui.unit.dp
|
||||||
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.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseStream
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||||
import com.github.damontecres.wholphin.ui.components.TitleValueText
|
import com.github.damontecres.wholphin.ui.components.TitleValueText
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -24,6 +26,7 @@ import kotlin.time.Duration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FocusedEpisodeFooter(
|
fun FocusedEpisodeFooter(
|
||||||
|
preferences: UserPreferences,
|
||||||
ep: BaseItem,
|
ep: BaseItem,
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
playOnClick: (Duration) -> Unit,
|
playOnClick: (Duration) -> Unit,
|
||||||
|
|
@ -52,21 +55,22 @@ fun FocusedEpisodeFooter(
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
dto.mediaStreams
|
chooseStream(dto, chosenStreams?.itemPlayback, MediaStreamType.VIDEO, preferences)
|
||||||
?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
|
||||||
?.displayTitle
|
?.displayTitle
|
||||||
?.let {
|
?.let {
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
stringResource(R.string.video),
|
stringResource(R.string.video),
|
||||||
it,
|
it,
|
||||||
|
modifier = Modifier.widthIn(max = 160.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val audioDisplay =
|
|
||||||
remember(ep.id, chosenStreams) { getAudioDisplay(ep.data, chosenStreams) }
|
val audioDisplay = getAudioDisplay(ep.data, chosenStreams, preferences)
|
||||||
audioDisplay?.let {
|
audioDisplay?.let {
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
stringResource(R.string.audio),
|
stringResource(R.string.audio),
|
||||||
it,
|
it,
|
||||||
|
modifier = Modifier.widthIn(max = 200.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +81,7 @@ fun FocusedEpisodeFooter(
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
"Subtitles",
|
"Subtitles",
|
||||||
it,
|
it,
|
||||||
modifier = Modifier.widthIn(max = 64.dp),
|
modifier = Modifier.widthIn(max = 160.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.series
|
package com.github.damontecres.wholphin.ui.detail.series
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -14,28 +11,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
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.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.chooseStream
|
||||||
|
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.EpisodeList
|
import com.github.damontecres.wholphin.ui.detail.EpisodeList
|
||||||
import com.github.damontecres.wholphin.ui.detail.ItemListAndMapping
|
import com.github.damontecres.wholphin.ui.detail.ItemListAndMapping
|
||||||
import com.github.damontecres.wholphin.ui.detail.SeriesViewModel
|
import com.github.damontecres.wholphin.ui.detail.SeriesViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.seasonEpisode
|
import com.github.damontecres.wholphin.util.seasonEpisode
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -101,6 +101,7 @@ fun SeriesOverview(
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(episodes) {
|
LaunchedEffect(episodes) {
|
||||||
episodes?.let { episodes ->
|
episodes?.let { episodes ->
|
||||||
|
|
@ -137,7 +138,68 @@ fun SeriesOverview(
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
series?.let { series ->
|
series?.let { series ->
|
||||||
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
|
||||||
|
|
||||||
|
fun buildMoreForEpisode(
|
||||||
|
ep: BaseItem,
|
||||||
|
fromLongClick: Boolean,
|
||||||
|
): DialogParams =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = fromLongClick,
|
||||||
|
title = series.name + " - " + ep.data.seasonEpisode,
|
||||||
|
items =
|
||||||
|
buildMoreDialogItems(
|
||||||
|
ep,
|
||||||
|
watched = ep.data.userData?.played ?: false,
|
||||||
|
series = series,
|
||||||
|
sourceId = chosenStreams?.sourceId,
|
||||||
|
navigateTo = viewModel::navigateTo,
|
||||||
|
onClickWatch = { played ->
|
||||||
|
episodeList
|
||||||
|
?.getOrNull(position.episodeRowIndex)
|
||||||
|
?.let {
|
||||||
|
viewModel.setWatched(
|
||||||
|
it.id,
|
||||||
|
played,
|
||||||
|
position.episodeRowIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChooseVersion = {
|
||||||
|
chooseVersion =
|
||||||
|
chooseVersionParams(ep.data.mediaSources!!) { idx ->
|
||||||
|
val source = ep.data.mediaSources!![idx]
|
||||||
|
viewModel.savePlayVersion(
|
||||||
|
ep,
|
||||||
|
source.id!!.toUUID(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
moreDialog = null
|
||||||
|
},
|
||||||
|
onChooseTracks = { type ->
|
||||||
|
chooseSource(
|
||||||
|
ep.data,
|
||||||
|
chosenStreams?.itemPlayback,
|
||||||
|
)?.let { source ->
|
||||||
|
chooseVersion =
|
||||||
|
chooseStream(
|
||||||
|
streams = source.mediaStreams.orEmpty(),
|
||||||
|
type = type,
|
||||||
|
onClick = { trackIndex ->
|
||||||
|
viewModel.saveTrackSelection(
|
||||||
|
ep,
|
||||||
|
chosenStreams?.itemPlayback,
|
||||||
|
trackIndex,
|
||||||
|
type,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
SeriesOverviewContent(
|
SeriesOverviewContent(
|
||||||
|
preferences = preferences,
|
||||||
series = series,
|
series = series,
|
||||||
seasons = seasons.items,
|
seasons = seasons.items,
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
|
|
@ -173,8 +235,8 @@ fun SeriesOverview(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClick = {
|
onLongClick = { ep ->
|
||||||
// TODO
|
moreDialog = buildMoreForEpisode(ep, true)
|
||||||
},
|
},
|
||||||
playOnClick = { resume ->
|
playOnClick = { resume ->
|
||||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
|
|
@ -196,54 +258,7 @@ fun SeriesOverview(
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||||
moreDialog =
|
moreDialog = buildMoreForEpisode(ep, false)
|
||||||
DialogParams(
|
|
||||||
fromLongClick = false,
|
|
||||||
title = series.name + " - " + ep.data.seasonEpisode,
|
|
||||||
items =
|
|
||||||
listOf(
|
|
||||||
DialogItem(
|
|
||||||
"Play",
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
viewModel.navigateTo(
|
|
||||||
Destination.Playback(
|
|
||||||
ep.id,
|
|
||||||
ep.resumeMs ?: 0L,
|
|
||||||
ep,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
DialogItem(
|
|
||||||
"Go to series",
|
|
||||||
Icons.AutoMirrored.Filled.ArrowForward,
|
|
||||||
// iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
viewModel.navigateTo(
|
|
||||||
Destination.MediaItem(
|
|
||||||
series.id,
|
|
||||||
BaseItemKind.SERIES,
|
|
||||||
series,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
// DialogItem(
|
|
||||||
// "Playback Settings",
|
|
||||||
// Icons.Default.Settings,
|
|
||||||
// // iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
// ) {
|
|
||||||
// // TODO choose audio or subtitle tracks?
|
|
||||||
// },
|
|
||||||
// DialogItem(
|
|
||||||
// "Play Version",
|
|
||||||
// Icons.Default.PlayArrow,
|
|
||||||
// iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
// ) {
|
|
||||||
// // TODO only show for multiple files
|
|
||||||
// },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
|
|
@ -281,4 +296,14 @@ fun SeriesOverview(
|
||||||
waitToLoad = params.fromLongClick,
|
waitToLoad = params.fromLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
chooseVersion?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { chooseVersion = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
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.data.model.aspectRatioFloat
|
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -58,6 +59,7 @@ import kotlin.time.Duration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesOverviewContent(
|
fun SeriesOverviewContent(
|
||||||
|
preferences: UserPreferences,
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
seasons: List<BaseItem?>,
|
seasons: List<BaseItem?>,
|
||||||
episodes: EpisodeList,
|
episodes: EpisodeList,
|
||||||
|
|
@ -282,6 +284,7 @@ fun SeriesOverviewContent(
|
||||||
item {
|
item {
|
||||||
focusedEpisode?.let { ep ->
|
focusedEpisode?.let { ep ->
|
||||||
FocusedEpisodeFooter(
|
FocusedEpisodeFooter(
|
||||||
|
preferences = preferences,
|
||||||
ep = ep,
|
ep = ep,
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
playOnClick = playOnClick,
|
playOnClick = playOnClick,
|
||||||
|
|
|
||||||
|
|
@ -371,15 +371,25 @@ fun PlaybackOverlay(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart),
|
.align(Alignment.TopStart),
|
||||||
) {
|
) {
|
||||||
currentPlayback?.tracks?.letNotEmpty {
|
Column(
|
||||||
PlaybackTrackInfo(
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
trackSupport = it,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.background(AppColors.TransparentBlack50),
|
.background(AppColors.TransparentBlack50),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Play method: ${currentPlayback?.playMethod?.serialName}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
|
currentPlayback?.tracks?.letNotEmpty {
|
||||||
|
PlaybackTrackInfo(
|
||||||
|
trackSupport = it,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -15,6 +16,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Icon
|
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 com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.util.TrackSupport
|
import com.github.damontecres.wholphin.util.TrackSupport
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,7 +30,7 @@ fun PlaybackTrackInfo(
|
||||||
val selectedWeight = .5f
|
val selectedWeight = .5f
|
||||||
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
|
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
|
||||||
val textStyle =
|
val textStyle =
|
||||||
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onBackground)
|
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -71,7 +73,11 @@ fun PlaybackTrackInfo(
|
||||||
items(trackSupport) { track ->
|
items(trackSupport) { track ->
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier,
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
track.selected,
|
||||||
|
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
val texts =
|
val texts =
|
||||||
listOf(
|
listOf(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistCreator
|
import com.github.damontecres.wholphin.data.model.PlaylistCreator
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseStream
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
|
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
|
@ -158,7 +160,7 @@ class PlaybackViewModel
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
loading,
|
loading,
|
||||||
"Error preparing for plaback on ${destination.itemId}",
|
"Error preparing for playback for ${destination.itemId}",
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
||||||
|
|
@ -212,37 +214,6 @@ class PlaybackViewModel
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
val playbackConfig =
|
|
||||||
if (itemPlayback != null) {
|
|
||||||
itemPlayback
|
|
||||||
} else {
|
|
||||||
val user = serverRepository.currentUser!!
|
|
||||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
|
||||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
|
||||||
if (it.sourceId == null) {
|
|
||||||
it.copy(
|
|
||||||
// TODO Better choice than first (which may not be the best resolution), using getPostedPlaybackInfo might be better?
|
|
||||||
sourceId =
|
|
||||||
base.mediaSources
|
|
||||||
?.firstOrNull()
|
|
||||||
?.id
|
|
||||||
?.toUUIDOrNull(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?: ItemPlayback(
|
|
||||||
userId = user.rowId,
|
|
||||||
itemId = base.id,
|
|
||||||
sourceId =
|
|
||||||
base.mediaSources
|
|
||||||
?.firstOrNull()
|
|
||||||
?.id
|
|
||||||
?.toUUIDOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
dto = base
|
dto = base
|
||||||
val title =
|
val title =
|
||||||
if (base.type == BaseItemKind.EPISODE) {
|
if (base.type == BaseItemKind.EPISODE) {
|
||||||
|
|
@ -263,14 +234,24 @@ class PlaybackViewModel
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@PlaybackViewModel.title.value = title
|
this@PlaybackViewModel.title.value = title
|
||||||
this@PlaybackViewModel.subtitle.value = subtitle
|
this@PlaybackViewModel.subtitle.value = subtitle
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = playbackConfig
|
|
||||||
}
|
}
|
||||||
val mediaSource =
|
|
||||||
if (playbackConfig.sourceId != null) {
|
val playbackConfig =
|
||||||
base.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == playbackConfig.sourceId }
|
if (itemPlayback != null) {
|
||||||
|
itemPlayback
|
||||||
} else {
|
} else {
|
||||||
base.mediaSources?.firstOrNull()
|
val user = serverRepository.currentUser!!
|
||||||
|
itemPlaybackDao.getItem(user, base.id)?.let {
|
||||||
|
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||||
|
if (it.sourceId != null) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mediaSource = chooseSource(base, playbackConfig)
|
||||||
|
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
showToast(
|
showToast(
|
||||||
context,
|
context,
|
||||||
|
|
@ -314,31 +295,31 @@ class PlaybackViewModel
|
||||||
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
// TODO audio selection based on channel layout or preferences or default
|
|
||||||
val audioLanguage = preferences.userConfig.audioLanguagePreference
|
|
||||||
val audioIndex =
|
val audioIndex =
|
||||||
if (playbackConfig.audioIndexEnabled) {
|
chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences)
|
||||||
playbackConfig.audioIndex
|
?.index
|
||||||
} else if (audioLanguage != null) {
|
|
||||||
audioStreams.firstOrNull { it.language == audioLanguage }?.index
|
|
||||||
?: audioStreams.firstOrNull()?.index
|
|
||||||
} else {
|
|
||||||
audioStreams.firstOrNull()?.index
|
|
||||||
}
|
|
||||||
|
|
||||||
val subtitleIndex =
|
val subtitleIndex =
|
||||||
determineSubtitleIndex(
|
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
|
||||||
subtitleStreams = subtitleStreams,
|
?.index
|
||||||
subtitleMode = preferences.userConfig.subtitleMode,
|
|
||||||
subtitleLanguage = preferences.userConfig.subtitleLanguagePreference,
|
|
||||||
audioLanguage = audioLanguage,
|
|
||||||
playbackConfig = playbackConfig,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Timber.v("base.mediaStreams=${base.mediaStreams}")
|
// Timber.v("base.mediaStreams=${base.mediaStreams}")
|
||||||
// Timber.v("subtitleTracks=$subtitleStreams")
|
// Timber.v("subtitleTracks=$subtitleStreams")
|
||||||
// Timber.v("audioStreams=$audioStreams")
|
// Timber.v("audioStreams=$audioStreams")
|
||||||
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||||
|
|
||||||
|
val itemPlaybackToUse =
|
||||||
|
playbackConfig ?: ItemPlayback(
|
||||||
|
rowId = -1,
|
||||||
|
userId = -1,
|
||||||
|
itemId = base.id,
|
||||||
|
sourceId = mediaSource.id?.toUUIDOrNull(),
|
||||||
|
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
||||||
|
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@PlaybackViewModel.audioStreams.value = audioStreams
|
this@PlaybackViewModel.audioStreams.value = audioStreams
|
||||||
|
|
@ -346,7 +327,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
changeStreams(
|
changeStreams(
|
||||||
base,
|
base,
|
||||||
playbackConfig,
|
itemPlaybackToUse,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
if (positionMs > 0) positionMs else C.TIME_UNSET,
|
if (positionMs > 0) positionMs else C.TIME_UNSET,
|
||||||
|
|
@ -434,6 +415,10 @@ class PlaybackViewModel
|
||||||
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
|
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
|
||||||
Timber.v("Playback decision: $decision")
|
Timber.v("Playback decision: $decision")
|
||||||
|
|
||||||
|
val externalSubtitleCount =
|
||||||
|
source.mediaStreams
|
||||||
|
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
|
||||||
|
|
||||||
val externalSubtitle =
|
val externalSubtitle =
|
||||||
source.mediaStreams
|
source.mediaStreams
|
||||||
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == subtitleIndex && it.isExternal }
|
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == subtitleIndex && it.isExternal }
|
||||||
|
|
@ -519,6 +504,7 @@ class PlaybackViewModel
|
||||||
source,
|
source,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
|
externalSubtitleCount,
|
||||||
)
|
)
|
||||||
player.removeListener(this)
|
player.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
@ -731,7 +717,7 @@ data class CurrentPlayback(
|
||||||
val playSessionId: String?,
|
val playSessionId: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val Format.idAsInt: Int?
|
val Format.idAsInt: Int?
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
get() =
|
get() =
|
||||||
id?.let {
|
id?.let {
|
||||||
|
|
@ -748,6 +734,7 @@ private fun applyTrackSelections(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
audioIndex: Int?,
|
audioIndex: Int?,
|
||||||
subtitleIndex: Int?,
|
subtitleIndex: Int?,
|
||||||
|
externalSubtitleCount: Int,
|
||||||
) {
|
) {
|
||||||
if (source.supportsDirectPlay) {
|
if (source.supportsDirectPlay) {
|
||||||
if (subtitleIndex != null && subtitleIndex >= 0) {
|
if (subtitleIndex != null && subtitleIndex >= 0) {
|
||||||
|
|
@ -786,7 +773,7 @@ private fun applyTrackSelections(
|
||||||
(0..<group.mediaTrackGroup.length)
|
(0..<group.mediaTrackGroup.length)
|
||||||
.map {
|
.map {
|
||||||
group.getTrackFormat(it).idAsInt
|
group.getTrackFormat(it).idAsInt
|
||||||
}.contains(audioIndex + 1) // Indexes are 1 based
|
}.contains(audioIndex - externalSubtitleCount + 1) // Indexes are 1 based
|
||||||
}
|
}
|
||||||
Timber.v("Chosen audio track: $chosenTrack")
|
Timber.v("Chosen audio track: $chosenTrack")
|
||||||
chosenTrack?.let {
|
chosenTrack?.let {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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.ChosenStreams
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||||
|
import com.github.damontecres.wholphin.data.model.chooseStream
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
|
@ -65,16 +69,32 @@ fun formatSubtitleLang(mediaStreams: List<MediaStream>?): String? =
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.joinToString(", ") { languageName(it) }
|
?.joinToString(", ") { languageName(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the selected audio display title for the given item & chosen streams
|
||||||
|
*/
|
||||||
fun getAudioDisplay(
|
fun getAudioDisplay(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
) = (
|
preferences: UserPreferences,
|
||||||
chosenStreams?.audioStream
|
) = getAudioDisplay(item, chosenStreams?.itemPlayback, preferences)
|
||||||
?: item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
|
||||||
)?.displayTitle
|
/**
|
||||||
|
* Gets the selected audio display title for the given item & chosen streams
|
||||||
|
*/
|
||||||
|
fun getAudioDisplay(
|
||||||
|
item: BaseItemDto,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
preferences: UserPreferences,
|
||||||
|
) = chooseStream(item, itemPlayback, MediaStreamType.AUDIO, preferences)
|
||||||
|
?.displayTitle
|
||||||
?.replace(" - Default", "")
|
?.replace(" - Default", "")
|
||||||
?.ifBlank { null }
|
?.ifBlank { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the selected subtitle language for the given item & chosen streams
|
||||||
|
*
|
||||||
|
* If none are chosen, returns a concatenated list of languages available
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun getSubtitleDisplay(
|
fun getSubtitleDisplay(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
|
|
@ -84,5 +104,7 @@ fun getSubtitleDisplay(
|
||||||
} else if (chosenStreams?.subtitleStream != null) {
|
} else if (chosenStreams?.subtitleStream != null) {
|
||||||
languageName(chosenStreams.subtitleStream.language)
|
languageName(chosenStreams.subtitleStream.language)
|
||||||
} else {
|
} else {
|
||||||
formatSubtitleLang(item.mediaStreams)
|
chooseSource(item, chosenStreams?.itemPlayback)?.let {
|
||||||
|
formatSubtitleLang(it.mediaStreams)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.idAsInt
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
@ -118,11 +119,20 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> =
|
||||||
} else {
|
} else {
|
||||||
it.value
|
it.value
|
||||||
}
|
}
|
||||||
|
} +
|
||||||
|
if (type == TrackType.VIDEO) {
|
||||||
|
listOf("res=${format.width}x${format.height}")
|
||||||
|
} else if (type == TrackType.AUDIO) {
|
||||||
|
listOf("channels=${format.channelCount}", "lang=${format.language}")
|
||||||
|
} else if (type == TrackType.TEXT) {
|
||||||
|
listOf("lang=${format.language}")
|
||||||
|
} else {
|
||||||
|
listOf()
|
||||||
}
|
}
|
||||||
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
|
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
|
||||||
add(
|
add(
|
||||||
TrackSupport(
|
TrackSupport(
|
||||||
format.id,
|
format.id + " (${format.idAsInt})",
|
||||||
type,
|
type,
|
||||||
reason,
|
reason,
|
||||||
it.isSelected,
|
it.isSelected,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue