diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json new file mode 100644 index 00000000..f3b128c5 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json @@ -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')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 65dd47bb..fe6e7620 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -9,6 +9,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo @@ -30,8 +31,9 @@ import java.util.UUID NavDrawerPinnedItem::class, LibraryDisplayInfo::class, PlaybackLanguageChoice::class, + ItemTrackModification::class, ], - version = 11, + version = 12, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -42,6 +44,7 @@ import java.util.UUID AutoMigration(8, 9), AutoMigration(9, 10), AutoMigration(10, 11), + AutoMigration(11, 12), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt index e0743040..3b660c9a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query 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 java.util.UUID @@ -26,4 +27,20 @@ interface ItemPlaybackDao { @Query("SELECT * from ItemPlayback WHERE userId=:userId") fun getItems(userId: Int): List + + @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId") + suspend fun getTrackModifications( + userId: Int, + itemId: UUID, + ): List + + @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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 35254c00..62e922d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -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.ItemPlayback +import com.github.damontecres.wholphin.data.model.ItemTrackModification import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences @@ -16,6 +17,7 @@ import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration @Singleton class ItemPlaybackRepository @@ -179,6 +181,32 @@ class ItemPlaybackRepository val id = itemPlaybackDao.saveItem(toSave) 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt new file mode 100644 index 00000000..e8bbfeb4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt index 3295c686..9c62f17b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt @@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.util.TrackSupport import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.TranscodingInfo +import kotlin.time.Duration data class CurrentPlayback( val item: BaseItem, @@ -18,4 +19,5 @@ data class CurrentPlayback( val videoDecoder: String? = null, val audioDecoder: String? = null, val transcodeInfo: TranscodingInfo? = null, + val subtitleDelay: Duration = Duration.ZERO, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 1b3e97a4..f6a42f64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -1,13 +1,26 @@ package com.github.damontecres.wholphin.ui.playback 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.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalView 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.data.model.TrackIndex +import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import timber.log.Timber +import kotlin.time.Duration enum class PlaybackDialogType { MORE, @@ -16,6 +29,7 @@ enum class PlaybackDialogType { AUDIO, PLAYBACK_SPEED, VIDEO_SCALE, + SUBTITLE_DELAY, } data class PlaybackSettings( @@ -26,16 +40,19 @@ data class PlaybackSettings( val subtitleStreams: List, val playbackSpeed: Float, val contentScale: ContentScale, + val subtitleDelay: Duration, ) @Composable fun PlaybackDialog( + enableSubtitleDelay: Boolean, type: PlaybackDialogType, settings: PlaybackSettings, onDismissRequest: () -> Unit, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit, + onChangeSubtitleDelay: (Duration) -> Unit, ) { when (type) { PlaybackDialogType.MORE -> { @@ -97,11 +114,14 @@ fun PlaybackDialog( PlaybackDialogType.SETTINGS -> { val options = - listOf( - stringResource(R.string.audio), - stringResource(R.string.playback_speed), - stringResource(R.string.video_scale), - ) + buildList { + add(stringResource(R.string.audio)) + add(stringResource(R.string.playback_speed)) + add(stringResource(R.string.video_scale)) + if (enableSubtitleDelay) { + add(stringResource(R.string.subtitle_delay)) + } + } BottomDialog( choices = options, currentChoice = null, @@ -111,6 +131,7 @@ fun PlaybackDialog( 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) + 3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY) } }, gravity = Gravity.END, @@ -173,5 +194,31 @@ fun PlaybackDialog( 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), + ) + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index d414a2be..c905e3f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Media3SubtitleOverride +import com.github.damontecres.wholphin.util.mpv.MpvPlayer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -163,6 +165,11 @@ fun PlaybackPage( var playbackSpeed by remember { mutableFloatStateOf(1.0f) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } + val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO + LaunchedEffect(subtitleDelay) { + (player as? MpvPlayer)?.subtitleDelay = subtitleDelay + } + val presentationState = rememberPresentationState(player, false) val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) @@ -543,6 +550,7 @@ fun PlaybackPage( subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), playbackSpeed = playbackSpeed, contentScale = contentScale, + subtitleDelay = subtitleDelay, ), onDismissRequest = { playbackDialog = null @@ -553,8 +561,16 @@ fun PlaybackPage( onControllerInteraction = { 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, + onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, + enableSubtitleDelay = player is MpvPlayer, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 88536176..e462a312 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -515,7 +515,7 @@ class PlaybackViewModel this@PlaybackViewModel.currentItemPlayback.value = itemPlayback } - + loadSubtitleDelay() return@withContext } } else { @@ -686,6 +686,7 @@ class PlaybackViewModel if (result.bothSelected) { player.removeListener(this) } + viewModelScope.launchIO { loadSubtitleDelay() } } } } @@ -1150,4 +1151,47 @@ class PlaybackViewModel Timber.d("decoder: onAudioDisabled") 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) } + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt new file mode 100644 index 00000000..17792899 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index d1bfd492..daef054b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -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 timber.log.Timber import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** @@ -799,7 +800,7 @@ class MpvPlayer( // no-op } - var subtitleDelay: Double + var subtitleDelaySeconds: Double get() { if (isReleased) return 0.0 return MPVLib.getPropertyDouble("sub-delay") ?: 0.0 @@ -808,6 +809,16 @@ class MpvPlayer( if (isReleased) return 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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84ca35c5..6532b865 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -393,6 +393,7 @@ Show favorite channels first Sort channels by recently watched Color-code programs + Subtitle delay Disabled