MPV: Adjust subtitle delay/offset (#470)

**This is for MPV playback backend only!** Since this only works in MPV
for now, the option is not shown if using ExoPlayer.

Adds a setting (the gear icon during playback) to adjust the subtitle
delay.

A negative delay shows subtitles sooner. A positive delay shows them
later. For example, if a subtitle is supposed to show between
1000ms-2000ms, a negative 250ms delay will show it at 750ms-1750ms
instead.

The delay is persisted between plays of the same media & subtitle track.

Ref: #12 


![subtitle_delay](https://github.com/user-attachments/assets/824e3a28-650a-4619-9535-b67addaabf1e)
This commit is contained in:
damontecres 2025-12-15 10:06:36 -05:00 committed by GitHub
parent 3f5ce703d2
commit ab8bbf2bd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 751 additions and 9 deletions

View file

@ -0,0 +1,434 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "e9bcae45448f995d1f4ebf291e45c510",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
},
{
"fieldPath": "pin",
"columnName": "pin",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e9bcae45448f995d1f4ebf291e45c510')"
]
}
}

View file

@ -9,6 +9,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
@ -30,8 +31,9 @@ import java.util.UUID
NavDrawerPinnedItem::class, NavDrawerPinnedItem::class,
LibraryDisplayInfo::class, LibraryDisplayInfo::class,
PlaybackLanguageChoice::class, PlaybackLanguageChoice::class,
ItemTrackModification::class,
], ],
version = 11, version = 12,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -42,6 +44,7 @@ import java.util.UUID
AutoMigration(8, 9), AutoMigration(8, 9),
AutoMigration(9, 10), AutoMigration(9, 10),
AutoMigration(10, 11), AutoMigration(10, 11),
AutoMigration(11, 12),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)

View file

@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import java.util.UUID import java.util.UUID
@ -26,4 +27,20 @@ interface ItemPlaybackDao {
@Query("SELECT * from ItemPlayback WHERE userId=:userId") @Query("SELECT * from ItemPlayback WHERE userId=:userId")
fun getItems(userId: Int): List<ItemPlayback> fun getItems(userId: Int): List<ItemPlayback>
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
suspend fun getTrackModifications(
userId: Int,
itemId: UUID,
): List<ItemTrackModification>
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId AND trackIndex=:trackIndex")
suspend fun getTrackModifications(
userId: Int,
itemId: UUID,
trackIndex: Int,
): ItemTrackModification
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveItem(item: ItemTrackModification): Long
} }

View file

@ -2,6 +2,7 @@ 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.ItemPlayback
import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -16,6 +17,7 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration
@Singleton @Singleton
class ItemPlaybackRepository class ItemPlaybackRepository
@ -179,6 +181,32 @@ class ItemPlaybackRepository
val id = itemPlaybackDao.saveItem(toSave) val id = itemPlaybackDao.saveItem(toSave)
return toSave.copy(rowId = id) return toSave.copy(rowId = id)
} }
suspend fun getTrackModifications(
itemId: UUID,
trackIndex: Int,
): ItemTrackModification? =
serverRepository.currentUser.value?.rowId?.let { userId ->
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
}
suspend fun saveTrackModifications(
itemId: UUID,
trackIndex: Int,
delay: Duration,
) {
serverRepository.currentUser.value?.rowId?.let { userId ->
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
itemPlaybackDao.saveItem(
ItemTrackModification(
userId,
itemId,
trackIndex,
delay.inWholeMilliseconds,
),
)
}
}
} }
data class ChosenStreams( data class ChosenStreams(

View file

@ -0,0 +1,30 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
primaryKeys = ["userId", "itemId", "trackIndex"],
)
@Serializable
data class ItemTrackModification(
val userId: Int,
val itemId: UUID,
val trackIndex: Int,
val delayMs: Long,
)

View file

@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.util.TrackSupport
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodingInfo import org.jellyfin.sdk.model.api.TranscodingInfo
import kotlin.time.Duration
data class CurrentPlayback( data class CurrentPlayback(
val item: BaseItem, val item: BaseItem,
@ -18,4 +19,5 @@ data class CurrentPlayback(
val videoDecoder: String? = null, val videoDecoder: String? = null,
val audioDecoder: String? = null, val audioDecoder: String? = null,
val transcodeInfo: TranscodingInfo? = null, val transcodeInfo: TranscodingInfo? = null,
val subtitleDelay: Duration = Duration.ZERO,
) )

View file

@ -1,13 +1,26 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
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.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration
enum class PlaybackDialogType { enum class PlaybackDialogType {
MORE, MORE,
@ -16,6 +29,7 @@ enum class PlaybackDialogType {
AUDIO, AUDIO,
PLAYBACK_SPEED, PLAYBACK_SPEED,
VIDEO_SCALE, VIDEO_SCALE,
SUBTITLE_DELAY,
} }
data class PlaybackSettings( data class PlaybackSettings(
@ -26,16 +40,19 @@ data class PlaybackSettings(
val subtitleStreams: List<SubtitleStream>, val subtitleStreams: List<SubtitleStream>,
val playbackSpeed: Float, val playbackSpeed: Float,
val contentScale: ContentScale, val contentScale: ContentScale,
val subtitleDelay: Duration,
) )
@Composable @Composable
fun PlaybackDialog( fun PlaybackDialog(
enableSubtitleDelay: Boolean,
type: PlaybackDialogType, type: PlaybackDialogType,
settings: PlaybackSettings, settings: PlaybackSettings,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onControllerInteraction: () -> Unit, onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
onChangeSubtitleDelay: (Duration) -> Unit,
) { ) {
when (type) { when (type) {
PlaybackDialogType.MORE -> { PlaybackDialogType.MORE -> {
@ -97,11 +114,14 @@ fun PlaybackDialog(
PlaybackDialogType.SETTINGS -> { PlaybackDialogType.SETTINGS -> {
val options = val options =
listOf( buildList {
stringResource(R.string.audio), add(stringResource(R.string.audio))
stringResource(R.string.playback_speed), add(stringResource(R.string.playback_speed))
stringResource(R.string.video_scale), add(stringResource(R.string.video_scale))
) if (enableSubtitleDelay) {
add(stringResource(R.string.subtitle_delay))
}
}
BottomDialog( BottomDialog(
choices = options, choices = options,
currentChoice = null, currentChoice = null,
@ -111,6 +131,7 @@ fun PlaybackDialog(
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
} }
}, },
gravity = Gravity.END, gravity = Gravity.END,
@ -173,5 +194,31 @@ fun PlaybackDialog(
gravity = Gravity.END, gravity = Gravity.END,
) )
} }
PlaybackDialogType.SUBTITLE_DELAY -> {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.setDimAmount(0f)
Box(
modifier =
Modifier
.wrapContentSize()
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(16.dp),
),
) {
SubtitleDelay(
delay = settings.subtitleDelay,
onChangeDelay = onChangeSubtitleDelay,
modifier = Modifier.padding(8.dp),
)
}
}
}
} }
} }

View file

@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -163,6 +165,11 @@ fun PlaybackPage(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
LaunchedEffect(subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
}
val presentationState = rememberPresentationState(player, false) val presentationState = rememberPresentationState(player, false)
val scaledModifier = val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
@ -543,6 +550,7 @@ fun PlaybackPage(
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
contentScale = contentScale, contentScale = contentScale,
subtitleDelay = subtitleDelay,
), ),
onDismissRequest = { onDismissRequest = {
playbackDialog = null playbackDialog = null
@ -553,8 +561,16 @@ fun PlaybackPage(
onControllerInteraction = { onControllerInteraction = {
controllerViewState.pulseControls(Long.MAX_VALUE) controllerViewState.pulseControls(Long.MAX_VALUE)
}, },
onClickPlaybackDialogType = { playbackDialog = it }, onClickPlaybackDialogType = {
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
// Hide controls so subtitles are fully visible
controllerViewState.hideControls()
}
playbackDialog = it
},
onPlaybackActionClick = onPlaybackActionClick, onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer,
) )
} }
} }

View file

@ -515,7 +515,7 @@ class PlaybackViewModel
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
} }
loadSubtitleDelay()
return@withContext return@withContext
} }
} else { } else {
@ -686,6 +686,7 @@ class PlaybackViewModel
if (result.bothSelected) { if (result.bothSelected) {
player.removeListener(this) player.removeListener(this)
} }
viewModelScope.launchIO { loadSubtitleDelay() }
} }
} }
} }
@ -1150,4 +1151,47 @@ class PlaybackViewModel
Timber.d("decoder: onAudioDisabled") Timber.d("decoder: onAudioDisabled")
currentPlayback.update { it?.copy(audioDecoder = null) } currentPlayback.update { it?.copy(audioDecoder = null) }
} }
private var subtitleDelaySaveJob: Job? = null
fun updateSubtitleDelay(delta: Duration) {
subtitleDelaySaveJob?.cancel()
currentPlayback.update {
it?.let {
val newDelay = it.subtitleDelay + delta
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
subtitleDelaySaveJob =
viewModelScope.launchIO {
// Debounce & save
currentItemPlayback.value?.let { item ->
delay(1500)
itemPlaybackRepository.saveTrackModifications(
item.itemId,
item.subtitleIndex,
newDelay,
)
}
}
result
}
}
}
suspend fun loadSubtitleDelay() {
currentItemPlayback.value?.let {
if (it.subtitleIndexEnabled) {
val result =
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
if (result != null) {
Timber.v(
"Loading subtitle delay %s for track=%s, itemId=%s",
result.delayMs,
it.subtitleIndex,
it.itemId,
)
currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
}
}
}
}
} }

View file

@ -0,0 +1,109 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
@Composable
fun SubtitleDelay(
delay: Duration,
onChangeDelay: (Duration) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
text = stringResource(R.string.subtitle_delay) + ": " + delay.toString(),
color = MaterialTheme.colorScheme.onSurface,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
delayIncrements.reversed().forEach {
SubtitleDelayButton(
text = "-$it",
onClick = { onChangeDelay.invoke(-it) },
modifier = Modifier,
)
}
SubtitleDelayButton(
text = stringResource(R.string.reset),
onClick = { onChangeDelay.invoke(-delay) },
modifier = Modifier.focusRequester(focusRequester),
)
delayIncrements.forEach {
SubtitleDelayButton(
text = "+$it",
onClick = { onChangeDelay.invoke(it) },
modifier = Modifier,
)
}
}
}
}
@Composable
fun SubtitleDelayButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Button(
onClick = onClick,
modifier = modifier.width(64.dp),
shape =
ClickableSurfaceDefaults.shape(
shape = RoundedCornerShape(33),
),
) {
Text(
text = text,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@PreviewTvSpec
@Composable
private fun SubtitleDelayPreview() {
WholphinTheme {
SubtitleDelay(
delay = 1.5.seconds,
onChangeDelay = {},
modifier = Modifier,
)
}
}

View file

@ -45,6 +45,7 @@ import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBA
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG
import timber.log.Timber import timber.log.Timber
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/** /**
@ -799,7 +800,7 @@ class MpvPlayer(
// no-op // no-op
} }
var subtitleDelay: Double var subtitleDelaySeconds: Double
get() { get() {
if (isReleased) return 0.0 if (isReleased) return 0.0
return MPVLib.getPropertyDouble("sub-delay") ?: 0.0 return MPVLib.getPropertyDouble("sub-delay") ?: 0.0
@ -808,6 +809,16 @@ class MpvPlayer(
if (isReleased) return if (isReleased) return
MPVLib.setPropertyDouble("sub-delay", value) MPVLib.setPropertyDouble("sub-delay", value)
} }
var subtitleDelay: Duration
get() {
if (isReleased) return Duration.ZERO
return subtitleDelaySeconds.seconds
}
set(value) {
if (isReleased) return
subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0
}
} }
fun MPVLib.setPropertyColor( fun MPVLib.setPropertyColor(

View file

@ -393,6 +393,7 @@
<string name="favorite_channels_at_beginning">Show favorite channels first</string> <string name="favorite_channels_at_beginning">Show favorite channels first</string>
<string name="sort_channels_recently_watched">Sort channels by recently watched</string> <string name="sort_channels_recently_watched">Sort channels by recently watched</string>
<string name="color_code_programs">Color-code programs</string> <string name="color_code_programs">Color-code programs</string>
<string name="subtitle_delay">Subtitle delay</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>