From 7644b4c586ea0d0d9e1f1361bc95a30511bfaebc Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:00:06 -0500 Subject: [PATCH 001/124] Minor UI changes (#407) - Semi-bold the movies or series title on the details pages to match home page - Shows total subtitle track count if subtitles disabled - Remove the springy animations from the play/watch/favorite/more buttons - Go back a page when playback finishes and there's nothing left in the queue Closes #393 --- .../wholphin/ui/components/PlayButtons.kt | 66 ++++++++----------- .../ui/components/SeriesComponents.kt | 2 + .../ui/components/VideoStreamDetails.kt | 57 ++++++++++------ .../ui/detail/movie/MovieDetailsHeader.kt | 2 + .../wholphin/ui/playback/PlaybackViewModel.kt | 2 +- 5 files changed, 70 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 6a14fdbf..0f99f8bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -78,39 +78,37 @@ fun ExpandablePlayButtons( if (resumePosition > Duration.ZERO) { item("play") { ExpandablePlayButton( - R.string.resume, - resumePosition, - Icons.Default.PlayArrow, - playOnClick, - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus) - .animateItem(), + title = R.string.resume, + resume = resumePosition, + icon = Icons.Default.PlayArrow, + onClick = playOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus), ) } item("restart") { ExpandablePlayButton( - R.string.restart, - Duration.ZERO, - Icons.Default.Refresh, - playOnClick, - Modifier - .onFocusChanged(buttonOnFocusChanged) - .animateItem(), + title = R.string.restart, + resume = Duration.ZERO, + icon = Icons.Default.Refresh, + onClick = playOnClick, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), mirrorIcon = true, ) } } else { item("play") { ExpandablePlayButton( - R.string.play, - Duration.ZERO, - Icons.Default.PlayArrow, - playOnClick, - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus) - .animateItem(), + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = playOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus), ) } } @@ -121,10 +119,7 @@ fun ExpandablePlayButtons( title = if (watched) R.string.mark_unwatched else R.string.mark_watched, iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, onClick = watchOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .animateItem(), + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), ) } @@ -135,23 +130,18 @@ fun ExpandablePlayButtons( iconStringRes = R.string.fa_heart, onClick = favoriteOnClick, iconColor = if (favorite) Color.Red else Color.Unspecified, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .animateItem(), + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), ) } // More button item("more") { ExpandablePlayButton( - R.string.more, - Duration.ZERO, - Icons.Default.MoreVert, - { moreOnClick.invoke() }, - Modifier - .onFocusChanged(buttonOnFocusChanged) - .animateItem(), + title = R.string.more, + resume = Duration.ZERO, + icon = Icons.Default.MoreVert, + onClick = { moreOnClick.invoke() }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index 36cbc904..bdb94ba5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -23,6 +24,7 @@ fun SeriesName( text = seriesName ?: "", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 44e2705d..9d83bc59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -9,7 +9,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -103,14 +106,16 @@ fun VideoStreamDetails( } val audioCount = remember(source) { source?.audioStreamCount ?: 0 } val audio = - if (audioCount == 0 || audioStream == null) { - stringResource(R.string.none) - } else { - listOfNotNull( - languageName(audioStream.language), - formatAudioCodec(context, audioStream.codec, audioStream.profile), - audioStream.channelLayout, - ).joinToString(" ") + remember(audioCount, audioStream) { + if (audioCount == 0 || audioStream == null) { + context.getString(R.string.none) + } else { + listOfNotNull( + languageName(audioStream.language), + formatAudioCodec(context, audioStream.codec, audioStream.profile), + audioStream.channelLayout, + ).joinToString(" ") + } } StreamLabel( text = audio, @@ -132,17 +137,22 @@ fun VideoStreamDetails( remember(source) { (source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0) } + var disabled by remember { mutableStateOf(false) } val subtitle = - if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { - stringResource(R.string.disabled) - } else if (subtitleCount == 0 || subtitleStream == null) { - null - } else { - listOfNotNull( - languageName(subtitleStream.language), - "SDH".takeIf { subtitleStream.isHearingImpaired }, - formatSubtitleCodec(subtitleStream.codec), - ).joinToString(" ") + remember(subtitleCount, subtitleStream, itemPlayback) { + if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { + disabled = true + context.getString(R.string.disabled) + } else if (subtitleCount == 0 || subtitleStream == null) { + null + } else { + disabled = false + listOfNotNull( + languageName(subtitleStream.language), + "SDH".takeIf { subtitleStream.isHearingImpaired }, + formatSubtitleCodec(subtitleStream.codec), + ).joinToString(" ") + } } subtitle?.let { StreamLabel( @@ -150,6 +160,7 @@ fun VideoStreamDetails( count = subtitleCount, icon = R.string.fa_closed_captioning, modifier = Modifier.widthIn(max = 160.dp), + disabled = disabled, ) } } @@ -190,6 +201,7 @@ fun StreamLabel( modifier: Modifier = Modifier, @StringRes icon: Int? = null, count: Int = 0, + disabled: Boolean = false, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -222,9 +234,10 @@ fun StreamLabel( overflow = TextOverflow.Ellipsis, modifier = Modifier, ) - if (count > 1) { + val countToUse = if (disabled) count else count - 1 + if (countToUse > 0) { Text( - text = "(+${count - 1})", + text = "(+$countToUse)", maxLines = 1, modifier = Modifier, ) @@ -248,6 +261,10 @@ private fun StreamLabelPreview() { StreamLabel("HDR") StreamLabel("H264") StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2) + + StreamLabel("PGS", count = 1) + StreamLabel("PGS", count = 1, disabled = true) + StreamLabel("PGS", count = 2) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 71115a9a..fcd15f7e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -53,6 +54,7 @@ fun MovieDetailsHeader( text = movie.name ?: "", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth(.75f), 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 2f08ee96..5672d673 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 @@ -722,7 +722,7 @@ class PlaybackViewModel withContext(Dispatchers.Main) { nextUp.value = nextItem if (nextItem == null) { - controllerViewState.showControls() + navigationManager.goBack() } } } From 033bdccaa2fd8cdb1a9e1850ba172660cbce9086 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:06:12 -0500 Subject: [PATCH 002/124] Add Google Play Store link to readme Added a link to the Google Play Store for Wholphin. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fd07c32d..86bc01ea 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,17 @@ Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).

-Help get Wholphin listed on the Play Store -
-
Current Release Translation status +
+ +Get Wholphin on Google Play + +

0_3_5_home From 0f4254c6ec0f89ec7bef79b75a4938dc6d4a38df Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:16:45 -0500 Subject: [PATCH 003/124] Persist audio & subtitle track changes for series if in different language (#405) This only applies if the user has set a preferred audio and/or subtitle language on the web UI! If the user chooses an audio/subtitle track that is a different language than their preferred, it will be remembered for that series. Playback of future episodes of the series will pick a matching language. The audio & subtitle selections are independent. You can make the choice before playback or during playback. Closes #378 --- .../11.json | 383 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 15 +- .../wholphin/data/ItemPlaybackRepository.kt | 142 +++++-- .../data/PlaybackLanguageChoiceDao.kt | 20 + .../wholphin/data/model/ItemPlayback.kt | 125 ------ .../data/model/PlaybackLanguageChoice.kt | 32 ++ .../wholphin/services/StreamChoiceService.kt | 227 +++++++++++ .../services/UserPreferencesService.kt | 27 ++ .../wholphin/services/hilt/DatabaseModule.kt | 5 + .../ui/components/VideoStreamDetails.kt | 64 ++- .../ui/detail/episode/EpisodeDetails.kt | 43 +- .../ui/detail/episode/EpisodeDetailsHeader.kt | 4 +- .../ui/detail/episode/EpisodeViewModel.kt | 15 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 43 +- .../ui/detail/movie/MovieDetailsHeader.kt | 4 +- .../ui/detail/movie/MovieViewModel.kt | 19 +- .../ui/detail/series/FocusedEpisodeHeader.kt | 4 +- .../ui/detail/series/SeriesOverview.kt | 43 +- .../ui/detail/series/SeriesViewModel.kt | 19 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 77 ++-- .../ui/playback/SubtitleSearchUtils.kt | 3 +- 21 files changed, 990 insertions(+), 324 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json new file mode 100644 index 00000000..bb9e1bd6 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json @@ -0,0 +1,383 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "26bbe361923e3aac064c08c52e65264e", + "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" + ] + } + ] + } + ], + "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, '26bbe361923e3aac064c08c52e65264e')" + ] + } +} 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 5dd76067..65dd47bb 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 @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy @@ -22,8 +23,15 @@ import timber.log.Timber import java.util.UUID @Database( - entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], - version = 10, + entities = [ + JellyfinServer::class, + JellyfinUser::class, + ItemPlayback::class, + NavDrawerPinnedItem::class, + LibraryDisplayInfo::class, + PlaybackLanguageChoice::class, + ], + version = 11, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -33,6 +41,7 @@ import java.util.UUID AutoMigration(7, 8), AutoMigration(8, 9), AutoMigration(9, 10), + AutoMigration(10, 11), ], ) @TypeConverters(Converters::class) @@ -44,6 +53,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun serverPreferencesDao(): ServerPreferencesDao abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao + + abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao } class Converters { 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 97537146..374aa75c 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,10 +2,13 @@ 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.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex -import com.github.damontecres.wholphin.data.model.chooseSource +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +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.serializer.toUUIDOrNull @@ -20,47 +23,59 @@ class ItemPlaybackRepository constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + private val streamChoiceService: StreamChoiceService, ) { - fun getSelectedTracks( + suspend fun getSelectedTracks( itemId: UUID, item: BaseItem, + prefs: UserPreferences, ): ChosenStreams? = serverRepository.currentUser.value?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) - if (itemPlayback != null) { - Timber.v("Got itemPlayback for %s", itemId) - getChosenItemFromPlayback(item, itemPlayback) - } else { - null - } + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) + Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}") + return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) } fun getChosenItemFromPlayback( item: BaseItem, - itemPlayback: ItemPlayback, + itemPlayback: ItemPlayback?, + plc: PlaybackLanguageChoice?, + prefs: UserPreferences, ): ChosenStreams? { val source = - item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } + item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId } + ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty()) if (source != null) { val audioStream = - if (itemPlayback.audioIndexEnabled) { - source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex } - } else { - null - } + streamChoiceService.chooseAudioStream( + candidates = + source.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + .orEmpty(), + itemPlayback = itemPlayback, + playbackLanguageChoice = plc, + prefs = prefs, + ) val subtitleStream = - if (itemPlayback.subtitleIndexEnabled) { - source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex } - } else { - null - } + streamChoiceService.chooseSubtitleStream( + candidates = + source.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } + .orEmpty(), + itemPlayback = itemPlayback, + playbackLanguageChoice = plc, + prefs = prefs, + ) return ChosenStreams( - itemPlayback, - item.id, - source.id?.toUUIDOrNull(), - audioStream, - subtitleStream, - itemPlayback.subtitleIndex == TrackIndex.DISABLED, + itemPlayback = itemPlayback, + plc = plc, + itemId = item.id, + source = source, + videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, + audioStream = audioStream, + subtitleStream = subtitleStream, + subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, ) } else { return null @@ -89,22 +104,61 @@ class ItemPlaybackRepository itemPlayback: ItemPlayback?, trackIndex: Int, type: MediaStreamType, - ) = serverRepository.currentUser.value?.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 + ): ItemPlayback = + serverRepository.current.value!!.let { current -> + val source = + itemPlayback?.sourceId?.let { sourceId -> + item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId } + } ?: streamChoiceService.chooseSource(item.data, null) + if (source == null) { + Timber.w("Could not find media source for ${item.id}") + throw IllegalArgumentException("Could not find media source for ${item.id}") } - Timber.v("Saving track selection %s", toSave) - saveItemPlayback(toSave) - } + var toSave = + itemPlayback ?: ItemPlayback( + userId = current.user.rowId, + itemId = item.id, + sourceId = source.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) + toSave = saveItemPlayback(toSave) + val seriesId = item.data.seriesId + if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) { + if (type == MediaStreamType.AUDIO) { + val audioLang = current.userDto.configuration?.audioLanguagePreference + val stream = source.mediaStreams?.first { it.index == trackIndex } + if (stream?.language != null && stream.language != audioLang) { + streamChoiceService.updateAudio(item.data, stream.language!!) + } + } else if (type == MediaStreamType.SUBTITLE) { + if (trackIndex == TrackIndex.DISABLED) { + streamChoiceService.updateSubtitles( + item.data, + subtitleLang = null, + subtitlesDisabled = true, + ) + } else { + val subtitleLang = + current.userDto.configuration?.subtitleLanguagePreference + val stream = source.mediaStreams?.first { it.index == trackIndex } + if (stream?.language != null && stream.language != subtitleLang) { + streamChoiceService.updateSubtitles( + item.data, + stream.language!!, + subtitlesDisabled = false, + ) + } + } + } + } + toSave + } /** * Saves the [ItemPlayback] into the database, returning the same object with the rowId updated if needed @@ -127,9 +181,11 @@ class ItemPlaybackRepository } data class ChosenStreams( - val itemPlayback: ItemPlayback, + val itemPlayback: ItemPlayback?, + val plc: PlaybackLanguageChoice?, val itemId: UUID, - val sourceId: UUID?, + val source: MediaSourceInfo, + val videoStream: MediaStream?, val audioStream: MediaStream?, val subtitleStream: MediaStream?, val subtitlesDisabled: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt new file mode 100644 index 00000000..e268a08c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt @@ -0,0 +1,20 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import java.util.UUID + +@Dao +interface PlaybackLanguageChoiceDao { + @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId") + suspend fun get( + userId: Int, + seriesId: UUID, + ): PlaybackLanguageChoice? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(plc: PlaybackLanguageChoice): Long +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index e70f7c78..83f2d5df 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -7,19 +7,10 @@ import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.letNotEmpty import kotlinx.serialization.Serializable import kotlinx.serialization.Transient 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.UUIDSerializer -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID @Entity( @@ -53,122 +44,6 @@ data class ItemPlayback( val subtitleIndexEnabled = subtitleIndex >= 0 } -/** - * Returns the [MediaSourceInfo] with the highest video resolution - */ -fun chooseSource(sources: List?) = - 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, - 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 { 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, - 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 { /** * The user has not explicitly specified a track to use diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt new file mode 100644 index 00000000..f452e2f1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt @@ -0,0 +1,32 @@ +@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", "seriesId"], +) +@Serializable +data class PlaybackLanguageChoice( + val userId: Int, + val seriesId: UUID, + val itemId: UUID? = null, + val audioLanguage: String? = null, + val subtitleLanguage: String? = null, + val subtitlesDisabled: Boolean? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt new file mode 100644 index 00000000..7c10c549 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -0,0 +1,227 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.letNotEmpty +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 timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StreamChoiceService + @Inject + constructor( + private val serverRepository: ServerRepository, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + ) { + suspend fun updateAudio( + dto: BaseItemDto, + audioLang: String, + ) = update(dto) { + it.copy( + audioLanguage = audioLang, + ) + } + + suspend fun updateSubtitles( + dto: BaseItemDto, + subtitleLang: String?, + subtitlesDisabled: Boolean, + ) = update(dto) { + it.copy( + subtitleLanguage = if (subtitlesDisabled) null else subtitleLang, + subtitlesDisabled = subtitlesDisabled, + ) + } + + suspend fun update( + dto: BaseItemDto, + update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice, + ) { + val seriesId = dto.seriesId + if (seriesId != null) { + val userId = serverRepository.currentUser.value!!.rowId + val currentPlc = + playbackLanguageChoiceDao.get(userId, seriesId) + ?: PlaybackLanguageChoice(userId, seriesId, dto.id) + val newPlc = update.invoke(currentPlc) + Timber.v("Saving series PLC: %s", newPlc) + playbackLanguageChoiceDao.save(newPlc) + } + } + + suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = + dto.seriesId?.let { + playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) + } + + /** + * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution + */ + fun chooseSource( + dto: BaseItemDto, + itemPlayback: ItemPlayback?, + ): MediaSourceInfo? = + itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } } + ?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull() + + /** + * Returns the [MediaSourceInfo] with the highest video resolution + */ + fun chooseSource(sources: List?) = + 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 + } + + suspend fun chooseStream( + source: MediaSourceInfo, + seriesId: UUID?, + itemPlayback: ItemPlayback?, + plc: PlaybackLanguageChoice?, + type: MediaStreamType, + prefs: UserPreferences, + ): MediaStream? { + val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) } + return source.mediaStreams?.letNotEmpty { streams -> + val candidates = streams.filter { it.type == type } + when (type) { + MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, plc, prefs) + MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, plc, prefs) + else -> candidates.firstOrNull() + } + } + } + + fun chooseAudioStream( + candidates: List, + itemPlayback: ItemPlayback?, + playbackLanguageChoice: PlaybackLanguageChoice?, + 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 = + if (prefs.userConfig.audioLanguagePreference.isNotNullOrBlank()) { + // If the user has chosen a preferred language, but changed tracks on the series, use that + // Otherwise, use their preferred language + playbackLanguageChoice?.audioLanguage + ?: prefs.userConfig.audioLanguagePreference + } else { + null + } + if (audioLanguage.isNotNullOrBlank()) { + val sorted = + candidates.sortedWith(compareBy { 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, + itemPlayback: ItemPlayback?, + playbackLanguageChoice: PlaybackLanguageChoice?, + 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 = + if (prefs.userConfig.subtitleLanguagePreference.isNotNullOrBlank()) { + // If the user has chosen a preferred language, but changed tracks on the series, use that + // Otherwise, use their preferred language + playbackLanguageChoice?.subtitleLanguage + ?: prefs.userConfig.subtitleLanguagePreference + } else { + null + } + + val subtitleMode = + when { + playbackLanguageChoice?.subtitlesDisabled == false && + playbackLanguageChoice.subtitleLanguage != null && + subtitleLanguage.isNotNullOrBlank() -> { + // User has a subtitle language preference, but has chosen a different language for the series + // So override their normal playback mode to always display subtitles + SubtitlePlaybackMode.ALWAYS + } + + playbackLanguageChoice?.subtitlesDisabled == true -> { + // Series level settings disables subtitles + SubtitlePlaybackMode.NONE + } + + else -> { + // Fallback to the user's preference + prefs.userConfig.subtitleMode + } + } + return when (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 + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt new file mode 100644 index 00000000..a59f287b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.services + +import androidx.datastore.core.DataStore +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.UserPreferences +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UserPreferencesService + @Inject + constructor( + private val serverRepository: ServerRepository, + private val preferencesDataStore: DataStore, + ) { + suspend fun getCurrent(): UserPreferences = + serverRepository.currentUserDto.value!!.configuration.let { userConfig -> + val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + UserPreferences( + appPrefs, + userConfig ?: DefaultUserConfiguration, + ) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index dbf61912..30fb4422 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -56,6 +57,10 @@ object DatabaseModule { @Singleton fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + @Provides + @Singleton + fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 9d83bc59..0aff93fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -26,12 +27,8 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.ItemPlayback -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.data.ChosenStreams import com.github.damontecres.wholphin.preferences.AppThemeColors -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.playback.audioStreamCount @@ -40,16 +37,30 @@ import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.languageName import com.github.damontecres.wholphin.util.profile.Codec -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType @Composable +@NonRestartableComposable fun VideoStreamDetails( - preferences: UserPreferences, - dto: BaseItemDto, - itemPlayback: ItemPlayback?, + chosenStreams: ChosenStreams?, + modifier: Modifier = Modifier, +) = VideoStreamDetails( + chosenStreams?.source, + chosenStreams?.videoStream, + chosenStreams?.audioStream, + chosenStreams?.subtitleStream, + modifier, +) + +@Composable +fun VideoStreamDetails( + source: MediaSourceInfo?, + videoStream: MediaStream?, + audioStream: MediaStream?, + subtitleStream: MediaStream?, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -57,17 +68,6 @@ fun VideoStreamDetails( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - val source = remember(dto, itemPlayback) { chooseSource(dto, itemPlayback) } - - val videoStream = - remember(dto, itemPlayback) { - chooseStream( - dto, - itemPlayback, - MediaStreamType.VIDEO, - preferences, - ) - } val video = remember(videoStream) { videoStream @@ -95,15 +95,6 @@ fun VideoStreamDetails( StreamLabel(it) } - val audioStream = - remember(dto, itemPlayback) { - chooseStream( - dto, - itemPlayback, - MediaStreamType.AUDIO, - preferences, - ) - } val audioCount = remember(source) { source?.audioStreamCount ?: 0 } val audio = remember(audioCount, audioStream) { @@ -124,23 +115,14 @@ fun VideoStreamDetails( modifier = Modifier.widthIn(max = 200.dp), ) - val subtitleStream = - remember(dto, itemPlayback) { - chooseStream( - dto, - itemPlayback, - MediaStreamType.SUBTITLE, - preferences, - ) - } val subtitleCount = remember(source) { (source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0) } var disabled by remember { mutableStateOf(false) } val subtitle = - remember(subtitleCount, subtitleStream, itemPlayback) { - if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { + remember(subtitleCount, subtitleStream) { + if (subtitleCount > 0 && subtitleStream == null) { disabled = true context.getString(R.string.disabled) } else if (subtitleCount == 0 || subtitleStream == null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index cd83bde4..cfcc4284 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -30,7 +30,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogParams @@ -57,6 +56,7 @@ import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -157,7 +157,7 @@ fun EpisodeDetails( watched = ep.data.userData?.played ?: false, favorite = ep.data.userData?.isFavorite ?: false, seriesId = ep.data.seriesId, - sourceId = chosenStreams?.sourceId, + sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), actions = moreActions, onChooseVersion = { chooseVersion = @@ -174,25 +174,26 @@ fun EpisodeDetails( moreDialog = null }, onChooseTracks = { type -> - chooseSource( - ep.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - context = context, - streams = source.mediaStreams.orEmpty(), - type = type, - onClick = { trackIndex -> - viewModel.saveTrackSelection( - ep, - chosenStreams?.itemPlayback, - trackIndex, - type, - ) - }, - ) - } + viewModel.streamChoiceService + .chooseSource( + ep.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + context = context, + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + ep, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } }, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index 126f9f30..d3c77b67 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -58,9 +58,7 @@ fun EpisodeDetailsHeader( EpisodeQuickDetails(dto) VideoStreamDetails( - preferences = preferences, - dto = dto, - itemPlayback = chosenStreams?.itemPlayback, + chosenStreams = chosenStreams, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index b07e4b5c..ad113a9d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -12,7 +12,9 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -44,8 +46,10 @@ class EpisodeViewModel private val navigationManager: NavigationManager, val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, + val streamChoiceService: StreamChoiceService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, + private val userPreferencesService: UserPreferencesService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -85,8 +89,9 @@ class EpisodeViewModel "Error fetching movie", ), ) { + val prefs = userPreferencesService.getCurrent() val item = fetchAndSetItem().await() - val result = itemPlaybackRepository.getSelectedTracks(item.id, item) + val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) withContext(Dispatchers.Main) { this@EpisodeViewModel.item.value = item chosenStreams.value = result @@ -115,10 +120,12 @@ class EpisodeViewModel sourceId: UUID, ) { viewModelScope.launchIO { + val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } withContext(Dispatchers.Main) { chosenStreams.value = chosen @@ -133,6 +140,8 @@ class EpisodeViewModel type: MediaStreamType, ) { viewModelScope.launchIO { + val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -142,7 +151,7 @@ class EpisodeViewModel ) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } withContext(Dispatchers.Main) { chosenStreams.value = chosen diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index aafc4c03..88f5cce7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -45,7 +45,6 @@ import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.aspectRatioFloat -import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.AspectRatios @@ -83,6 +82,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -204,7 +204,7 @@ fun MovieDetails( watched = movie.data.userData?.played ?: false, favorite = movie.data.userData?.isFavorite ?: false, seriesId = null, - sourceId = chosenStreams?.sourceId, + sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), actions = moreActions, onChooseVersion = { chooseVersion = @@ -221,25 +221,26 @@ fun MovieDetails( moreDialog = null }, onChooseTracks = { type -> - chooseSource( - movie.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - context = context, - streams = source.mediaStreams.orEmpty(), - type = type, - onClick = { trackIndex -> - viewModel.saveTrackSelection( - movie, - chosenStreams?.itemPlayback, - trackIndex, - type, - ) - }, - ) - } + viewModel.streamChoiceService + .chooseSource( + movie.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + context = context, + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + movie, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } }, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index fcd15f7e..c976e79b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -72,9 +72,7 @@ fun MovieDetailsHeader( } VideoStreamDetails( - preferences = preferences, - dto = dto, - itemPlayback = chosenStreams?.itemPlayback, + chosenStreams = chosenStreams, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 17a68afc..4634aafa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -54,11 +56,13 @@ class MovieViewModel private val navigationManager: NavigationManager, val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, + val streamChoiceService: StreamChoiceService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val peopleFavorites: PeopleFavorites, private val trailerService: TrailerService, private val extrasService: ExtrasService, + private val userPreferencesService: UserPreferencesService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -104,7 +108,12 @@ class MovieViewModel ), ) { val item = fetchAndSetItem().await() - val result = itemPlaybackRepository.getSelectedTracks(item.id, item) + val result = + itemPlaybackRepository.getSelectedTracks( + item.id, + item, + userPreferencesService.getCurrent(), + ) withContext(Dispatchers.Main) { this@MovieViewModel.item.value = item chosenStreams.value = result @@ -172,10 +181,12 @@ class MovieViewModel sourceId: UUID, ) { viewModelScope.launchIO { + val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } withContext(Dispatchers.Main) { chosenStreams.value = chosen @@ -190,6 +201,8 @@ class MovieViewModel type: MediaStreamType, ) { viewModelScope.launchIO { + val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -199,7 +212,7 @@ class MovieViewModel ) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } withContext(Dispatchers.Main) { chosenStreams.value = chosen diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 30c95c95..5b25783b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -37,9 +37,7 @@ fun FocusedEpisodeHeader( if (dto != null) { VideoStreamDetails( - preferences = preferences, - dto = dto, - itemPlayback = chosenStreams?.itemPlayback, + chosenStreams = chosenStreams, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 61ef8d6b..54988b6d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.map import com.github.damontecres.wholphin.R 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.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.DialogParams @@ -53,6 +52,7 @@ import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -219,7 +219,7 @@ fun SeriesOverview( watched = ep.data.userData?.played ?: false, favorite = ep.data.userData?.isFavorite ?: false, seriesId = series.id, - sourceId = chosenStreams?.sourceId, + sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -257,25 +257,26 @@ fun SeriesOverview( moreDialog = null }, onChooseTracks = { type -> - chooseSource( - ep.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - context = context, - streams = source.mediaStreams.orEmpty(), - type = type, - onClick = { trackIndex -> - viewModel.saveTrackSelection( - ep, - chosenStreams?.itemPlayback, - trackIndex, - type, - ) - }, - ) - } + viewModel.streamChoiceService + .chooseSource( + ep.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + context = context, + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + ep, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } }, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 4185cb0b..35837c05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull @@ -70,6 +72,8 @@ class SeriesViewModel private val peopleFavorites: PeopleFavorites, private val trailerService: TrailerService, private val extrasService: ExtrasService, + val streamChoiceService: StreamChoiceService, + private val userPreferencesService: UserPreferencesService, ) : ItemViewModel(api) { private lateinit var seriesId: UUID private lateinit var prefs: UserPreferences @@ -372,7 +376,12 @@ class SeriesViewModel chosenStreamsJob?.cancel() chosenStreamsJob = viewModelScope.launchIO { - val result = itemPlaybackRepository.getSelectedTracks(itemId, item) + val result = + itemPlaybackRepository.getSelectedTracks( + itemId, + item, + userPreferencesService.getCurrent(), + ) withContext(Dispatchers.Main) { chosenStreams.value = result } @@ -384,10 +393,12 @@ class SeriesViewModel sourceId: UUID, ) { viewModelScope.launchIO { + val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } withContext(Dispatchers.Main) { chosenStreams.value = chosen @@ -402,6 +413,8 @@ class SeriesViewModel type: MediaStreamType, ) { viewModelScope.launchIO { + val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -411,7 +424,7 @@ class SeriesViewModel ) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } withContext(Dispatchers.Main) { chosenStreams.value = chosen 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 5672d673..a60f6c00 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 @@ -29,8 +29,6 @@ import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist 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.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen @@ -43,6 +41,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain @@ -123,6 +122,7 @@ class PlaybackViewModel private val deviceInfo: DeviceInfo, private val deviceProfileService: DeviceProfileService, private val refreshRateService: RefreshRateService, + val streamChoiceService: StreamChoiceService, ) : ViewModel(), Player.Listener, AnalyticsListener { @@ -338,7 +338,8 @@ class PlaybackViewModel } } } - val mediaSource = chooseSource(base, playbackConfig) + val mediaSource = streamChoiceService.chooseSource(base, playbackConfig) + val plc = streamChoiceService.getPlaybackLanguageChoice(base) if (mediaSource == null) { showToast( @@ -362,12 +363,26 @@ class PlaybackViewModel .orEmpty() val audioIndex = - chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences) - ?.index + streamChoiceService + .chooseStream( + mediaSource, + base.seriesId, + playbackConfig, + plc, + MediaStreamType.AUDIO, + preferences, + )?.index val subtitleIndex = - chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences) - ?.index + streamChoiceService + .chooseStream( + mediaSource, + base.seriesId, + playbackConfig, + plc, + MediaStreamType.SUBTITLE, + preferences, + )?.index Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") @@ -563,7 +578,7 @@ class PlaybackViewModel else -> throw Exception("No supported playback method") } - Timber.v("Playback decision: $transcodeType") + Timber.i("Playback decision for $itemId: $transcodeType") val externalSubtitleCount = source.externalSubtitlesCount @@ -605,21 +620,6 @@ class PlaybackViewModel liveStreamId = source.liveStreamId, mediaSourceInfo = source, ) - val itemPlayback = - currentItemPlayback.copy( - sourceId = source.id?.toUUIDOrNull(), - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, - ) - if (userInitiated) { - viewModelScope.launchIO { - Timber.v("Saving user initiated item playback: %s", itemPlayback) - val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = updated - } - } - } if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { @@ -638,14 +638,13 @@ class PlaybackViewModel api = api, player = player, playback = playback, - itemPlayback = itemPlayback, + itemPlayback = currentItemPlayback, ) player.addListener(activityListener) this@PlaybackViewModel.activityListener = activityListener loading.value = LoadingState.Success this@PlaybackViewModel.currentPlayback.update { playback } - this@PlaybackViewModel.currentItemPlayback.value = itemPlayback player.setMediaItem( mediaItem, positionMs, @@ -679,23 +678,41 @@ class PlaybackViewModel fun changeAudioStream(index: Int) { viewModelScope.launchIO { + Timber.d("Changing audio track to %s", index) + val itemPlayback = + itemPlaybackRepository.saveTrackSelection( + item = item, + itemPlayback = currentItemPlayback.value!!, + trackIndex = index, + type = MediaStreamType.AUDIO, + ) + this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) changeStreams( item, - currentItemPlayback.value!!, + itemPlayback, index, - currentItemPlayback.value?.subtitleIndex, + itemPlayback.subtitleIndex, onMain { player.currentPosition }, true, ) } } - fun changeSubtitleStream(index: Int?): Job = + fun changeSubtitleStream(index: Int): Job = viewModelScope.launchIO { + Timber.d("Changing subtitle track to %s", index) + val itemPlayback = + itemPlaybackRepository.saveTrackSelection( + item = item, + itemPlayback = currentItemPlayback.value!!, + trackIndex = index, + type = MediaStreamType.SUBTITLE, + ) + this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) changeStreams( item, - currentItemPlayback.value!!, - currentItemPlayback.value?.audioIndex, + itemPlayback, + itemPlayback.audioIndex, index, onMain { player.currentPosition }, true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 4ee2ded3..8568b286 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.TrackIndex -import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.setValueOnMain @@ -101,7 +100,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( api.userLibraryApi.getItem(itemId = it.itemId).content, api, ) - val mediaSource = chooseSource(item.data, it) + val mediaSource = streamChoiceService.chooseSource(item.data, it) if (mediaSource == null) { // This shouldn't happen, but just in case showToast( From c97553f82a069f6982d0c4232f3ff233165f3e46 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:40:32 -0500 Subject: [PATCH 004/124] Support login for users without passwords (#421) Just requires a username to be entered to enable the login button. Fixes #419 --- .../github/damontecres/wholphin/ui/setup/SwitchUserContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index df0b79ee..2134e481 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -286,7 +286,7 @@ fun SwitchUserContent( TextButton( stringRes = R.string.login, onClick = { onSubmit.invoke() }, - enabled = username.text.isNotNullOrBlank() && password.text.isNotNullOrBlank(), + enabled = username.text.isNotNullOrBlank(), modifier = Modifier.align(Alignment.CenterHorizontally), ) } From 2331f8362a149ce81e6c8d9b908b83bb4efd5c15 Mon Sep 17 00:00:00 2001 From: damontecres Date: Fri, 28 Nov 2025 21:08:14 +0000 Subject: [PATCH 005/124] Translated using Weblate (Spanish) Currently translated at 99.5% (243 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 44 +++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7bdbf50d..f1ca329f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -165,8 +165,8 @@ Poner la fuente en cursiva Detrás de las cámaras - - + + Canciones principales @@ -175,63 +175,63 @@ Vídeos temáticos - - + + Clips - - + + Escenas eliminadas - - + + Entrevistas - - + + Escenas - - + + Fragmentos - - + + Mini-documentales - - + + Cortos - - + + %s descarga %s descargas - + %d hora %d horas - + %d elemento %d elementos - + %d segundo %d segundos - + El dispositivo es compatible con AC3/Dolby Digital Comprobar actualizaciones automáticamente From 76d5d19ba0711b0ab21cc942a4e9db145341f146 Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Sat, 29 Nov 2025 09:59:47 +0000 Subject: [PATCH 006/124] Translated using Weblate (Slovak) Currently translated at 43.4% (106 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 45c38853..c55a7acc 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -33,7 +33,7 @@ Sťahovanie… Povolené - Zadajte IP adresu alebo URL servera vrátane portu + Zadajte IP adresu alebo URL servera Epizódy Chyba pri načítaní kolekcie %1$s Externé @@ -101,4 +101,5 @@ Vybrať server Vybrať používateľa Zobraziť informácie o debugu + Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server. From a8eef63a18400e0216fa6e20948432580afa4d29 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Sat, 29 Nov 2025 11:00:14 +0000 Subject: [PATCH 007/124] Translated using Weblate (French) Currently translated at 100.0% (244 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 208650bb..7d54e282 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -303,4 +303,5 @@ Dolby Vision Dolby Atmos Remplacer la lecture + Marge From 8edb5b3c36cf8375ddba3b3a598378a95c40f0c0 Mon Sep 17 00:00:00 2001 From: Vistaus Date: Sat, 29 Nov 2025 09:39:51 +0000 Subject: [PATCH 008/124] Translated using Weblate (Dutch) Currently translated at 100.0% (244 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0825a25c..e0fa4976 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -124,7 +124,7 @@ Trailer Trailers - + DVR-schema Gids @@ -156,47 +156,47 @@ Extra\'s Overig - + Achter de schermen - + Titelmuziek - + Titelvideo\'s - + Clips - + Verwijderde scènes - + Interviews - + Scènes - + Voorvertoningen - + Featurettes - + Shorts - + %s download @@ -287,4 +287,5 @@ Verwijderen Dolby Vision Dolby Atmos + Marge From 988bead39c4c63a8e385c1c6cab7f4c8045c5757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sat, 29 Nov 2025 22:41:13 +0000 Subject: [PATCH 009/124] Translated using Weblate (Estonian) Currently translated at 100.0% (244 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 92428ff4..0d7da644 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -223,7 +223,7 @@ Treiler Treilerid - + DVR-i kava Telekava @@ -287,4 +287,5 @@ Käitumine sisukokkuvõtte vahelejätmisel Taasesituse jätkamisel jäta vahele tagasisuunas Vaataja uinumise taimer + Veeris From 5386040c1c605bc5e1f11de614feadfafc8c3c38 Mon Sep 17 00:00:00 2001 From: sarjona98 Date: Sun, 30 Nov 2025 11:16:55 +0000 Subject: [PATCH 010/124] Added translation using Weblate (Catalan) --- app/src/main/res/values-ca/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-ca/strings.xml diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 26c17bf84adc1c77451a814268fd85ba096dbb04 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Sun, 30 Nov 2025 18:04:56 +0000 Subject: [PATCH 011/124] Translated using Weblate (French) Currently translated at 100.0% (244 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7d54e282..1a3afd7e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -197,7 +197,7 @@ Échelle de contenu par défaut Installer la mise à jour Version installée - Nombre max d’éléments sur les lignes de la page d\'accueil + Nombre d’éléments sur les lignes de la page d\'accueil Choisissez les éléments à afficher par défaut, les autres seront masqués Personnaliser les éléments du menu de navigation Cliquez pour changer de page From 77f12d793396d340b8a7860a9cbf76247867cf9d Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sun, 30 Nov 2025 17:17:12 +0000 Subject: [PATCH 012/124] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.1% (242 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 249c4d5f..84f761f1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -26,9 +26,9 @@ 預覽 使用者設定 佇列 - 近期新增 - 近期錄製 - 近期發行 + 最近新增 + 最近錄製 + 最近發行 推薦 錄製節目 重頭播放 @@ -41,8 +41,8 @@ 顯示 隨機播放 跳過 - 加入日期 - 劇集加入日期 + 新增日期 + 劇集新增日期 發行日期 名稱 隨機 @@ -61,7 +61,7 @@ 版本 畫面比例 影片 - 所有影片 + 影片 觀看直播 顯示時間 新增播放清單 @@ -194,7 +194,7 @@ 顯示播放偵錯資訊 播放 前情提要 - 最近新增的 %1$s + 最近新增於 %1$s 錄製劇集 移出我的最愛 繼續播放 @@ -269,4 +269,5 @@ 將嘗試傳送至最近連線的伺服器 傳送程式日誌到目前伺服器 有助於偵錯 + 邊距 From bb238fed43720a17f92479e0cd58aaa434e6c801 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sun, 30 Nov 2025 20:51:37 +0000 Subject: [PATCH 013/124] Translated using Weblate (Italian) Currently translated at 100.0% (244 of 244 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4d88195b..fd343ce0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -303,4 +303,5 @@ Salta Riassunto Sovrascrivi riproduzione Salta indietro al riavvio della riproduzione + Margine From 9e82f42083fd2c15cc3d85e57b41c7fc64ec06f1 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 1 Dec 2025 02:18:40 +0000 Subject: [PATCH 014/124] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.1% (243 of 245 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 84f761f1..944a7672 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -42,7 +42,7 @@ 隨機播放 跳過 新增日期 - 劇集新增日期 + 單集新增日期 發行日期 名稱 隨機 @@ -147,7 +147,7 @@ 位元率 生日 取消錄製 - 取消影集錄製 + 取消劇集錄製 取消 章節 選擇 %1$s @@ -172,7 +172,7 @@ 下載中… 已啟用 輸入伺服器 IP 位址或 URL - 劇集 + 單集 我的最愛 前往劇集 前往 @@ -183,8 +183,8 @@ #ABCDEFGHIJKLMNOPQRSTUVWXYZ 授權資訊 電視直播 - 標記整部劇集為已觀看? - 標記整部劇集為未觀看? + 標記整部電視劇為已觀看? + 標記整部電視劇為未觀看? 標記為未觀看 標記為已觀看 最大位元率 @@ -223,7 +223,7 @@ %d 秒 播放下一集前的延遲時間 - 僅適用於劇集 + 僅適用於電視劇 一律降轉為雙聲道 選取選單時自動切換頁面 @@ -270,4 +270,5 @@ 傳送程式日誌到目前伺服器 有助於偵錯 邊距 + 詳細日誌記錄 From caf2f991418e955c56887e0b30f97b9de22f47e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 1 Dec 2025 14:42:15 +0000 Subject: [PATCH 015/124] Translated using Weblate (Estonian) Currently translated at 100.0% (245 of 245 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 0d7da644..61d84a81 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -288,4 +288,5 @@ Taasesituse jätkamisel jäta vahele tagasisuunas Vaataja uinumise taimer Veeris + Detailne logimine From 288a64fb359c7cfa63a2a1a5cba6862eccb96cc3 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Mon, 1 Dec 2025 13:28:37 +0000 Subject: [PATCH 016/124] Translated using Weblate (Italian) Currently translated at 100.0% (245 of 245 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fd343ce0..1cc64f59 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -304,4 +304,5 @@ Sovrascrivi riproduzione Salta indietro al riavvio della riproduzione Margine + Log dettagliati From 88a76fb95924b1c9f777f0cafff2d9431bc99ca3 Mon Sep 17 00:00:00 2001 From: Vistaus Date: Mon, 1 Dec 2025 09:18:19 +0000 Subject: [PATCH 017/124] Translated using Weblate (Dutch) Currently translated at 100.0% (245 of 245 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e0fa4976..b7feb031 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -288,4 +288,5 @@ Dolby Vision Dolby Atmos Marge + Uitgebreid logboek aanleggen From 84a3e899ff308f46e47d499b259a3f52502d5dbc Mon Sep 17 00:00:00 2001 From: streamyfinchris Date: Mon, 1 Dec 2025 21:56:50 +0000 Subject: [PATCH 018/124] Translated using Weblate (Swedish) Currently translated at 84.8% (208 of 245 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 21304e53..643ff303 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -197,7 +197,7 @@ Markera som ospelad Markera som spelad Maximal bithastighet - Mer liknande detta + Liknande titlar Näst på tur Ingen data Inga schemalagda inspelningar @@ -205,7 +205,7 @@ Ingen Slutsekvens Sökväg - Personer + Medverkande Spelningar Spela härifrån Visa felsökningsinfo för uppspelning @@ -253,4 +253,5 @@ Utseende på text Bakgrundsfärg Spelad + Filter From 3dd6d82f58d26ea257ab162d56d45cb73cb10733 Mon Sep 17 00:00:00 2001 From: opakholis Date: Mon, 1 Dec 2025 22:09:49 +0000 Subject: [PATCH 019/124] Translated using Weblate (Indonesian) Currently translated at 99.1% (243 of 245 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ab1a6c76..cf05326b 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -269,4 +269,6 @@ Tahun Dekade Hapus + Dolby Vision + Dolby Atmos From 78d0a3062c7e93f63e75f62872b1db3f750e8c53 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 3 Dec 2025 16:01:30 +0000 Subject: [PATCH 020/124] Translated using Weblate (Italian) Currently translated at 100.0% (246 of 246 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1cc64f59..38f9608e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -305,4 +305,5 @@ Salta indietro al riavvio della riproduzione Margine Log dettagliati + Dimensione cache Immagini su disco (MB) From f462c7e78f06e9d2aca6dad45014ccfefea4c872 Mon Sep 17 00:00:00 2001 From: Vistaus Date: Wed, 3 Dec 2025 10:00:43 +0000 Subject: [PATCH 021/124] Translated using Weblate (Dutch) Currently translated at 100.0% (246 of 246 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index b7feb031..b170d555 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -289,4 +289,5 @@ Dolby Atmos Marge Uitgebreid logboek aanleggen + Omvang van afbeeldingscache (in MB) From b409b4230004894e51d2b511f142c65754ccb8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Thu, 4 Dec 2025 07:31:54 +0000 Subject: [PATCH 022/124] Translated using Weblate (Estonian) Currently translated at 100.0% (246 of 246 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 61d84a81..f2ee3c2d 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -289,4 +289,5 @@ Vaataja uinumise taimer Veeris Detailne logimine + Pildipuhvri suurus (MB) From 18c7a05dd3fa31757cee26d5f043903f4918f985 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 4 Dec 2025 19:53:37 +0000 Subject: [PATCH 023/124] Translated using Weblate (Italian) Currently translated at 100.0% (264 of 264 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 38f9608e..56cf110a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -306,4 +306,22 @@ Margine Log dettagliati Dimensione cache Immagini su disco (MB) + MPV: Usa gpu-next + Modifica mpv.conf + Annullare le modifiche? + Inserisci PIN + Accedi automaticamente + Accedi tramite server + Premi centro per confermare + Richiedi PIN per il profilo + Conferma PIN + Errato + Il PIN deve essere di 4 cifre o più + Rimuoverà il PIN + Opzioni di visualizzazione + Colonne + Spaziatura + Mostra dettagli + Rapporto d’aspetto + Tipo di immagine From 232ba120e09ce0e8a67594e926c1ffb06a03d2e0 Mon Sep 17 00:00:00 2001 From: Vistaus Date: Fri, 5 Dec 2025 09:50:39 +0000 Subject: [PATCH 024/124] Translated using Weblate (Dutch) Currently translated at 100.0% (266 of 266 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index b170d555..90648e0e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -290,4 +290,24 @@ Marge Uitgebreid logboek aanleggen Omvang van afbeeldingscache (in MB) + Mpv: gpu-next gebruiken + mpv.conf bewerken + Wijzigingen negeren? + Pincode invoeren + Automatisch inloggen + Inloggen via server + Druk op het midden om te bevestigen + Profiel voorzien van pincode + Pincode bevestigen + Onjuist + De pincode dient 4 of meer tekens te bevatten + De pincode wordt gewist + Weergaveopties + Kolommen + Tussenruimte + Beeldverhouding + Details bekijken + Afbeeldingstype + + Gastrollen From 34bc5534195d53b4f08c0dfe43dde5f8112c7274 Mon Sep 17 00:00:00 2001 From: Tellos Date: Fri, 5 Dec 2025 12:23:31 +0000 Subject: [PATCH 025/124] Added translation using Weblate (Russian) --- app/src/main/res/values-ru/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-ru/strings.xml diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From ac8a2a40b766a2da597ef0b925729f2dd16837e4 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Fri, 5 Dec 2025 13:02:13 +0000 Subject: [PATCH 026/124] Translated using Weblate (Italian) Currently translated at 100.0% (266 of 266 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 56cf110a..dd3e5eeb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -324,4 +324,6 @@ Mostra dettagli Rapporto d’aspetto Tipo di immagine + + Guest star From 062e7f331d72125ac571a4fefbc4a93c8f3ba386 Mon Sep 17 00:00:00 2001 From: Tellos Date: Fri, 5 Dec 2025 14:58:27 +0000 Subject: [PATCH 027/124] Translated using Weblate (Russian) Currently translated at 65.7% (175 of 266 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 223 ++++++++++++++++++++++++- 1 file changed, 222 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 55344e51..16e3a866 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,3 +1,224 @@ - \ No newline at end of file + Информация + В избранное + Добавить сервер + Добавить Пользователя + Аудио + Место рождения + Битрейт + Рождён + Отменить запись + Отмена + Главы + Выберите %1$s + Очистить кэш изображений + Коллекция + Коллекции + Оценка сообщества + Произошла ошибка! Нажмите клавишу, чтобы отправить файл журнала на ваш сервер. + Подтвердить + Продолжить просмотр + Оценка критиков + %.1f секунда + По умолчанию + Удалить + Умер + Режиссёр %1$s + Режиссёр + Обнаруженные серверы + + Скачивание… + Введите IP или URL сервера + Эпизоды + Ошибка при загрузке коллекции %1$s + Избранное + Устройство поддерживает AC3/Dolby Digital + Расширенные настройки + Тема оформления + Автоматически проверять наличие обновлений + Проверить наличие обновлений + Всегда преобразовывать в стерео + Использовать декодер FFmpeg + Размер + Жанры + Скрыть + Вступление + Библиотека + Информация о лицензии + Эфирное ТВ + Загрузка… + Отметить как непросмотренное + Отметить как просмотренное + Максимальный битрейт + Похожее + Фильмы + Имя + Далее + Нет информации + Нет результатов + Нет доступных обновлений + Путь + Люди + Показать отладочную инфомацию воспроизведения + Скорость воспроизведения + Воспроизведение + Плейлист + Плейлисты + Настройки профиля + Очередь + Недавно добавлено в %1$s + Недавно добавлено + Рекомендовано Вам + Убрать из избранного + Продолжить + Сохранить + + Найти + Поиск… + Выберите сервер + Выберите пользователя + Показать отладочную информацию + Показать + Перемешать + Пропустить + Дата добавления + Дата добавления эпизода + Дата воспроизведения + Дата выхода + Название + Случайно + Студии + Подзаголовок + Субтитры + Предложения + Сменить сервер + Сменить + Лучшее непросмотренное + Трейлер + + Трейлеры + + + + + Телегид + Сезон + Сезоны + Телешоу + Неизвестно + Обновления + Версия + Масштаб видео + Видео + Видео + %1$d возраст + Воспроизвести с перекодированием + Показать часы + Создать новый плейлист + Добавить в плейлист + Пауза одним нажатием + Нажмите центральную кнопку для паузы/воспроизведения + Шрифт + Фоновое изображение + Успех + Возрастной рейтинг + Продолжительность + Дополнительно + + Другое + + + + + + За кадром + + + + + + Клипы + + + + + + Удаленные сцены + + + + + + Интервью + + + + + + Сцены + + + + + + %s загрузка + %s загрузок + %s загрузок + %s загрузок + + + %d час + %d часа + %d часов + %d часов + + + %d секунда + %d секунды + %d секунд + %d секунд + + Активные записи + Внешний + Перейти + Пропустить рекламу + Пропустить вступление + Недавно записанное + Недавно вышедшее + Реклама + #ABCDEFGHIJKLMNOPQRSTUVWXYZАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ + Отметить все сезоны как воспроизведённые? + Отметить все сезоны как невоспроизведённые? + Скрыть отладочниую информацию + Домашняя страница + Отключено + Включено + Скрыть элементы управления воспроизведением + Ещё + Нет запланированных записей + Воспроизвести + Перезапустить + Воспроизвести отсюда + Смотреть прямой эфир + Порядок выхода эпизодов + Сброс + + Приглашённые звёзды + Соотношение сторон + Показать подробности + Интервал + Dolby Vision + Dolby Atmos + Редактировать mpv.conf + Отменить изменения? + Ведите ПИН-код + Автоматический вход + ПИН-код должен содержать минимум 4 символа + Настройки MPV + Настройки ExoPlayer + Год + Десятилетие + Убрать + MPV: Использовать аппаратное декодирование + From 01d10ea128591d884e3780c9348c84c7d196fbed Mon Sep 17 00:00:00 2001 From: Tellos Date: Fri, 5 Dec 2025 20:20:56 +0000 Subject: [PATCH 028/124] Translated using Weblate (Russian) Currently translated at 91.7% (244 of 266 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 117 ++++++++++++++++++++----- 1 file changed, 93 insertions(+), 24 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 16e3a866..34f257ca 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -67,8 +67,8 @@ Плейлисты Настройки профиля Очередь - Недавно добавлено в %1$s - Недавно добавлено + Недавно добавленное в %1$s + Недавно добавленное Рекомендовано Вам Убрать из избранного Продолжить @@ -89,7 +89,7 @@ Название Случайно Студии - Подзаголовок + Файл субтитров Субтитры Предложения Сменить сервер @@ -98,9 +98,9 @@ Трейлер Трейлеры - - - + + + Телегид Сезон @@ -127,39 +127,39 @@ Дополнительно Другое - - - + + + За кадром - - - + + + Клипы - - - + + + Удаленные сцены - - - + + + Интервью - - - + + + Сцены - - - + + + %s загрузка @@ -221,4 +221,73 @@ Десятилетие Убрать MPV: Использовать аппаратное декодирование + Фильтр + Заключительные титры + Превью + Отправить + Загрузка занимает много времени, возможно, потребуется перезапустить воспроизведение + Отменить запись сериала + Нет + Интерфейс + Записать программу + Записать сериал + Количество воспроизведений + Краткое содержание + Вход через сервер + Нажмите центральную клавишу для подтверждения + Требовать ПИН-код профиля + Подтвердите ПИН-код + Неверно + Показать опции + Колонки + Стиль субтитров + Цвет фона + Жирный шрифт + Стиль фона + Тип изображения + Удалит ПИН-код + Размер кэша изображений на диске (Мбайт) + Пропустить краткое содержание + Пропустить заключительные титры + Подробный журнал + Отключите при нестабильной работе + MPV: использовать gpu-next + Немедленный + Отступ + Пропустить сегмент + Пропустить превью + Параметры + Перемотать назад при возобновлении воспроизведения + Перемотать назад + Поведение при пропуске рекламы + Перемотка вперёд + Поведение при пропуске вступления + Поведение при пропуске заключительных титров + Поведение при пропуске превью + Поведение при пропуске краткого содержания + Доступно обновление + URL для проверки обновлений приложения + Воспроизведено + Отправлять отчёты об ошибках + Показать Далее + Курсив + Расширенный интерфейс + Задержка перед воспроизведением следующей серии + Автовоспроизведение следующей серии + Применяется только к Телешоу + + Прямое воспроизведение ASS-субтитров + Прямое воспроизведение PGS-субтитров + Размер шрифта + Цвет щрифта + Прозрачность шрифта + Стиль обводки + Цвет обводки + Прозрачность фона + Установить обновление + Версия приложения + Отправить журнал на текущий сервер + Совершит попытку отправки на последний подключённый сервер + Перейти к сериалу + Расписание записей From a0472fdbedd24cfb41d0aaccdca4aceaaa9df660 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sat, 6 Dec 2025 00:39:58 +0000 Subject: [PATCH 029/124] Translated using Weblate (Italian) Currently translated at 100.0% (267 of 267 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dd3e5eeb..9c35b8e9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -326,4 +326,5 @@ Tipo di immagine Guest star + Dimensione del bordo From c984c02ff5c4b69a7b97fb097a406cc286d1a9cd Mon Sep 17 00:00:00 2001 From: Vistaus Date: Sat, 6 Dec 2025 08:58:07 +0000 Subject: [PATCH 030/124] Translated using Weblate (Dutch) Currently translated at 100.0% (267 of 267 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 90648e0e..f1808787 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -310,4 +310,5 @@ Afbeeldingstype Gastrollen + Randbreedte From 527d1040740bc5493dc7fb6f573833d74d7b0b1e Mon Sep 17 00:00:00 2001 From: Tellos Date: Sat, 6 Dec 2025 14:24:04 +0000 Subject: [PATCH 031/124] Translated using Weblate (Russian) Currently translated at 100.0% (267 of 267 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 103 +++++++++++++++++++------ 1 file changed, 78 insertions(+), 25 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 34f257ca..aeb28c50 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,7 +4,7 @@ В избранное Добавить сервер Добавить Пользователя - Аудио + Аудиодорожка Место рождения Битрейт Рождён @@ -97,10 +97,10 @@ Лучшее непросмотренное Трейлер - Трейлеры - - - + Трейлер + Трейлера + Трейлеров + Трейлеров Телегид Сезон @@ -114,7 +114,7 @@ Видео %1$d возраст Воспроизвести с перекодированием - Показать часы + Показывать часы Создать новый плейлист Добавить в плейлист Пауза одним нажатием @@ -127,9 +127,9 @@ Дополнительно Другое - - - + Других + Других + Других За кадром @@ -138,28 +138,28 @@ - Клипы - - - + Клип + Клипа + Клипов + Клипов - Удаленные сцены - - - + Удаленная сцена + Удалённые сцены + Удалённых сцен + Удалённых сцен Интервью - - - + Интервью + Интервью + Интервью - Сцены - - - + Сцена + Сцены + Сцен + Сцен %s загрузка @@ -194,7 +194,7 @@ Домашняя страница Отключено Включено - Скрыть элементы управления воспроизведением + Скрывать элементы управления воспроизведением Ещё Нет запланированных записей Воспроизвести @@ -290,4 +290,57 @@ Совершит попытку отправки на последний подключённый сервер Перейти к сериалу Расписание записей + Ссылка для обновлениий + + %d объект + %d объекта + %d объектов + %d объектов + + + Отрывки + + + + + + Дополнительные материалы + + + + + Выбор плеера + Толщина обводки + Масштаб по умолчанию + Максимальное количество элементов в ряду на домашней странице + Форсированные + + Саундтреки + + + + + + Видеоролики + + + + + Настроить элементы боковой панели + Выберите элементы для показа на панели, остальные элементы будут скрыты + Воспроизведение фоновой музыки + Приоритеты воспроизведения + Запоминать выбранные вкладки + Разрешить просмотренное в \"Далее\" + Интервал перемотки + Используется при отладке + Смена страниц по нажатию + Смена страниц по выделению + Остановка воспроизведения при бездействии + + Короткометражки + + + + From 66b60bab4d38a977bd6150eeab2254b3ed6718bd Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sat, 6 Dec 2025 23:21:59 +0000 Subject: [PATCH 032/124] Translated using Weblate (Italian) Currently translated at 100.0% (271 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9c35b8e9..ed33256b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -327,4 +327,8 @@ Guest star Dimensione del bordo + Accedi + Automatico + Cambio frequenza di aggiornamento + Usa nome utente/password From ecab42e4438dec0ec062232e97bb92f54576c642 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sun, 7 Dec 2025 10:41:02 +0000 Subject: [PATCH 033/124] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 90.0% (244 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 944a7672..7e65e85b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -11,7 +11,7 @@ 更多 電影 名稱 - 即將播放 + 接下來播放 無資料 無查詢結果 無排程錄影 @@ -24,7 +24,7 @@ 播放清單 播放清單 預覽 - 使用者設定 + 個人化設定 佇列 最近新增 最近錄製 @@ -224,12 +224,12 @@ 播放下一集前的延遲時間 僅適用於電視劇 - + 一律降轉為雙聲道 選取選單時自動切換頁面 防睡保護 - 在即將播放中顯示重播內容 - 顯示即將播放 + 在接下來播放中顯示重播內容 + 顯示接下來播放 跳過前情提要 跳過片尾 跳過片頭 @@ -271,4 +271,5 @@ 有助於偵錯 邊距 詳細日誌記錄 + 使用 PIN 鍵登入 From 4dd4aa8198971fbc885a330d6ea47b0c75658f19 Mon Sep 17 00:00:00 2001 From: Vistaus Date: Sun, 7 Dec 2025 09:35:45 +0000 Subject: [PATCH 034/124] Translated using Weblate (Dutch) Currently translated at 100.0% (271 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f1808787..a793ee8f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -311,4 +311,8 @@ Gastrollen Randbreedte + Andere ververssnelheid toepassen + Automatisch + Gebruikersnaam/wachtwoord gebruiken + Inloggen From 4428f6b093b871cc1239ed4a8c05418bc0164d60 Mon Sep 17 00:00:00 2001 From: SuperCool Date: Sun, 7 Dec 2025 22:13:16 +0000 Subject: [PATCH 035/124] Added translation using Weblate (Hebrew) --- app/src/main/res/values-iw/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-iw/strings.xml diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-iw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From c5712415f23eb4b3f1ad1b755bf003d9e1038def Mon Sep 17 00:00:00 2001 From: streamyfinchris Date: Sun, 7 Dec 2025 23:52:56 +0000 Subject: [PATCH 036/124] Translated using Weblate (Swedish) Currently translated at 85.2% (231 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 643ff303..b7d13882 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -254,4 +254,27 @@ Bakgrundsfärg Spelad Filter + Konfirmera kod + Bildtyp + Storlek på bilddiskcache (MB) + Visa alternativ + Kolumner + Avstånd + Bildformat + Visa detaljer + Gästartister + Automatisk + Använd användarnamn/lösenord + Logga in + Hoppa framåt + Återställ + Skippa sammanfattning + År + Årtionde + Ta bort + Dolby Vision + Marginal + Skriv in PIN + Logga in automatiskt + Logga in via server From 5ee8df7939ec55636e1811ceefd7127c040acffd Mon Sep 17 00:00:00 2001 From: Pawsi Date: Tue, 9 Dec 2025 22:46:53 +0000 Subject: [PATCH 037/124] Added translation using Weblate (Polish) --- app/src/main/res/values-pl/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-pl/strings.xml diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From f0b3f01fbad1545647dfb2a7b280edf382279c28 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Wed, 10 Dec 2025 16:49:28 +0000 Subject: [PATCH 038/124] Translated using Weblate (Portuguese) Currently translated at 27.3% (74 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 78 ++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 045e125f..34a61f80 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,3 +1,81 @@ + Sobre + Favorito + Adicionar Servidor + Adicionar Utilizador + Áudio + Cancelar Gravação + Cancelar + Capítulos + Escolher %1$s + Eliminar cache de imagens + Colecção + Colecções + Confirmar + %.1f segundos + Padrão + Eliminar + Realizado por %1$s + Realizador + Desativado + A transferir + Activado + Episódios + Externo + Favoritos + Tamanho + Forçado + Ir para + Gravações Activas + Local de Nascimento + Bitrate + Nascido + Cancelar Gravação da Série + Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor + Continuar a ver + Morreu + Servidores Detectados + + Introduz IP e URL do Servidor + Erro ao carregar a colecção %1$s + Géneros + Ir para a Série + Esconder controlos de reprodução + Esconder informação de depuração + Esconder + Início + Imediato + Introdução + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Biblioteca + Informação da licença + TV em Directo + A carregar… + Marcar série como vista? + Marcar toda a série como não vista? + Marcar como não visto + Marcar como visto + Bitrate Máximo + Mais como isto + Mais + Filmes + Nome + Próximo + Sem dados + Sem resultados + Sem gravações programadas + Nenhuma atualização disponível + Nenhum + Créditos finais + Pessoas + Reproduções + Reproduzir daqui + Reproduzir + Mostrar depuração da reprodução + Velocidade da Reprodução + Reprodução + Lista de Reprodução + Listas de Reprodução + Pré-visualização From 048bf89a66c99037028f688c7224ad7600c00631 Mon Sep 17 00:00:00 2001 From: Pawsi Date: Tue, 9 Dec 2025 23:44:20 +0000 Subject: [PATCH 039/124] Translated using Weblate (Polish) Currently translated at 89.6% (243 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 195 ++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 55344e51..cc136dfa 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,3 +1,196 @@ - \ No newline at end of file + Dodaj serwer + Dodaj użytkownika + Dźwięk + Miejsce urodzenia + Bitrate + Potwierdź + Kontynuuj oglądanie + Ocena krytyków + Pobieranie… + Odcinki + Biblioteka + Informacje o licencji + Ładowanie… + Oznaczyć serial jako odtworzony? + Oznaczyć cały sezon jako nieodtworzony? + Oznacz jako nieobejrzane + Oznacz jako obejrzane + Maksymalny bitrate + Podobne + Więcej + Filmy + Następnie + Brak danych + Brak wyników + Brak zaplanowanych nagrań + Brak dostępnych aktualizacji + Prędkość odtwarzania + Odtwarzanie + Polecane + Zapisz program + Pomiń + Data dodania + Data Dodania Odcinka + Seriale + Sezon + Sezony + Napisy + Napisy + Propozycje + Zmień serwer + Zmień + Najlepiej Oceniane Nieobejrzane + Zwiastun + + Zwiastuny + + + + + Potwierdź + Data Wydania + Nazwa + Losowy + Przewodnik TV + Studia Filmowe + Losuj + Pokaż następne + Ostatnio Wydane + Odtwórz + Pokaż informacje debugowania odtwarzacza + Napisy końcowe + Ścieżka + Playlisty + Podgląd + Ustawienia Profilu Użytkownika + Kolejka + Zapisz + Szukaj + Wyszukiwanie… + Ostatnio dodane + Ostatnio Nagrane + Ostatnio dodane w %1$s + Wznów + Data Odtworzenia + Ulubione + Anuluj + Odcinki + Wybierz %1$s + Wyczyść pamięć podręczną obrazów + Ocena społeczności + Wystąpił błąd. Nacisnij przycisk żeby wysłać logi na Twój serwer. + Reżyser + Gatunek + Idź do serialu + Idż do + Ukryj przyciski odtwarzania + Ukryj informacje o debugowaniu + Ukryj + Strona Główna + Ulubione + Usuń + Domyślna ścieżka + Odnalezione Serwery + Wprowadż IP serwera lub adres URL + Rozmiar + Wymuszona ścieżka + Wstęp + Nazwa + Usuń z ulubionych + Uruchom ponownie + Wybierz Serwer + Wybierz Użytkownika + Pokaż infomacje o debugowaniu + Użyj nazwy użytkownika/hasła + Login + Kolumny + Odstępy + Pokaż szczegóły + Wprowadź PIN + Odstęp + Rok + Dekada + Usuń + Telewizja + Odtwórz z transkodowaniem + Pokaż Zegar + Czcionka + Tło + Pauza jednym kliknięciem + Wciśnij środek by pauzować/wznowić + + Wywiady + + + + + + Sceny + + + + + + Czołówki + + + + + Utwórz nową playliste + Dodaj do playlisty + Wersja + Aktualizacje + Wstrzymaj Nagrywanie + Zobacz opcje + Potwierdź PIN + Nieprawidłowe + PIN musi zawierać 4 lub więcej znaków + Wymagaj PIN dla profilu + Dolby Vision + Dolby Atmos + MPV: użyj gpu-next + Edytuj mpv.conf + Odrzucić zmiany? + Naciśnij środek aby potwierdzić + Rozmiar pamięci tymczasowej obrazów (MB) + Wyłącz jeśli doświadczasz problemów + Opcje MPV + Opcje odtwarzacza ExoPlayer + Pomiń Segment + Pomiń Reklamy + Pomiń czołówkę + Pomiń napisy końcowe + Loguj automatycznie + Reset + Czcionka pogrubiona + Styl obramowania + Kolor obramowania + Przezroczystość tła + Styl tła + Styl napisów + Kolor tła + Uaktualnij adres URL + Rozmiar czcionki + Kolor czcionki + Ustawienia + Wyślij raport o błędach + Aktualizacja dostępna + MPV: używaj dekodowania sprzętowego + Filtr + Czcionka kursywy + + Za kulisami + + + + + Ustawienia zaawansowane + Sprawdź dostępność aktualizacji + Automatycznie sprawdzaj dostępność aktualizacji + Zainstaluj aktualizacje + Zainstalowana wersja + Użyj dekodera FFmpeg + Rozmiar ramki + From 7d465b1a6016a571b6bb139e87733093a2b106ed Mon Sep 17 00:00:00 2001 From: ictu Date: Wed, 10 Dec 2025 08:08:22 +0000 Subject: [PATCH 040/124] Translated using Weblate (Polish) Currently translated at 89.6% (243 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 70 +++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index cc136dfa..d71a07d9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -76,7 +76,7 @@ Data Odtworzenia Ulubione Anuluj - Odcinki + Rozdziały Wybierz %1$s Wyczyść pamięć podręczną obrazów Ocena społeczności @@ -142,7 +142,7 @@ Dodaj do playlisty Wersja Aktualizacje - Wstrzymaj Nagrywanie + Anuluj Nagrywanie Zobacz opcje Potwierdź PIN Nieprawidłowe @@ -193,4 +193,70 @@ Zainstalowana wersja Użyj dekodera FFmpeg Rozmiar ramki + O Wholphin + Nagrania w toku + Data Urodzenia + Anuluj Nagrywanie Serialu + Kolekcja + Kolekcje + Reklama + %.1f sekundy + Nieaktywne + + Wyreżyserowane przez %1$s + Aktywne + Błąd podczas ładowania kolekcji %1$s + Ścieżka zewnętrzna + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + + %d godzina + %d godziny + %d godzin + %d godzin + + Typ obrazu + + Domyślne skalowanie + Odtwarzaj bezpośrednio napisy ASS + Odtwarzaj bezpośrednio napisy PGS + Zawsze miksuj w dół do stereo + Motyw interfejsu + + %s pobieranie + %s pobierania + %s pobierań + %s pobierań + + + %d sekunda + %d sekundy + %d sekund + %d sekund + + Opóźnienie przed odtworzeniem kolejnego odcinka + Automatycznie odtwarzaj kolejny odcinek + Stosowane jedynie wobec seriali + + Maksimum elementów wiersza na stronie domowej + Wybierz domyślne elementy do wyświetlenia, pozostałe zostaną ukryte + + %d element + %d elementy + %d elementów + %d elementów + + Urządzenie wspiera AC3/Dolby Digital + Zaawansowany UI + Zmarły(-a) + Liczba odtworzeń + Odtwarzaj od tej pozycji + Playlista + Streszczenie + Zapisz serial + + Pokaż + Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzania + Harmonogram nagrywania + Interfejs użytkownika + Skalowanie obrazu From 2f5d6f7e1cd1859d016f6119124a84c4ce8e7a74 Mon Sep 17 00:00:00 2001 From: Pawsi Date: Wed, 10 Dec 2025 08:14:51 +0000 Subject: [PATCH 041/124] Translated using Weblate (Polish) Currently translated at 89.6% (243 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d71a07d9..69a8f90a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -255,8 +255,47 @@ Zapisz serial Pokaż - Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzania + Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzacza Harmonogram nagrywania Interfejs użytkownika Skalowanie obrazu + Przezroczystość czcionki + Adres URL do sprawdzenia aktualizacji + Obsada + Żaden + Nieznany + %1$d lat temu + Powodzenie + Ocena Rodzicielska + Dodatki + + Inne + + + + + + Usunięte sceny + + + + + Naciśnij by zmienić stronę + Przydatne do debugowania + Wyślij logi aplikacji do serwera + Poziomy przewijania + Obejrzane + Logowanie na serwer + Umożliwiaj ponowne oglądanie w sekcji następne + Próba wysłania raportu błędów do ostatnio połączonego serwera + Zapamiętaj wybrane karty + Odtwórz muzykę motywu multimediów + Przewijanie do przodu + Pomiń wstęp + Pomiń informacje o poprzednich odcinkach + Dopasuj elementy interfejsu nawigacyjnego + Cofanie + Cofnij gdy wznawiasz odtwarzanie + Player odtwarzający + Ochrona przed uśpieniem From 86d58eba04cfc2ffe537416bae2690bd5b40efec Mon Sep 17 00:00:00 2001 From: adambibor Date: Thu, 11 Dec 2025 06:23:17 +0000 Subject: [PATCH 042/124] Added translation using Weblate (Hungarian) --- app/src/main/res/values-hu/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-hu/strings.xml diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 163da18f73045703df237470a7393dbac5d9e1f4 Mon Sep 17 00:00:00 2001 From: adambibor Date: Thu, 11 Dec 2025 07:01:07 +0000 Subject: [PATCH 043/124] Translated using Weblate (Hungarian) Currently translated at 62.3% (169 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 215 ++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 55344e51..7d0320bb 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,3 +1,216 @@ - \ No newline at end of file + Névjegy + Kedvenc + Szerver hozzáadása + Felhasználó hozzáadása + Audio + Születési hely + Bitráta + Született + Mégse + Fejezetek + Válassz %1$s + Kép gyorsítótár törlése + Gyűjtemény + Gyűjtemények + Közösségi értékelés + Hiba történt! Nyomd meg a gombot, hogy elküldd a naplófájlokat a szervernek. + Megerősít + Kritikusi értékelés + %.1f másodperc + Alapértelmezett + Törlés + Rendezte %1$s + Rendező + Letiltva + Felfedezett szerverek + + Letöltés folyamatban… + Engedélyezve + Add meg a szerver IP-t vagy URL-t + Epizódok + Hiba a gyűjtemény betöltése során: %1$s + Külső + Kedvencek + Aktív felvételek + Felvétel leállítása + Sorozatfelvétel leállítása + Reklám + Megtekintés folytatása + Halott + Méret + Kényszerített + Műfajok + Ugrás a sorozathoz + Ugrás + Lejátszási gombok elrejtése + Hibajavítási infó elrejtése + Elrejt + Kezdőlap + Azonnal + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Könyvtár + Lincenc információk + Élő TV + Betöltés… + Megjelölöd az egész sorozatot megnézettként? + Megjelölöd az egész sorozatot megnézetlenként? + Jelölés megnézetlenként + Jelölés megnézettként + Max bitráta + Hasonlóak + Továbbiak + Filmek + Név + Következő + Nincs adat + Nincs találat + Nincs időzített felvétel + Nincs elérhető frissítés + Nincs + Outro + Elérési út + Emberek + Lejátszások száma + Lejátszás innen + Lejátszás + Lejátszás hibakeresési adatainak mutatása + Lejátszási sebesség + Visszajátszás + Lejátszási lista + Lejátszási listák + Előnézet + Felhasználó beállításai + Lejátszási sor + Nemrég hozzáadva: %1$s + Nemrég hozzáadott + Nemrég rögzített + Nemrég kiadott + Ajánlott + Program felvétele + Sorozat felvétele + Törlés a kedvencekből + Újraindítás + Folytatás + Mentés + + Keresés + Keresés… + Szerver kiválasztása + Felhasználó kiválasztása + Hibakeresési info megjelenítése + Következő mutatása + Megmutat + Véletlenszerű + Átugrás + Hozzáadva + Epizód hozzáadásának dátuma + Lejátszás dátuma + Megjelenés dátuma + Név + Random + Stúdiók + Beküld + A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítanod a lejátszást + Felirat + Feliratok + Ajánlások + Váltás szerverek között + Váltás + Legjobbra értékelt nem megtekintett + Előzetes + + Előzetesek + + + DVR Időzítés + Műsorújság + Évad + Évadok + Sorozatok + Felület + Ismeretlen + Frissítések + Verzió + Képarány + Videó + Videók + Nézd élőben + %1$d éves + Lejátszás átalakítással + Aktuális idő mutatása + Sorrend epízód vetítése alapján + Új lejátszási lista létrehozása + Hozzáadás lejátszási listához + Szüneteltetés egy klikkel + Nyomd a D-Pad közepét megállításhoz/lejátszáshoz + Dőlt betűk + Betűtípus + Háttérszín + Sikeres + Szülői besorolás + Műsoridő + Extrák + + Egyéb + + + + Színfalak mögött + + + + Téma zenék + + + + Téma videók + + + + Klippek + + + + Törölt jelenetek + + + + Interjúk + + + + Jelenetek + + + + Minták + + + + Rövidfilmek + + + + Rövidek + + + + %s letöltés + %s letöltések + + + %d óra + %d órák + + + %d elem + %d elemek + + + %d másodperc + %d másodpercek + + From e4e56bf7e116b712b0c1243a3384b58492fa2e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Thu, 11 Dec 2025 15:34:08 +0000 Subject: [PATCH 044/124] Translated using Weblate (Estonian) Currently translated at 100.0% (271 of 271 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index f2ee3c2d..873bdbbe 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -290,4 +290,29 @@ Veeris Detailne logimine Pildipuhvri suurus (MB) + Sisesta PIN-kood + Logi automaatselt sisse + Logi sisse serveri abil + Kinnitamiseks vajuta keskele + Küsi profiili jaoks PIN-koodi + Korda PIN-koodi + Pole õige + PIN-kood peab olema 4 või enam märki pikk + Sellega eemaldatakse PIN-kood + MPV: kasuta gpu-next liidestust + Muuda mpv.conf faili + Kas loobud muudatustest? + Vaate seaded + Veerud + Ruumivahed + Küljesuhe + Näita üksikasju + Pildi tüüp + + Külalisnäitlejad + Ääre suurus + Kaadrisageduse vahetamine + Automaatne + Pruugi kasutajanime ja salasõna + Logi sisse From 319b46983fc0de92b9791054fd57ade4c5cd51d2 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:52:13 -0500 Subject: [PATCH 045/124] Play item directly when clicking play button on remote (#424) On the home page or any grid, if focused on a "playable" item (movie, episode, etc), pressing the Play or Play-Pause button on the remote will immediately start playback. Dev note: Also guards against an edge case where an item passed into playback doesn't have media sources due to more limited item fields in the query. --- .../ui/components/CollectionFolderGrid.kt | 13 +++++++---- .../wholphin/ui/components/GenreCardGrid.kt | 1 + .../wholphin/ui/components/ItemGrid.kt | 1 + .../ui/components/RecommendedContent.kt | 4 ++++ .../wholphin/ui/detail/CardGrid.kt | 8 ++++++- .../damontecres/wholphin/ui/main/HomePage.kt | 22 ++++++++++++++++++- .../wholphin/ui/playback/PlaybackViewModel.kt | 8 +++++-- 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 22591db4..2ba65f13 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -579,7 +579,10 @@ fun CollectionFolderGrid( defaultViewOptions = defaultViewOptions, onSaveViewOptions = { viewModel.saveViewOptions(it) }, playEnabled = playEnabled, - onClickPlay = { shuffle -> + onClickPlay = { _, item -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + }, + onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { viewModel.navigationManager.navigateTo( Destination.PlaybackList( @@ -678,7 +681,8 @@ fun CollectionFolderGridContent( letterPosition: suspend (Char) -> Int, sortOptions: List, playEnabled: Boolean, - onClickPlay: (shuffle: Boolean) -> Unit, + onClickPlayAll: (shuffle: Boolean) -> Unit, + onClickPlay: (Int, BaseItem) -> Unit, modifier: Modifier = Modifier, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, @@ -786,12 +790,12 @@ fun CollectionFolderGridContent( title = R.string.play, resume = Duration.ZERO, icon = Icons.Default.PlayArrow, - onClick = { onClickPlay.invoke(false) }, + onClick = { onClickPlayAll.invoke(false) }, ) ExpandableFaButton( title = R.string.shuffle, iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlay.invoke(true) }, + onClick = { onClickPlayAll.invoke(true) }, ) } } @@ -813,6 +817,7 @@ fun CollectionFolderGridContent( pager = pager, onClickItem = onClickItem, onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, letterPosition = letterPosition, gridFocusRequester = gridFocusRequester, showJumpButtons = false, // TODO add preference diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index b3711d0a..33900e2f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -117,6 +117,7 @@ fun GenreCardGrid( ) }, onLongClickItem = { _, _ -> }, + onClickPlay = { _, _ -> }, letterPosition = { viewModel.positionOfLetter(it) }, gridFocusRequester = gridFocusRequester, showJumpButtons = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index cf27554d..1e274de6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -124,6 +124,7 @@ fun ItemGrid( viewModel.navigateTo(Destination.Playback(item.id, 0)) }, onLongClickItem = { index: Int, item: BaseItem -> }, + onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) }, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index f69c018a..30bb369c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -146,6 +147,9 @@ fun RecommendedContent( onLongClickItem = { position, item -> moreDialog.makePresent(RowColumnItem(position, item)) }, + onClickPlay = { _, item -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + }, onFocusPosition = onFocusPosition, showClock = preferences.appPreferences.interfacePreferences.showClock, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 633ba5bc..8ffba496 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -63,6 +63,7 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.playback.isBackwardButton import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp +import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.Dispatchers @@ -78,6 +79,7 @@ fun CardGrid( pager: List, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, + onClickPlay: (Int, BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, gridFocusRequester: FocusRequester, showJumpButtons: Boolean, @@ -213,7 +215,11 @@ fun CardGrid( jumpToTop() return@onKeyEvent true } else if (isPlayKeyUp(it)) { - // TODO play the focused item + val item = pager.getOrNull(focusedIndex) + if (item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke(focusedIndex, item) + } return@onKeyEvent true } else if (useJumpRemoteButtons && isForwardButton(it)) { jump(jump1) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 1dc6b7b0..12bd8ff6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -67,12 +68,16 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp +import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber import java.util.UUID @Composable @@ -157,6 +162,9 @@ fun HomePage( items = dialogItems, ) }, + onClickPlay = { _, item -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + }, loadingState = refreshing, showClock = preferences.appPreferences.interfacePreferences.showClock, modifier = modifier, @@ -193,6 +201,7 @@ fun HomePageContent( homeRows: List, onClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, modifier: Modifier = Modifier, onFocusPosition: ((RowColumn) -> Unit)? = null, @@ -264,7 +273,18 @@ fun HomePageContent( top = 0.dp, bottom = Cards.height2x3, ), - modifier = Modifier.focusRestorer(), + modifier = + Modifier + .focusRestorer() + .onKeyEvent { + val item = focusedItem + if (isPlayKeyUp(it) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke(position, item) + return@onKeyEvent true + } + return@onKeyEvent false + }, ) { itemsIndexed(homeRows) { rowIndex, row -> when (val r = row) { 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 a60f6c00..38d1679d 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 @@ -225,9 +225,13 @@ class PlaybackViewModel "Error preparing for playback for $itemId", ), ) { + val destItem = (destination as? Destination.Playback)?.item?.data val queriedItem = - (destination as? Destination.Playback)?.item?.data - ?: api.userLibraryApi.getItem(itemId).content + if (destItem?.mediaSources != null) { + destItem + } else { + api.userLibraryApi.getItem(itemId).content + } val base = if (queriedItem.type.playable) { queriedItem From c5c0dff70540523913a0253a64498affb4b63be2 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:52:21 -0500 Subject: [PATCH 046/124] Fix subtitle smart mode to check chosen audio language (#420) Fixes how the smart subtitle mode logic works Fixes #406 --- .../wholphin/data/ItemPlaybackRepository.kt | 1 + .../wholphin/services/StreamChoiceService.kt | 42 +++++++++++++++---- .../wholphin/ui/playback/PlaybackViewModel.kt | 32 +++++++------- 3 files changed, 50 insertions(+), 25 deletions(-) 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 374aa75c..35254c00 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 @@ -59,6 +59,7 @@ class ItemPlaybackRepository ) val subtitleStream = streamChoiceService.chooseSubtitleStream( + audioStream = audioStream, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 7c10c549..1af9d11e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -91,22 +91,17 @@ class StreamChoiceService result } - suspend fun chooseStream( + suspend fun chooseAudioStream( source: MediaSourceInfo, seriesId: UUID?, itemPlayback: ItemPlayback?, plc: PlaybackLanguageChoice?, - type: MediaStreamType, prefs: UserPreferences, ): MediaStream? { val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) } return source.mediaStreams?.letNotEmpty { streams -> - val candidates = streams.filter { it.type == type } - when (type) { - MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, plc, prefs) - MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, plc, prefs) - else -> candidates.firstOrNull() - } + val candidates = streams.filter { it.type == MediaStreamType.AUDIO } + chooseAudioStream(candidates, itemPlayback, plc, prefs) } } @@ -142,7 +137,35 @@ class StreamChoiceService } } + suspend fun chooseSubtitleStream( + source: MediaSourceInfo, + audioStream: MediaStream?, + seriesId: UUID?, + itemPlayback: ItemPlayback?, + plc: PlaybackLanguageChoice?, + prefs: UserPreferences, + ): MediaStream? { + val plc = + plc ?: seriesId?.let { + playbackLanguageChoiceDao.get( + serverRepository.currentUser.value!!.rowId, + it, + ) + } + return source.mediaStreams?.letNotEmpty { streams -> + val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } + chooseSubtitleStream( + audioStream, + candidates, + itemPlayback, + plc, + prefs, + ) + } + } + fun chooseSubtitleStream( + audioStream: MediaStream?, candidates: List, itemPlayback: ItemPlayback?, playbackLanguageChoice: PlaybackLanguageChoice?, @@ -202,7 +225,8 @@ class StreamChoiceService SubtitlePlaybackMode.SMART -> { val audioLanguage = prefs.userConfig.audioLanguagePreference - if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + val audioStreamLang = audioStream?.language + if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { candidates.firstOrNull { it.language == subtitleLanguage } } else { null 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 38d1679d..5f8b656c 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 @@ -366,26 +366,26 @@ class PlaybackViewModel ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() - val audioIndex = + val audioStream = streamChoiceService - .chooseStream( - mediaSource, - base.seriesId, - playbackConfig, - plc, - MediaStreamType.AUDIO, - preferences, - )?.index + .chooseAudioStream( + source = mediaSource, + seriesId = base.seriesId, + itemPlayback = playbackConfig, + plc = plc, + prefs = preferences, + ) + val audioIndex = audioStream?.index val subtitleIndex = streamChoiceService - .chooseStream( - mediaSource, - base.seriesId, - playbackConfig, - plc, - MediaStreamType.SUBTITLE, - preferences, + .chooseSubtitleStream( + source = mediaSource, + audioStream = audioStream, + seriesId = base.seriesId, + itemPlayback = playbackConfig, + plc = plc, + prefs = preferences, )?.index Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") From c2ad0a6a8a53f7bfb0b2d0f4b92477ecea6f930b Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 11 Dec 2025 12:55:31 -0500 Subject: [PATCH 047/124] Fix pre-commit --- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-iw/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 55344e51..045e125f 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1,3 +1,3 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 55344e51..045e125f 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,3 +1,3 @@ - \ No newline at end of file + From f63a30d2951bacc9142ca90986dd842d422ad2f2 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:55:52 -0500 Subject: [PATCH 048/124] Add additional ffmpeg audio decoders (#423) Adds a few more ffmpeg audio decoders for ExoPlayer Fixes #417 --- scripts/ffmpeg/build_ffmpeg_decoder.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh index 7dd28133..59718b52 100755 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh @@ -13,7 +13,7 @@ PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")" # Config ANDROID_ABI=21 -ENABLED_DECODERS=(dca ac3 eac3 mlp truehd) +ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3) FFMPEG_BRANCH="release/6.0" # Path configs From 2403a44c186d503d26bfb565ddad7c0a51875816 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:03:16 -0500 Subject: [PATCH 049/124] Add view option for hiding card titles (#422) Adds a view option for grids to show/hide titles. It is enabled by default. Closes #403 --- .../damontecres/wholphin/ui/cards/GridCard.kt | 56 ++++++++++--------- .../ui/components/CollectionFolderGrid.kt | 9 +-- .../ui/components/ViewOptionsDialog.kt | 9 +++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 45 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index b6f3ff4f..218a64d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.cards +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -44,6 +45,7 @@ fun GridCard( imageAspectRatio: Float = AspectRatios.TALL, imageContentScale: ContentScale = ContentScale.Fit, imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, + showTitle: Boolean = true, ) { val dto = item?.data val focused by interactionSource.collectIsFocusedAsState() @@ -98,34 +100,36 @@ fun GridCard( .background(MaterialTheme.colorScheme.surfaceVariant), ) } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { - Text( - text = item?.title ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, + AnimatedVisibility(showTitle) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = item?.subtitle ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = item?.title ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = item?.subtitle ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 2ba65f13..35b5d1c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -681,6 +681,10 @@ fun CollectionFolderGridContent( letterPosition: suspend (Char) -> Int, sortOptions: List, playEnabled: Boolean, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + defaultViewOptions: ViewOptions, + onSaveViewOptions: (ViewOptions) -> Unit, + viewOptions: ViewOptions, onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlay: (Int, BaseItem) -> Unit, modifier: Modifier = Modifier, @@ -689,10 +693,6 @@ fun CollectionFolderGridContent( currentFilter: GetItemsFilter = GetItemsFilter(), filterOptions: List> = listOf(), onFilterChange: (GetItemsFilter) -> Unit = {}, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, - defaultViewOptions: ViewOptions, - viewOptions: ViewOptions, - onSaveViewOptions: (ViewOptions) -> Unit, ) { val context = LocalContext.current val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) @@ -837,6 +837,7 @@ fun CollectionFolderGridContent( imageContentScale = viewOptions.contentScale.scale, imageAspectRatio = viewOptions.aspectRatio.ratio, imageType = viewOptions.imageType, + showTitle = viewOptions.showTitles, modifier = mod, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index 8142002b..e4e6d350 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -113,6 +113,7 @@ data class ViewOptions( val aspectRatio: AspectRatio = AspectRatio.TALL, val showDetails: Boolean = false, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, + val showTitles: Boolean = true, ) { companion object { val ViewOptionsColumns = @@ -165,6 +166,13 @@ data class ViewOptions( getter = { it.showDetails }, setter = { vo, value -> vo.copy(showDetails = value) }, ) + val ViewOptionsShowTitles = + AppSwitchPreference( + title = R.string.show_titles, + defaultValue = true, + getter = { it.showTitles }, + setter = { vo, value -> vo.copy(showTitles = value) }, + ) val ViewOptionsImageType = AppChoicePreference( @@ -194,6 +202,7 @@ data class ViewOptions( ViewOptionsImageType, ViewOptionsAspectRatio, ViewOptionsDetailHeader, + ViewOptionsShowTitles, ViewOptionsColumns, ViewOptionsSpacing, ViewOptionsContentScale, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 678c8d5d..eb2fce4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -354,6 +354,7 @@ Automatic Use username/password Login + Show titles Disabled From e381df0295cabf06d95aacbe0581f3ebc6b2675a Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:03:27 -0500 Subject: [PATCH 050/124] Add batch of translations (#425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update translations from https://translate.codeberg.org/projects/wholphin/ --------- Co-authored-by: damontecres Co-authored-by: n8llcaster Co-authored-by: RabSsS Co-authored-by: Vistaus Co-authored-by: Priit Jõerüüt Co-authored-by: sarjona98 Co-authored-by: SimonHung Co-authored-by: arcker95 Co-authored-by: streamyfinchris Co-authored-by: opakholis Co-authored-by: Tellos Co-authored-by: SuperCool Co-authored-by: Pawsi Co-authored-by: American_Jesus Co-authored-by: ictu Co-authored-by: adambibor --- app/src/main/res/values-ca/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 44 +-- app/src/main/res/values-et/strings.xml | 30 +- app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-hu/strings.xml | 216 +++++++++++++ app/src/main/res/values-in/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 28 ++ app/src/main/res/values-iw/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 52 +++- app/src/main/res/values-pl/strings.xml | 301 ++++++++++++++++++ app/src/main/res/values-pt/strings.xml | 78 +++++ app/src/main/res/values-ru/strings.xml | 346 +++++++++++++++++++++ app/src/main/res/values-sk/strings.xml | 3 +- app/src/main/res/values-sv/strings.xml | 28 +- app/src/main/res/values-zh-rTW/strings.xml | 37 ++- 15 files changed, 1118 insertions(+), 56 deletions(-) create mode 100644 app/src/main/res/values-ca/strings.xml create mode 100644 app/src/main/res/values-hu/strings.xml create mode 100644 app/src/main/res/values-iw/strings.xml create mode 100644 app/src/main/res/values-pl/strings.xml create mode 100644 app/src/main/res/values-ru/strings.xml diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7bdbf50d..f1ca329f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -165,8 +165,8 @@ Poner la fuente en cursiva Detrás de las cámaras - - + + Canciones principales @@ -175,63 +175,63 @@ Vídeos temáticos - - + + Clips - - + + Escenas eliminadas - - + + Entrevistas - - + + Escenas - - + + Fragmentos - - + + Mini-documentales - - + + Cortos - - + + %s descarga %s descargas - + %d hora %d horas - + %d elemento %d elementos - + %d segundo %d segundos - + El dispositivo es compatible con AC3/Dolby Digital Comprobar actualizaciones automáticamente diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 92428ff4..873bdbbe 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -223,7 +223,7 @@ Treiler Treilerid - + DVR-i kava Telekava @@ -287,4 +287,32 @@ Käitumine sisukokkuvõtte vahelejätmisel Taasesituse jätkamisel jäta vahele tagasisuunas Vaataja uinumise taimer + Veeris + Detailne logimine + Pildipuhvri suurus (MB) + Sisesta PIN-kood + Logi automaatselt sisse + Logi sisse serveri abil + Kinnitamiseks vajuta keskele + Küsi profiili jaoks PIN-koodi + Korda PIN-koodi + Pole õige + PIN-kood peab olema 4 või enam märki pikk + Sellega eemaldatakse PIN-kood + MPV: kasuta gpu-next liidestust + Muuda mpv.conf faili + Kas loobud muudatustest? + Vaate seaded + Veerud + Ruumivahed + Küljesuhe + Näita üksikasju + Pildi tüüp + + Külalisnäitlejad + Ääre suurus + Kaadrisageduse vahetamine + Automaatne + Pruugi kasutajanime ja salasõna + Logi sisse diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 208650bb..1a3afd7e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -197,7 +197,7 @@ Échelle de contenu par défaut Installer la mise à jour Version installée - Nombre max d’éléments sur les lignes de la page d\'accueil + Nombre d’éléments sur les lignes de la page d\'accueil Choisissez les éléments à afficher par défaut, les autres seront masqués Personnaliser les éléments du menu de navigation Cliquez pour changer de page @@ -303,4 +303,5 @@ Dolby Vision Dolby Atmos Remplacer la lecture + Marge diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 00000000..7d0320bb --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,216 @@ + + + Névjegy + Kedvenc + Szerver hozzáadása + Felhasználó hozzáadása + Audio + Születési hely + Bitráta + Született + Mégse + Fejezetek + Válassz %1$s + Kép gyorsítótár törlése + Gyűjtemény + Gyűjtemények + Közösségi értékelés + Hiba történt! Nyomd meg a gombot, hogy elküldd a naplófájlokat a szervernek. + Megerősít + Kritikusi értékelés + %.1f másodperc + Alapértelmezett + Törlés + Rendezte %1$s + Rendező + Letiltva + Felfedezett szerverek + + Letöltés folyamatban… + Engedélyezve + Add meg a szerver IP-t vagy URL-t + Epizódok + Hiba a gyűjtemény betöltése során: %1$s + Külső + Kedvencek + Aktív felvételek + Felvétel leállítása + Sorozatfelvétel leállítása + Reklám + Megtekintés folytatása + Halott + Méret + Kényszerített + Műfajok + Ugrás a sorozathoz + Ugrás + Lejátszási gombok elrejtése + Hibajavítási infó elrejtése + Elrejt + Kezdőlap + Azonnal + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Könyvtár + Lincenc információk + Élő TV + Betöltés… + Megjelölöd az egész sorozatot megnézettként? + Megjelölöd az egész sorozatot megnézetlenként? + Jelölés megnézetlenként + Jelölés megnézettként + Max bitráta + Hasonlóak + Továbbiak + Filmek + Név + Következő + Nincs adat + Nincs találat + Nincs időzített felvétel + Nincs elérhető frissítés + Nincs + Outro + Elérési út + Emberek + Lejátszások száma + Lejátszás innen + Lejátszás + Lejátszás hibakeresési adatainak mutatása + Lejátszási sebesség + Visszajátszás + Lejátszási lista + Lejátszási listák + Előnézet + Felhasználó beállításai + Lejátszási sor + Nemrég hozzáadva: %1$s + Nemrég hozzáadott + Nemrég rögzített + Nemrég kiadott + Ajánlott + Program felvétele + Sorozat felvétele + Törlés a kedvencekből + Újraindítás + Folytatás + Mentés + + Keresés + Keresés… + Szerver kiválasztása + Felhasználó kiválasztása + Hibakeresési info megjelenítése + Következő mutatása + Megmutat + Véletlenszerű + Átugrás + Hozzáadva + Epizód hozzáadásának dátuma + Lejátszás dátuma + Megjelenés dátuma + Név + Random + Stúdiók + Beküld + A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítanod a lejátszást + Felirat + Feliratok + Ajánlások + Váltás szerverek között + Váltás + Legjobbra értékelt nem megtekintett + Előzetes + + Előzetesek + + + DVR Időzítés + Műsorújság + Évad + Évadok + Sorozatok + Felület + Ismeretlen + Frissítések + Verzió + Képarány + Videó + Videók + Nézd élőben + %1$d éves + Lejátszás átalakítással + Aktuális idő mutatása + Sorrend epízód vetítése alapján + Új lejátszási lista létrehozása + Hozzáadás lejátszási listához + Szüneteltetés egy klikkel + Nyomd a D-Pad közepét megállításhoz/lejátszáshoz + Dőlt betűk + Betűtípus + Háttérszín + Sikeres + Szülői besorolás + Műsoridő + Extrák + + Egyéb + + + + Színfalak mögött + + + + Téma zenék + + + + Téma videók + + + + Klippek + + + + Törölt jelenetek + + + + Interjúk + + + + Jelenetek + + + + Minták + + + + Rövidfilmek + + + + Rövidek + + + + %s letöltés + %s letöltések + + + %d óra + %d órák + + + %d elem + %d elemek + + + %d másodperc + %d másodpercek + + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ab1a6c76..cf05326b 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -269,4 +269,6 @@ Tahun Dekade Hapus + Dolby Vision + Dolby Atmos diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4d88195b..ed33256b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -303,4 +303,32 @@ Salta Riassunto Sovrascrivi riproduzione Salta indietro al riavvio della riproduzione + Margine + Log dettagliati + Dimensione cache Immagini su disco (MB) + MPV: Usa gpu-next + Modifica mpv.conf + Annullare le modifiche? + Inserisci PIN + Accedi automaticamente + Accedi tramite server + Premi centro per confermare + Richiedi PIN per il profilo + Conferma PIN + Errato + Il PIN deve essere di 4 cifre o più + Rimuoverà il PIN + Opzioni di visualizzazione + Colonne + Spaziatura + Mostra dettagli + Rapporto d’aspetto + Tipo di immagine + + Guest star + Dimensione del bordo + Accedi + Automatico + Cambio frequenza di aggiornamento + Usa nome utente/password diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-iw/strings.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0825a25c..a793ee8f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -124,7 +124,7 @@ Trailer Trailers - + DVR-schema Gids @@ -156,47 +156,47 @@ Extra\'s Overig - + Achter de schermen - + Titelmuziek - + Titelvideo\'s - + Clips - + Verwijderde scènes - + Interviews - + Scènes - + Voorvertoningen - + Featurettes - + Shorts - + %s download @@ -287,4 +287,32 @@ Verwijderen Dolby Vision Dolby Atmos + Marge + Uitgebreid logboek aanleggen + Omvang van afbeeldingscache (in MB) + Mpv: gpu-next gebruiken + mpv.conf bewerken + Wijzigingen negeren? + Pincode invoeren + Automatisch inloggen + Inloggen via server + Druk op het midden om te bevestigen + Profiel voorzien van pincode + Pincode bevestigen + Onjuist + De pincode dient 4 of meer tekens te bevatten + De pincode wordt gewist + Weergaveopties + Kolommen + Tussenruimte + Beeldverhouding + Details bekijken + Afbeeldingstype + + Gastrollen + Randbreedte + Andere ververssnelheid toepassen + Automatisch + Gebruikersnaam/wachtwoord gebruiken + Inloggen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 00000000..69a8f90a --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,301 @@ + + + Dodaj serwer + Dodaj użytkownika + Dźwięk + Miejsce urodzenia + Bitrate + Potwierdź + Kontynuuj oglądanie + Ocena krytyków + Pobieranie… + Odcinki + Biblioteka + Informacje o licencji + Ładowanie… + Oznaczyć serial jako odtworzony? + Oznaczyć cały sezon jako nieodtworzony? + Oznacz jako nieobejrzane + Oznacz jako obejrzane + Maksymalny bitrate + Podobne + Więcej + Filmy + Następnie + Brak danych + Brak wyników + Brak zaplanowanych nagrań + Brak dostępnych aktualizacji + Prędkość odtwarzania + Odtwarzanie + Polecane + Zapisz program + Pomiń + Data dodania + Data Dodania Odcinka + Seriale + Sezon + Sezony + Napisy + Napisy + Propozycje + Zmień serwer + Zmień + Najlepiej Oceniane Nieobejrzane + Zwiastun + + Zwiastuny + + + + + Potwierdź + Data Wydania + Nazwa + Losowy + Przewodnik TV + Studia Filmowe + Losuj + Pokaż następne + Ostatnio Wydane + Odtwórz + Pokaż informacje debugowania odtwarzacza + Napisy końcowe + Ścieżka + Playlisty + Podgląd + Ustawienia Profilu Użytkownika + Kolejka + Zapisz + Szukaj + Wyszukiwanie… + Ostatnio dodane + Ostatnio Nagrane + Ostatnio dodane w %1$s + Wznów + Data Odtworzenia + Ulubione + Anuluj + Rozdziały + Wybierz %1$s + Wyczyść pamięć podręczną obrazów + Ocena społeczności + Wystąpił błąd. Nacisnij przycisk żeby wysłać logi na Twój serwer. + Reżyser + Gatunek + Idź do serialu + Idż do + Ukryj przyciski odtwarzania + Ukryj informacje o debugowaniu + Ukryj + Strona Główna + Ulubione + Usuń + Domyślna ścieżka + Odnalezione Serwery + Wprowadż IP serwera lub adres URL + Rozmiar + Wymuszona ścieżka + Wstęp + Nazwa + Usuń z ulubionych + Uruchom ponownie + Wybierz Serwer + Wybierz Użytkownika + Pokaż infomacje o debugowaniu + Użyj nazwy użytkownika/hasła + Login + Kolumny + Odstępy + Pokaż szczegóły + Wprowadź PIN + Odstęp + Rok + Dekada + Usuń + Telewizja + Odtwórz z transkodowaniem + Pokaż Zegar + Czcionka + Tło + Pauza jednym kliknięciem + Wciśnij środek by pauzować/wznowić + + Wywiady + + + + + + Sceny + + + + + + Czołówki + + + + + Utwórz nową playliste + Dodaj do playlisty + Wersja + Aktualizacje + Anuluj Nagrywanie + Zobacz opcje + Potwierdź PIN + Nieprawidłowe + PIN musi zawierać 4 lub więcej znaków + Wymagaj PIN dla profilu + Dolby Vision + Dolby Atmos + MPV: użyj gpu-next + Edytuj mpv.conf + Odrzucić zmiany? + Naciśnij środek aby potwierdzić + Rozmiar pamięci tymczasowej obrazów (MB) + Wyłącz jeśli doświadczasz problemów + Opcje MPV + Opcje odtwarzacza ExoPlayer + Pomiń Segment + Pomiń Reklamy + Pomiń czołówkę + Pomiń napisy końcowe + Loguj automatycznie + Reset + Czcionka pogrubiona + Styl obramowania + Kolor obramowania + Przezroczystość tła + Styl tła + Styl napisów + Kolor tła + Uaktualnij adres URL + Rozmiar czcionki + Kolor czcionki + Ustawienia + Wyślij raport o błędach + Aktualizacja dostępna + MPV: używaj dekodowania sprzętowego + Filtr + Czcionka kursywy + + Za kulisami + + + + + Ustawienia zaawansowane + Sprawdź dostępność aktualizacji + Automatycznie sprawdzaj dostępność aktualizacji + Zainstaluj aktualizacje + Zainstalowana wersja + Użyj dekodera FFmpeg + Rozmiar ramki + O Wholphin + Nagrania w toku + Data Urodzenia + Anuluj Nagrywanie Serialu + Kolekcja + Kolekcje + Reklama + %.1f sekundy + Nieaktywne + + Wyreżyserowane przez %1$s + Aktywne + Błąd podczas ładowania kolekcji %1$s + Ścieżka zewnętrzna + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + + %d godzina + %d godziny + %d godzin + %d godzin + + Typ obrazu + + Domyślne skalowanie + Odtwarzaj bezpośrednio napisy ASS + Odtwarzaj bezpośrednio napisy PGS + Zawsze miksuj w dół do stereo + Motyw interfejsu + + %s pobieranie + %s pobierania + %s pobierań + %s pobierań + + + %d sekunda + %d sekundy + %d sekund + %d sekund + + Opóźnienie przed odtworzeniem kolejnego odcinka + Automatycznie odtwarzaj kolejny odcinek + Stosowane jedynie wobec seriali + + Maksimum elementów wiersza na stronie domowej + Wybierz domyślne elementy do wyświetlenia, pozostałe zostaną ukryte + + %d element + %d elementy + %d elementów + %d elementów + + Urządzenie wspiera AC3/Dolby Digital + Zaawansowany UI + Zmarły(-a) + Liczba odtworzeń + Odtwarzaj od tej pozycji + Playlista + Streszczenie + Zapisz serial + + Pokaż + Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzacza + Harmonogram nagrywania + Interfejs użytkownika + Skalowanie obrazu + Przezroczystość czcionki + Adres URL do sprawdzenia aktualizacji + Obsada + Żaden + Nieznany + %1$d lat temu + Powodzenie + Ocena Rodzicielska + Dodatki + + Inne + + + + + + Usunięte sceny + + + + + Naciśnij by zmienić stronę + Przydatne do debugowania + Wyślij logi aplikacji do serwera + Poziomy przewijania + Obejrzane + Logowanie na serwer + Umożliwiaj ponowne oglądanie w sekcji następne + Próba wysłania raportu błędów do ostatnio połączonego serwera + Zapamiętaj wybrane karty + Odtwórz muzykę motywu multimediów + Przewijanie do przodu + Pomiń wstęp + Pomiń informacje o poprzednich odcinkach + Dopasuj elementy interfejsu nawigacyjnego + Cofanie + Cofnij gdy wznawiasz odtwarzanie + Player odtwarzający + Ochrona przed uśpieniem + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 045e125f..34a61f80 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,3 +1,81 @@ + Sobre + Favorito + Adicionar Servidor + Adicionar Utilizador + Áudio + Cancelar Gravação + Cancelar + Capítulos + Escolher %1$s + Eliminar cache de imagens + Colecção + Colecções + Confirmar + %.1f segundos + Padrão + Eliminar + Realizado por %1$s + Realizador + Desativado + A transferir + Activado + Episódios + Externo + Favoritos + Tamanho + Forçado + Ir para + Gravações Activas + Local de Nascimento + Bitrate + Nascido + Cancelar Gravação da Série + Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor + Continuar a ver + Morreu + Servidores Detectados + + Introduz IP e URL do Servidor + Erro ao carregar a colecção %1$s + Géneros + Ir para a Série + Esconder controlos de reprodução + Esconder informação de depuração + Esconder + Início + Imediato + Introdução + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Biblioteca + Informação da licença + TV em Directo + A carregar… + Marcar série como vista? + Marcar toda a série como não vista? + Marcar como não visto + Marcar como visto + Bitrate Máximo + Mais como isto + Mais + Filmes + Nome + Próximo + Sem dados + Sem resultados + Sem gravações programadas + Nenhuma atualização disponível + Nenhum + Créditos finais + Pessoas + Reproduções + Reproduzir daqui + Reproduzir + Mostrar depuração da reprodução + Velocidade da Reprodução + Reprodução + Lista de Reprodução + Listas de Reprodução + Pré-visualização diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000..aeb28c50 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,346 @@ + + + Информация + В избранное + Добавить сервер + Добавить Пользователя + Аудиодорожка + Место рождения + Битрейт + Рождён + Отменить запись + Отмена + Главы + Выберите %1$s + Очистить кэш изображений + Коллекция + Коллекции + Оценка сообщества + Произошла ошибка! Нажмите клавишу, чтобы отправить файл журнала на ваш сервер. + Подтвердить + Продолжить просмотр + Оценка критиков + %.1f секунда + По умолчанию + Удалить + Умер + Режиссёр %1$s + Режиссёр + Обнаруженные серверы + + Скачивание… + Введите IP или URL сервера + Эпизоды + Ошибка при загрузке коллекции %1$s + Избранное + Устройство поддерживает AC3/Dolby Digital + Расширенные настройки + Тема оформления + Автоматически проверять наличие обновлений + Проверить наличие обновлений + Всегда преобразовывать в стерео + Использовать декодер FFmpeg + Размер + Жанры + Скрыть + Вступление + Библиотека + Информация о лицензии + Эфирное ТВ + Загрузка… + Отметить как непросмотренное + Отметить как просмотренное + Максимальный битрейт + Похожее + Фильмы + Имя + Далее + Нет информации + Нет результатов + Нет доступных обновлений + Путь + Люди + Показать отладочную инфомацию воспроизведения + Скорость воспроизведения + Воспроизведение + Плейлист + Плейлисты + Настройки профиля + Очередь + Недавно добавленное в %1$s + Недавно добавленное + Рекомендовано Вам + Убрать из избранного + Продолжить + Сохранить + + Найти + Поиск… + Выберите сервер + Выберите пользователя + Показать отладочную информацию + Показать + Перемешать + Пропустить + Дата добавления + Дата добавления эпизода + Дата воспроизведения + Дата выхода + Название + Случайно + Студии + Файл субтитров + Субтитры + Предложения + Сменить сервер + Сменить + Лучшее непросмотренное + Трейлер + + Трейлер + Трейлера + Трейлеров + Трейлеров + + Телегид + Сезон + Сезоны + Телешоу + Неизвестно + Обновления + Версия + Масштаб видео + Видео + Видео + %1$d возраст + Воспроизвести с перекодированием + Показывать часы + Создать новый плейлист + Добавить в плейлист + Пауза одним нажатием + Нажмите центральную кнопку для паузы/воспроизведения + Шрифт + Фоновое изображение + Успех + Возрастной рейтинг + Продолжительность + Дополнительно + + Другое + Других + Других + Других + + + За кадром + + + + + + Клип + Клипа + Клипов + Клипов + + + Удаленная сцена + Удалённые сцены + Удалённых сцен + Удалённых сцен + + + Интервью + Интервью + Интервью + Интервью + + + Сцена + Сцены + Сцен + Сцен + + + %s загрузка + %s загрузок + %s загрузок + %s загрузок + + + %d час + %d часа + %d часов + %d часов + + + %d секунда + %d секунды + %d секунд + %d секунд + + Активные записи + Внешний + Перейти + Пропустить рекламу + Пропустить вступление + Недавно записанное + Недавно вышедшее + Реклама + #ABCDEFGHIJKLMNOPQRSTUVWXYZАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ + Отметить все сезоны как воспроизведённые? + Отметить все сезоны как невоспроизведённые? + Скрыть отладочниую информацию + Домашняя страница + Отключено + Включено + Скрывать элементы управления воспроизведением + Ещё + Нет запланированных записей + Воспроизвести + Перезапустить + Воспроизвести отсюда + Смотреть прямой эфир + Порядок выхода эпизодов + Сброс + + Приглашённые звёзды + Соотношение сторон + Показать подробности + Интервал + Dolby Vision + Dolby Atmos + Редактировать mpv.conf + Отменить изменения? + Ведите ПИН-код + Автоматический вход + ПИН-код должен содержать минимум 4 символа + Настройки MPV + Настройки ExoPlayer + Год + Десятилетие + Убрать + MPV: Использовать аппаратное декодирование + Фильтр + Заключительные титры + Превью + Отправить + Загрузка занимает много времени, возможно, потребуется перезапустить воспроизведение + Отменить запись сериала + Нет + Интерфейс + Записать программу + Записать сериал + Количество воспроизведений + Краткое содержание + Вход через сервер + Нажмите центральную клавишу для подтверждения + Требовать ПИН-код профиля + Подтвердите ПИН-код + Неверно + Показать опции + Колонки + Стиль субтитров + Цвет фона + Жирный шрифт + Стиль фона + Тип изображения + Удалит ПИН-код + Размер кэша изображений на диске (Мбайт) + Пропустить краткое содержание + Пропустить заключительные титры + Подробный журнал + Отключите при нестабильной работе + MPV: использовать gpu-next + Немедленный + Отступ + Пропустить сегмент + Пропустить превью + Параметры + Перемотать назад при возобновлении воспроизведения + Перемотать назад + Поведение при пропуске рекламы + Перемотка вперёд + Поведение при пропуске вступления + Поведение при пропуске заключительных титров + Поведение при пропуске превью + Поведение при пропуске краткого содержания + Доступно обновление + URL для проверки обновлений приложения + Воспроизведено + Отправлять отчёты об ошибках + Показать Далее + Курсив + Расширенный интерфейс + Задержка перед воспроизведением следующей серии + Автовоспроизведение следующей серии + Применяется только к Телешоу + + Прямое воспроизведение ASS-субтитров + Прямое воспроизведение PGS-субтитров + Размер шрифта + Цвет щрифта + Прозрачность шрифта + Стиль обводки + Цвет обводки + Прозрачность фона + Установить обновление + Версия приложения + Отправить журнал на текущий сервер + Совершит попытку отправки на последний подключённый сервер + Перейти к сериалу + Расписание записей + Ссылка для обновлениий + + %d объект + %d объекта + %d объектов + %d объектов + + + Отрывки + + + + + + Дополнительные материалы + + + + + Выбор плеера + Толщина обводки + Масштаб по умолчанию + Максимальное количество элементов в ряду на домашней странице + Форсированные + + Саундтреки + + + + + + Видеоролики + + + + + Настроить элементы боковой панели + Выберите элементы для показа на панели, остальные элементы будут скрыты + Воспроизведение фоновой музыки + Приоритеты воспроизведения + Запоминать выбранные вкладки + Разрешить просмотренное в \"Далее\" + Интервал перемотки + Используется при отладке + Смена страниц по нажатию + Смена страниц по выделению + Остановка воспроизведения при бездействии + + Короткометражки + + + + + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 45c38853..c55a7acc 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -33,7 +33,7 @@ Sťahovanie… Povolené - Zadajte IP adresu alebo URL servera vrátane portu + Zadajte IP adresu alebo URL servera Epizódy Chyba pri načítaní kolekcie %1$s Externé @@ -101,4 +101,5 @@ Vybrať server Vybrať používateľa Zobraziť informácie o debugu + Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 21304e53..b7d13882 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -197,7 +197,7 @@ Markera som ospelad Markera som spelad Maximal bithastighet - Mer liknande detta + Liknande titlar Näst på tur Ingen data Inga schemalagda inspelningar @@ -205,7 +205,7 @@ Ingen Slutsekvens Sökväg - Personer + Medverkande Spelningar Spela härifrån Visa felsökningsinfo för uppspelning @@ -253,4 +253,28 @@ Utseende på text Bakgrundsfärg Spelad + Filter + Konfirmera kod + Bildtyp + Storlek på bilddiskcache (MB) + Visa alternativ + Kolumner + Avstånd + Bildformat + Visa detaljer + Gästartister + Automatisk + Använd användarnamn/lösenord + Logga in + Hoppa framåt + Återställ + Skippa sammanfattning + År + Årtionde + Ta bort + Dolby Vision + Marginal + Skriv in PIN + Logga in automatiskt + Logga in via server diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 249c4d5f..7e65e85b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -11,7 +11,7 @@ 更多 電影 名稱 - 即將播放 + 接下來播放 無資料 無查詢結果 無排程錄影 @@ -24,11 +24,11 @@ 播放清單 播放清單 預覽 - 使用者設定 + 個人化設定 佇列 - 近期新增 - 近期錄製 - 近期發行 + 最近新增 + 最近錄製 + 最近發行 推薦 錄製節目 重頭播放 @@ -41,8 +41,8 @@ 顯示 隨機播放 跳過 - 加入日期 - 劇集加入日期 + 新增日期 + 單集新增日期 發行日期 名稱 隨機 @@ -61,7 +61,7 @@ 版本 畫面比例 影片 - 所有影片 + 影片 觀看直播 顯示時間 新增播放清單 @@ -147,7 +147,7 @@ 位元率 生日 取消錄製 - 取消影集錄製 + 取消劇集錄製 取消 章節 選擇 %1$s @@ -172,7 +172,7 @@ 下載中… 已啟用 輸入伺服器 IP 位址或 URL - 劇集 + 單集 我的最愛 前往劇集 前往 @@ -183,8 +183,8 @@ #ABCDEFGHIJKLMNOPQRSTUVWXYZ 授權資訊 電視直播 - 標記整部劇集為已觀看? - 標記整部劇集為未觀看? + 標記整部電視劇為已觀看? + 標記整部電視劇為未觀看? 標記為未觀看 標記為已觀看 最大位元率 @@ -194,7 +194,7 @@ 顯示播放偵錯資訊 播放 前情提要 - 最近新增的 %1$s + 最近新增於 %1$s 錄製劇集 移出我的最愛 繼續播放 @@ -223,13 +223,13 @@ %d 秒 播放下一集前的延遲時間 - 僅適用於劇集 - + 僅適用於電視劇 + 一律降轉為雙聲道 選取選單時自動切換頁面 防睡保護 - 在即將播放中顯示重播內容 - 顯示即將播放 + 在接下來播放中顯示重播內容 + 顯示接下來播放 跳過前情提要 跳過片尾 跳過片頭 @@ -269,4 +269,7 @@ 將嘗試傳送至最近連線的伺服器 傳送程式日誌到目前伺服器 有助於偵錯 + 邊距 + 詳細日誌記錄 + 使用 PIN 鍵登入 From 6d6156917cb89a411030446e46d2da75ce981563 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:28:03 -0500 Subject: [PATCH 051/124] Update readme with features --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 86bc01ea..7cf0c692 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,12 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin ### User interface -- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app +- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app - Option to combine Continue Watching & Next Up rows - Show Movie/TV Show titles when browsing libraries - Play theme music, if available +- Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles)) - Customize layout grids for libraries -- Access all your favorites quickly from the nav drawer - Multiple app color themes ### Playback @@ -45,6 +45,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin - Optionally skip back a few seconds when resuming playback - Live TV & DVR support - Auto play next episodes with pass out protection +- Option for automatic refresh rate switching on supported displays +- Trickplay support - Other (subjective) enhancements: - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad - Force Continue Watching & Next Up TV episodes to use their Series posters From c3f6fd7f601525ea34a23cb71f87b1e023d3ff3d Mon Sep 17 00:00:00 2001 From: Amar Sandhu Date: Fri, 12 Dec 2025 09:49:30 -0700 Subject: [PATCH 052/124] Add media information dialog. (#416) Addresses #414 --------- Co-authored-by: Damontecres --- .../wholphin/ui/components/Dialogs.kt | 9 +- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 418 +++++++++++++----- .../wholphin/ui/detail/DetailUtils.kt | 13 + .../ui/detail/episode/EpisodeDetails.kt | 12 + .../wholphin/ui/detail/movie/MovieDetails.kt | 11 + .../ui/detail/series/SeriesOverview.kt | 9 + app/src/main/res/values/strings.xml | 34 ++ 7 files changed, 401 insertions(+), 105 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 6f723f10..bfb098d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -310,6 +310,9 @@ fun DialogPopup( @Composable fun ScrollableDialog( onDismissRequest: () -> Unit, + width: Dp = 600.dp, + maxHeight: Dp = 380.dp, + itemSpacing: Dp = 8.dp, content: LazyListScope.() -> Unit, ) { val scrollAmount = 100f @@ -331,12 +334,12 @@ fun ScrollableDialog( LazyColumn( state = columnState, contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(itemSpacing), content = content, modifier = Modifier - .width(600.dp) - .heightIn(max = 380.dp) + .width(width) + .heightIn(max = maxHeight) .focusable() .background( MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index cf3e2896..32dc213d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -1,28 +1,35 @@ package com.github.damontecres.wholphin.ui.data +import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.components.formatAudioCodec +import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.util.languageName -import org.jellyfin.sdk.model.api.AudioSpatialFormat 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.VideoRange +import org.jellyfin.sdk.model.api.VideoRangeType import java.util.Locale data class ItemDetailsDialogInfo( @@ -37,129 +44,336 @@ fun ItemDetailsDialog( info: ItemDetailsDialogInfo, showFilePath: Boolean, onDismissRequest: () -> Unit, -) = ScrollableDialog( - onDismissRequest = onDismissRequest, ) { - item { - Text( - text = info.title, - style = MaterialTheme.typography.titleLarge, - ) - } - if (info.genres.isNotEmpty()) { + val context = LocalContext.current + // Extract stringResource calls outside of ScrollableDialog's non-composable lambda + val pathLabel = stringResource(R.string.path) + val fileSizeLabel = stringResource(R.string.file_size) + val videoLabel = stringResource(R.string.video) + val audioLabel = stringResource(R.string.audio) + val subtitleLabel = stringResource(R.string.subtitle) + val bitrateLabel = stringResource(R.string.bitrate) + + ScrollableDialog( + onDismissRequest = onDismissRequest, + width = 720.dp, + maxHeight = 480.dp, + itemSpacing = 4.dp, + ) { item { Text( - text = info.genres.joinToString(", "), - style = MaterialTheme.typography.titleSmall, + text = info.title, + style = MaterialTheme.typography.titleLarge, ) } - } - if (info.overview.isNotNullOrBlank()) { - item { - Text( - text = info.overview, - style = MaterialTheme.typography.bodyLarge, - ) + if (info.genres.isNotEmpty()) { + item { + Text( + text = info.genres.joinToString(", "), + style = MaterialTheme.typography.titleSmall, + ) + } } - } - if (info.files.isNotEmpty()) { - item { - Spacer(Modifier.height(12.dp)) + if (info.overview.isNotNullOrBlank()) { + item { + Text( + text = info.overview, + style = MaterialTheme.typography.bodyLarge, + ) + } } - } - items(info.files) { file -> - ProvideTextStyle(MaterialTheme.typography.bodyMedium) { - MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp)) + + // Show detailed media information for the selected source (first one if multiple) + info.files.forEachIndexed { index, source -> + source.mediaStreams?.letNotEmpty { mediaStreams -> + item { + Spacer(Modifier.height(8.dp)) + } + + // General file information + item { + val containerLabel = stringResource(R.string.container) + MediaInfoSection( + title = stringResource(R.string.general), + items = + buildList { + source.container?.let { add(containerLabel to it) } + if (showFilePath) { + source.path?.let { add(pathLabel to it) } + } + source.size?.let { + add(fileSizeLabel to formatBytes(it)) + } + source.bitrate?.let { + add( + bitrateLabel to formatBytes(it, byteRateSuffixes), + ) + } + }, + ) + } + + // Video streams + items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream -> + MediaInfoSection( + title = videoLabel, + items = buildVideoStreamInfo(context, stream), + ) + } + + // Audio streams - display multiple per row + items( + mediaStreams + .filter { it.type == MediaStreamType.AUDIO } + .chunked(3), + ) { streamGroup -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + streamGroup.forEach { stream -> + MediaInfoSection( + title = audioLabel, + items = buildAudioStreamInfo(context, stream), + modifier = Modifier.weight(1f), + ) + } + // Fill remaining space if less than 3 items + repeat(3 - streamGroup.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + // Subtitle streams - display multiple per row + items( + mediaStreams + .filter { it.type == MediaStreamType.SUBTITLE } + .chunked(3), + ) { streamGroup -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + streamGroup.forEach { stream -> + MediaInfoSection( + title = subtitleLabel, + items = buildSubtitleStreamInfo(context, stream), + modifier = Modifier.weight(1f), + ) + } + // Fill remaining space if less than 3 items + repeat(3 - streamGroup.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + if (index != info.files.lastIndex) { + item { + Spacer(Modifier.height(8.dp)) + } + } + } } } } @Composable -fun MediaSourceInfo( - source: MediaSourceInfo, - showFilePath: Boolean, +private fun MediaInfoSection( + title: String, + items: List>, modifier: Modifier = Modifier, ) { Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier.padding(vertical = 4.dp), ) { Text( - text = stringResource(R.string.name) + ": ${source.name}", + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, ) - Text( - text = "ID: ${source.id}", - ) - if (showFilePath) { - Text( - text = stringResource(R.string.path) + ": ${source.path}", - ) - } - source.size?.let { size -> - Text( - text = stringResource(R.string.file_size) + ": ${formatBytes(size)}", - ) - } - source.bitrate?.let { bitrate -> - Text( - text = - stringResource(R.string.bitrate) + ": ${ - formatBytes( - bitrate, - byteRateSuffixes, - ) - }", - ) - } - source.mediaStreams?.letNotEmpty { streams -> - streams.filter { it.type == MediaStreamType.VIDEO }.forEach { stream -> - val data = - buildList { - if (stream.width != null && stream.height != null) { - add("${stream.width}x${stream.height}") - } - stream.averageFrameRate?.let { - add(String.format(Locale.getDefault(), "%.3f", it) + " fps") - } - stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) } - stream.codec?.let(::add) - stream.profile?.let(::add) - } + items.forEach { (label, value) -> + Row( + modifier = Modifier.padding(start = 12.dp), + ) { Text( - text = stringResource(R.string.video) + ": " + data.joinToString(" - "), + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) - } - - streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream -> - val data = - buildList { - stream.language?.let { add(languageName(it)) } - stream.codec?.let(::add) - stream.channelLayout?.let(::add) - stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) } - if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName) - if (stream.isDefault) add(stringResource(R.string.default_track)) - } Text( - text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "), - ) - } - - streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream -> - val data = - buildList { - stream.language?.let { add(languageName(it)) } - stream.codec?.let(::add) - if (stream.isDefault) add(stringResource(R.string.default_track)) - if (stream.isForced) add(stringResource(R.string.forced_track)) - if (stream.isExternal) add(stringResource(R.string.external_track)) - } - Text( - text = - stringResource(R.string.subtitle) + " ${index + 1}: " + - data.joinToString(" - "), + text = value, + style = MaterialTheme.typography.bodyMedium, ) } } } } + +private fun buildVideoStreamInfo( + context: Context, + stream: MediaStream, +): List> = + buildList { + val titleLabel = context.getString(R.string.title) + val codecLabel = context.getString(R.string.codec) + val avcLabel = context.getString(R.string.avc) + val profileLabel = context.getString(R.string.profile) + val levelLabel = context.getString(R.string.level) + val resolutionLabel = context.getString(R.string.resolution) + val aspectRatioLabel = context.getString(R.string.aspect_ratio) + val anamorphicLabel = context.getString(R.string.anamorphic) + val interlacedLabel = context.getString(R.string.interlaced) + val framerateLabel = context.getString(R.string.framerate) + val bitrateLabel = context.getString(R.string.bitrate) + val bitDepthLabel = context.getString(R.string.bit_depth) + val videoRangeLabel = context.getString(R.string.video_range) + val videoRangeTypeLabel = context.getString(R.string.video_range_type) + val colorSpaceLabel = context.getString(R.string.color_space) + val colorTransferLabel = context.getString(R.string.color_transfer) + val colorPrimariesLabel = context.getString(R.string.color_primaries) + val pixelFormatLabel = context.getString(R.string.pixel_format) + val refFramesLabel = context.getString(R.string.ref_frames) + val nalLabel = context.getString(R.string.nal) + val yesStr = context.getString(R.string.yes) + val noStr = context.getString(R.string.no) + val sdrStr = context.getString(R.string.sdr) + val hdrStr = context.getString(R.string.hdr) + val hdr10Str = context.getString(R.string.hdr10) + val hdr10PlusStr = context.getString(R.string.hdr10_plus) + val hlgStr = context.getString(R.string.hlg) + val bitUnit = context.getString(R.string.bit_unit) + + stream.title?.let { add(titleLabel to it) } + stream.codec?.let { add(codecLabel to it.uppercase()) } + stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } + stream.profile?.let { add(profileLabel to it) } + stream.level?.let { add(levelLabel to it.toString()) } + if (stream.width != null && stream.height != null) { + add(resolutionLabel to "${stream.width}x${stream.height}") + } + if (stream.width != null && stream.height != null) { + val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) + add(aspectRatioLabel to aspectRatio) + } + stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } + stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) } + stream.averageFrameRate?.let { + add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) + } + stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } + stream.videoRange?.let { + val rangeStr = + when (it) { + VideoRange.SDR -> sdrStr + VideoRange.HDR -> hdrStr + VideoRange.UNKNOWN -> null + else -> null + } + rangeStr?.let { add(videoRangeLabel to it) } + } + stream.videoRangeType?.let { + val rangeTypeStr = + when (it) { + VideoRangeType.SDR -> sdrStr + + VideoRangeType.HDR10 -> hdr10Str + + VideoRangeType.HDR10_PLUS -> hdr10PlusStr + + VideoRangeType.HLG -> hlgStr + + VideoRangeType.DOVI, + VideoRangeType.DOVI_WITH_HDR10, + VideoRangeType.DOVI_WITH_HLG, + VideoRangeType.DOVI_WITH_SDR, + -> context.getString(R.string.dolby_vision) + + VideoRangeType.UNKNOWN -> null + + else -> null + } + rangeTypeStr?.let { add(videoRangeTypeLabel to it) } + } + stream.colorSpace?.let { add(colorSpaceLabel to it) } + stream.colorTransfer?.let { add(colorTransferLabel to it) } + stream.colorPrimaries?.let { add(colorPrimariesLabel to it) } + stream.pixelFormat?.let { add(pixelFormatLabel to it) } + stream.refFrames?.let { add(refFramesLabel to it.toString()) } + stream.nalLengthSize?.let { add(nalLabel to it.toString()) } + } + +private fun buildAudioStreamInfo( + context: Context, + stream: MediaStream, +): List> = + buildList { + val titleLabel = context.getString(R.string.title) + val languageLabel = context.getString(R.string.language) + val codecLabel = context.getString(R.string.codec) + val layoutLabel = context.getString(R.string.layout) + val channelsLabel = context.getString(R.string.channels) + val bitrateLabel = context.getString(R.string.bitrate) + val sampleRateLabel = context.getString(R.string.sample_rate) + val defaultLabel = context.getString(R.string.default_track) + val externalLabel = context.getString(R.string.external_track) + val yesStr = context.getString(R.string.yes) + val noStr = context.getString(R.string.no) + val sampleRateUnit = context.getString(R.string.sample_rate_unit) + + stream.title?.let { add(titleLabel to it) } + stream.language?.let { add(languageLabel to languageName(it)) } + stream.codec?.let { + val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase() + add(codecLabel to formattedCodec) + } + stream.channelLayout?.let { add(layoutLabel to it) } + stream.channels?.let { add(channelsLabel to it.toString()) } + stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } + stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } + } + +private fun buildSubtitleStreamInfo( + context: Context, + stream: MediaStream, +): List> = + buildList { + val titleLabel = context.getString(R.string.title) + val languageLabel = context.getString(R.string.language) + val codecLabel = context.getString(R.string.codec) + val avcLabel = context.getString(R.string.avc) + val defaultLabel = context.getString(R.string.default_track) + val forcedLabel = context.getString(R.string.forced_track) + val externalLabel = context.getString(R.string.external_track) + val yesStr = context.getString(R.string.yes) + val noStr = context.getString(R.string.no) + + stream.title?.let { add(titleLabel to it) } + stream.language?.let { add(languageLabel to languageName(it)) } + stream.codec?.let { + val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() + add(codecLabel to formattedCodec) + } + stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } + stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } + stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) } + stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) } + } + +private fun calculateAspectRatio( + width: Int, + height: Int, +): String { + val gcd = gcd(width, height) + val w = width / gcd + val h = height / gcd + return "$w:$h" +} + +private fun gcd( + a: Int, + b: Int, +): Int = if (b == 0) a else gcd(b, a % b) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 23daa573..c920fb5b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.ui.graphics.Color @@ -43,6 +44,7 @@ data class MoreDialogActions( * @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 + * @param onShowOverview callback to show overview dialog with media information */ fun buildMoreDialogItems( context: Context, @@ -54,6 +56,7 @@ fun buildMoreDialogItems( actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, + onShowOverview: () -> Unit, ): List = buildList { add( @@ -159,6 +162,16 @@ fun buildMoreDialogItems( }, ) } + if (item.data.mediaSources?.isNotEmpty() == true) { + add( + DialogItem( + context.getString(R.string.media_information), + Icons.Default.Info, + ) { + onShowOverview.invoke() + }, + ) + } add( DialogItem( context.getString(R.string.play_with_transcoding), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index cfcc4284..fdda6e4a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -195,6 +195,18 @@ fun EpisodeDetails( ) } }, + onShowOverview = { + val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull() + if (source != null) { + overviewDialog = + ItemDetailsDialogInfo( + title = ep.name ?: context.getString(R.string.unknown), + overview = ep.data.overview, + genres = ep.data.genres.orEmpty(), + files = listOf(source), + ) + } + }, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 88f5cce7..d22774e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -242,6 +242,17 @@ fun MovieDetails( ) } }, + onShowOverview = { + overviewDialog = + ItemDetailsDialogInfo( + title = + movie.name + ?: context.getString(R.string.unknown), + overview = movie.data.overview, + genres = movie.data.genres.orEmpty(), + files = movie.data.mediaSources.orEmpty(), + ) + }, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 54988b6d..d5520c87 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -278,6 +278,15 @@ fun SeriesOverview( ) } }, + onShowOverview = { + overviewDialog = + ItemDetailsDialogInfo( + title = ep.name ?: context.getString(R.string.unknown), + overview = ep.data.overview, + genres = ep.data.genres.orEmpty(), + files = ep.data.mediaSources.orEmpty(), + ) + }, ), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb2fce4f..5d28905b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,7 @@ Watch live %1$d years old Play with transcoding + Media Information Show Clock Aired Episode Order Create new playlist @@ -354,6 +355,39 @@ Automatic Use username/password Login + General + Container + Title + Codec + Profile + Level + Resolution + Anamorphic + Interlaced + Framerate + Bit depth + Video range + Video range type + Color space + Color transfer + Color primaries + Pixel format + Ref frames + NAL + Language + Layout + Channels + Sample rate + AVC + Yes + No + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz Show titles From 652becd685b9eeed641434e50c8587633dfcfcbe Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 12 Dec 2025 19:16:43 -0500 Subject: [PATCH 053/124] Disable pin check --- .../com/github/damontecres/wholphin/data/ServerRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 5fc98724..48bea5b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -126,7 +126,9 @@ class ServerRepository } if (serverAndUsers != null) { val user = serverAndUsers.users.firstOrNull { it.id == userId } - if (user != null && !user.hasPin) { + if (user != null) { + // TODO pin-related +// if (user != null && !user.hasPin) { changeUser(serverAndUsers.server, user) return true } From 0d8800863b940e133481245036af37901f374e32 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:04:52 -0500 Subject: [PATCH 054/124] Prefetch trickplay images (#445) Prefetch trickplay images and cache them so that quick scrolling doesn't require network calls and images will appear significantly faster Fixes #432 --- .pre-commit-config.yaml | 5 ++ .../damontecres/wholphin/ui/CoilConfig.kt | 35 ++++++++++++- .../wholphin/ui/playback/PlaybackViewModel.kt | 52 +++++++++++++++---- .../wholphin/ui/playback/SeekPreviewImage.kt | 2 - 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f8ec6d53..75d48ceb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,3 +18,8 @@ repos: entry: val DEBUG = true language: pygrep files: '.+\.kt' + - id: check-debug-logging-enabled + name: Check debug enabled + entry: debugLogging = true + language: pygrep + files: '.+\.kt' diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index 3efff666..899498fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache +import coil3.network.CacheStrategy +import coil3.network.NetworkRequest +import coil3.network.NetworkResponse import coil3.network.cachecontrol.CacheControlCacheStrategy import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.Options import coil3.request.crossfade import coil3.util.DebugLogger import okhttp3.OkHttpClient @@ -67,10 +71,39 @@ fun CoilConfig( .components { add( OkHttpNetworkFetcherFactory( - cacheStrategy = { CacheControlCacheStrategy() }, + cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) }, callFactory = { client }, ), ) }.build() } } + +/** + * This [CacheStrategy] always prefers the cached response for Trickplay images, + * otherwise the decision is delegated to the provided [CacheStrategy] + * + * The expectation is that Trickplay images will be prefetched so the cache will always be warm + */ +@OptIn(ExperimentalCoilApi::class) +private class WholphinCacheStrategy( + private val delegate: CacheStrategy, +) : CacheStrategy { + override suspend fun read( + cacheResponse: NetworkResponse, + networkRequest: NetworkRequest, + options: Options, + ): CacheStrategy.ReadResult = + if (networkRequest.url.contains("/Trickplay/")) { + CacheStrategy.ReadResult(cacheResponse) + } else { + delegate.read(cacheResponse, networkRequest, options) + } + + override suspend fun write( + cacheResponse: NetworkResponse?, + networkRequest: NetworkRequest, + networkResponse: NetworkResponse, + options: Options, + ): CacheStrategy.WriteResult = delegate.write(cacheResponse, networkRequest, networkResponse, options) +} 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 5f8b656c..524bcf03 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 @@ -21,6 +21,8 @@ import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener +import coil3.imageLoader +import coil3.request.ImageRequest import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -92,6 +94,7 @@ import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage +import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -99,6 +102,7 @@ import timber.log.Timber import java.util.Date import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -404,6 +408,13 @@ class PlaybackViewModel ?.get(mediaSource.id) ?.values ?.firstOrNull() + trickPlayInfo?.let { trickplayInfo -> + mediaSource.runTimeTicks?.ticks?.let { duration -> + viewModelScope.launchIO { + prefetchTrickplay(duration, trickplayInfo) + } + } + } val chapters = Chapter.fromDto(base, api) withContext(Dispatchers.Main) { @@ -723,18 +734,39 @@ class PlaybackViewModel ) } - fun getTrickplayUrl(index: Int): String? { - val itemId = item.id - val mediaSourceId = currentItemPlayback.value?.sourceId - val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null - return api.trickplayApi.getTrickplayTileImageUrl( - itemId, - trickPlayInfo.width, - index, - mediaSourceId, - ) + private suspend fun prefetchTrickplay( + duration: Duration, + trickplayInfo: TrickplayInfo, + ) { + val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight + val totalCount = + (duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1 + (0.. Date: Fri, 12 Dec 2025 22:05:01 -0500 Subject: [PATCH 055/124] Customize TV guide display (#443) Adds a view options button for the TV guide to allow some basic customization: * Show details header * Adjust how channels are sorted * Toggle color coding for programs Related to #250 Closes #288 Fixes #439 --- .../wholphin/preferences/AppPreference.kt | 53 ++++++++ .../preferences/AppPreferencesSerializer.kt | 18 +++ .../wholphin/services/AppUpgradeHandler.kt | 14 ++ .../wholphin/ui/detail/livetv/Components.kt | 5 +- .../ui/detail/livetv/LiveTvViewModel.kt | 59 ++++++++- .../detail/livetv/LiveTvViewOptionsDialog.kt | 94 ++++++++++++++ .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 102 ++++++++++++--- .../ui/detail/livetv/TvGuideHeader.kt | 122 ++++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 8 ++ app/src/main/res/values/strings.xml | 4 + 10 files changed, 456 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 9a196d6b..19810b5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -784,6 +784,51 @@ sealed interface AppPreference { } }, ) + + val LiveTvShowHeader = + AppSwitchPreference( + title = R.string.show_details, + defaultValue = true, + getter = { it.interfacePreferences.liveTvPreferences.showHeader }, + setter = { prefs, value -> + prefs.updateLiveTvPreferences { showHeader = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val LiveTvFavoriteChannelsBeginning = + AppSwitchPreference( + title = R.string.favorite_channels_at_beginning, + defaultValue = true, + getter = { it.interfacePreferences.liveTvPreferences.favoriteChannelsAtBeginning }, + setter = { prefs, value -> + prefs.updateLiveTvPreferences { favoriteChannelsAtBeginning = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val LiveTvChannelSortByWatched = + AppSwitchPreference( + title = R.string.sort_channels_recently_watched, + defaultValue = false, + getter = { it.interfacePreferences.liveTvPreferences.sortByRecentlyWatched }, + setter = { prefs, value -> + prefs.updateLiveTvPreferences { sortByRecentlyWatched = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val LiveTvColorCodePrograms = + AppSwitchPreference( + title = R.string.color_code_programs, + defaultValue = true, + getter = { it.interfacePreferences.liveTvPreferences.colorCodePrograms }, + setter = { prefs, value -> + prefs.updateLiveTvPreferences { colorCodePrograms = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) } } @@ -948,6 +993,14 @@ val advancedPreferences = ) } +val liveTvPreferences = + listOf( + AppPreference.LiveTvShowHeader, + AppPreference.LiveTvFavoriteChannelsBeginning, + AppPreference.LiveTvChannelSortByWatched, + AppPreference.LiveTvColorCodePrograms, + ) + data class AppSwitchPreference( @get:StringRes override val title: Int, override val defaultValue: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index e3223663..0529e644 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -98,6 +98,19 @@ class AppPreferencesSerializer .apply { resetSubtitles() }.build() + + liveTvPreferences = + LiveTvPreferences + .newBuilder() + .apply { + showHeader = AppPreference.LiveTvShowHeader.defaultValue + favoriteChannelsAtBeginning = + AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue + sortByRecentlyWatched = + AppPreference.LiveTvChannelSortByWatched.defaultValue + colorCodePrograms = + AppPreference.LiveTvColorCodePrograms.defaultValue + }.build() }.build() advancedPreferences = @@ -155,6 +168,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateLiveTvPreferences(block: LiveTvPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + liveTvPreferences = liveTvPreferences.toBuilder().apply(block).build() + } + inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences = update { advancedPreferences = advancedPreferences.toBuilder().apply(block).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index eda96fa3..67b55a1b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences +import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences @@ -173,4 +174,17 @@ suspend fun upgradeApp( } } } + if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { + appPreferences.updateData { + it.updateLiveTvPreferences { + showHeader = AppPreference.LiveTvShowHeader.defaultValue + favoriteChannelsAtBeginning = + AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue + sortByRecentlyWatched = + AppPreference.LiveTvChannelSortByWatched.defaultValue + colorCodePrograms = + AppPreference.LiveTvColorCodePrograms.defaultValue + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index 7011975f..41b7b114 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -25,14 +25,17 @@ import coil3.compose.AsyncImage fun Program( program: TvProgram, focused: Boolean, + colorCode: Boolean, modifier: Modifier = Modifier, ) { val background = if (focused) { MaterialTheme.colorScheme.inverseSurface - } else { + } else if (colorCode) { program.category?.color ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + } else { + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) } val textColor = MaterialTheme.colorScheme.contentColorFor(background) Box( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 0d86729d..b9c74ce6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.content.Context import androidx.compose.ui.graphics.Color +import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn @@ -23,7 +25,13 @@ import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -34,7 +42,9 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.TimerInfoDto import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks @@ -45,11 +55,13 @@ import java.util.UUID import javax.inject.Inject import kotlin.math.min import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds const val MAX_HOURS = 48L +@OptIn(FlowPreview::class) @HiltViewModel class LiveTvViewModel @Inject @@ -57,6 +69,7 @@ class LiveTvViewModel @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, + val preferences: DataStore, private val serverRepository: ServerRepository, ) : ViewModel() { val loading = MutableLiveData(LoadingState.Pending) @@ -75,11 +88,25 @@ class LiveTvViewModel private val range = 100 - fun init(firstLoad: Boolean) { - guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) - if (!firstLoad) { - loading.value = LoadingState.Loading + init { + viewModelScope.launchIO { + preferences.data + .map { + it.interfacePreferences.liveTvPreferences.let { + Pair(it.sortByRecentlyWatched, it.favoriteChannelsAtBeginning) + } + }.distinctUntilChanged() + .debounce { 500.milliseconds } + .collectLatest { + Timber.v("Init due to pref change") + loading.setValueOnMain(LoadingState.Pending) + init() + } } + } + + fun init() { + guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( @@ -87,11 +114,26 @@ class LiveTvViewModel "Could not fetch channels", ), ) { + val prefs = + (preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()) + .interfacePreferences.liveTvPreferences val channelData by api.liveTvApi.getLiveTvChannels( GetLiveTvChannelsRequest( startIndex = 0, userId = serverRepository.currentUser.value?.id, - enableFavoriteSorting = true, + enableFavoriteSorting = prefs.favoriteChannelsAtBeginning, + sortBy = + if (prefs.sortByRecentlyWatched) { + listOf(ItemSortBy.DATE_PLAYED) + } else { + null + }, + sortOrder = + if (prefs.sortByRecentlyWatched) { + SortOrder.DESCENDING + } else { + null + }, addCurrentProgram = false, ), ) @@ -148,6 +190,7 @@ class LiveTvViewModel channelIds = channelsToFetch.map { it.id }, sortBy = listOf(ItemSortBy.START_DATE), userId = serverRepository.currentUser.value?.id, + fields = listOf(ItemFields.OVERVIEW), ) val fetchedPrograms = api.liveTvApi @@ -173,6 +216,7 @@ class LiveTvViewModel } else { null } + val p = TvProgram( id = dto.id, @@ -188,6 +232,8 @@ class LiveTvViewModel duration = dto.runTimeTicks!!.ticks, name = dto.seriesName ?: dto.name, subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, + overview = dto.overview, + officialRating = dto.officialRating, seasonEpisode = if (dto.indexNumber != null && dto.parentIndexNumber != null) { SeasonEpisode( @@ -474,11 +520,13 @@ data class TvProgram( val duration: Duration, val name: String?, val subtitle: String?, + val overview: String? = null, val seasonEpisode: SeasonEpisode?, val isRecording: Boolean, val isSeriesRecording: Boolean, val isRepeat: Boolean, val category: ProgramCategory?, + val officialRating: String? = null, ) { val isFake = category == ProgramCategory.FAKE @@ -500,6 +548,7 @@ data class TvProgram( duration = ((endHours - startHours) * 60).toInt().minutes, name = NO_DATA, subtitle = null, + overview = null, seasonEpisode = null, isRecording = false, isSeriesRecording = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt new file mode 100644 index 00000000..a0971f8e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt @@ -0,0 +1,94 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import android.view.Gravity +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +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 androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.liveTvPreferences +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun LiveTvViewOptionsDialog( + preferences: AppPreferences, + onDismissRequest: () -> Unit, + onViewOptionsChange: (AppPreferences) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val columnState = rememberLazyListState() + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.END) + window.setDimAmount(0f) + } + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .width(256.dp) + .heightIn(max = 380.dp) + .focusRequester(focusRequester) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + stickyHeader { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + ) + } + items(liveTvPreferences) { pref -> + pref as AppPreference + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(preferences, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index d0a811fc..8fee8995 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -2,10 +2,13 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -13,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -24,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key @@ -39,10 +44,15 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.LiveTvPreferences import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -66,16 +76,21 @@ fun TvGuideGrid( modifier: Modifier = Modifier, viewModel: LiveTvViewModel = hiltViewModel(), ) { - var firstLoad by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { - viewModel.init(firstLoad) - firstLoad = false - } + val scope = rememberCoroutineScope() +// var firstLoad by rememberSaveable { mutableStateOf(true) } +// LaunchedEffect(Unit) { +// viewModel.init(firstLoad) +// firstLoad = false +// } val loading by viewModel.loading.observeAsState(LoadingState.Pending) val channels by viewModel.channels.observeAsState(listOf()) val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf())) // val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) // val fetchedRange by viewModel.fetchedRange.observeAsState(0..0) + val preferences by viewModel.preferences.data + .collectAsState(AppPreferences.getDefaultInstance()) + val tvPrefs = preferences.interfacePreferences.liveTvPreferences + var showViewOptions by remember { mutableStateOf(false) } when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) @@ -94,16 +109,49 @@ fun TvGuideGrid( val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) var showItemDialog by remember { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } + val buttonFocusRequester = remember { FocusRequester() } if (requestFocusAfterLoading) { LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } } - Column(modifier = modifier) { + var focusedPosition by rememberPosition(0, 0) + val focusedProgram = + remember(focusedPosition) { + focusedPosition.let { + val channelId = channels.getOrNull(it.row)?.id + programs.programsByChannel[channelId]?.getOrNull(it.column) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { if (channels.isEmpty()) { ErrorMessage("Live TV is enabled, but no channels were found.", null) } else { + AnimatedVisibility(tvPrefs.showHeader) { + TvGuideHeader( + program = focusedProgram, + modifier = + Modifier + .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) + .fillMaxHeight(.30f), + ) + } + AnimatedVisibility(focusedPosition.row < 1) { + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = { showViewOptions = true }, + modifier = + Modifier + .padding(start = tvGuideDimensions.channelWidth) + .focusRequester(buttonFocusRequester), + ) + } TvGuideGridContent( + preferences = tvPrefs, loading = state is LoadingState.Loading, channels = channels, programs = programs, @@ -118,6 +166,7 @@ fun TvGuideGrid( ) }, onFocus = { + focusedPosition = it onRowPosition.invoke(it.row) viewModel.onFocusChannel(it) }, @@ -144,6 +193,7 @@ fun TvGuideGrid( showItemDialog = index } }, + buttonFocusRequester = buttonFocusRequester, modifier = Modifier .fillMaxSize() @@ -192,10 +242,33 @@ fun TvGuideGrid( } } } + if (showViewOptions) { + LiveTvViewOptionsDialog( + preferences = preferences, + onDismissRequest = { showViewOptions = false }, + onViewOptionsChange = { newPrefs -> + scope.launchIO { + viewModel.preferences.updateData { + newPrefs + } + } + }, + ) + } } +val tvGuideDimensions = + ProgramGuideDimensions( + timelineHourWidth = 240.dp, + timelineHeight = 32.dp, + channelWidth = 120.dp, + channelHeight = 64.dp, + currentTimeWidth = 2.dp, + ) + @Composable fun TvGuideGridContent( + preferences: LiveTvPreferences, loading: Boolean, channels: List, programs: FetchedPrograms, @@ -204,6 +277,7 @@ fun TvGuideGridContent( onClickChannel: (Int, TvChannel) -> Unit, onClickProgram: (Int, TvProgram) -> Unit, onFocus: (RowColumn) -> Unit, + buttonFocusRequester: FocusRequester, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -229,25 +303,19 @@ fun TvGuideGridContent( state.animateToProgram(focusedProgramIndex, Alignment.Center) } } - val dimensions = - ProgramGuideDimensions( - timelineHourWidth = 240.dp, - timelineHeight = 32.dp, - channelWidth = 120.dp, - channelHeight = 64.dp, - currentTimeWidth = 2.dp, - ) var gridHasFocus by rememberSaveable { mutableStateOf(false) } var channelColumnFocused by rememberSaveable { mutableStateOf(false) } Box(modifier = modifier) { ProgramGuide( state = state, - dimensions = dimensions, + dimensions = tvGuideDimensions, modifier = Modifier .fillMaxSize() .onFocusChanged { gridHasFocus = it.hasFocus + }.focusProperties { + up = buttonFocusRequester }.focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyUp) { @@ -291,7 +359,7 @@ fun TvGuideGridContent( Key.DirectionUp -> { if (item.row <= 0) { - focusManager.moveFocus(FocusDirection.Up) +// focusManager.moveFocus(FocusDirection.Up) null } else { val newChannelIndex = item.row - 1 @@ -516,7 +584,7 @@ fun TvGuideGridContent( val program = programs.programs[programIndex] val focused = gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex - Program(program, focused, Modifier) + Program(program, focused, preferences.colorCodePrograms, Modifier) } channels( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt new file mode 100644 index 00000000..73b9b3c2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.ui.detail.livetv + +import android.text.format.DateUtils +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.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.StreamLabel +import com.github.damontecres.wholphin.ui.roundMinutes +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.time.toKotlinDuration + +@Composable +fun TvGuideHeader( + program: TvProgram?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val now = LocalDateTime.now() + val details = + remember(program) { + buildList { + program?.let { + val differentDay = it.start.toLocalDate() != now.toLocalDate() + val time = + DateUtils.formatDateRange( + context, + it.start + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + it.end + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, + ) + add(time) + } + if (program?.isFake == false) { + program + .duration + .roundMinutes + .toString() + .let(::add) + if (now.isAfter(program.start) && now.isBefore(program.end)) { + java.time.Duration + .between(now, program.end) + .toKotlinDuration() + .roundMinutes + .let { add("$it left") } + } + program.seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let(::add) + program.officialRating?.let(::add) + } + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start, + modifier = modifier, + ) { + Text( + text = program?.name ?: program?.id.toString(), + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(.6f), + ) { + program?.subtitle?.let { + Text( + text = program.subtitle, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DotSeparatedRow( + texts = details, + communityRating = null, + criticRating = null, + textStyle = MaterialTheme.typography.titleSmall, + modifier = Modifier, + ) + if (program?.isRepeat == true) { + StreamLabel(stringResource(R.string.live_tv_repeat)) + } + } + OverviewText( + overview = program?.overview ?: "", + maxLines = 3, + onClick = {}, + enabled = false, + ) + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index fe601b0b..a003bbcc 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -125,6 +125,13 @@ message SubtitlePreferences{ int32 edge_thickness = 12; } +message LiveTvPreferences { + bool show_header = 1; + bool favorite_channels_at_beginning = 2; + bool sort_by_recently_watched = 3; + bool color_code_programs = 4; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -133,6 +140,7 @@ message InterfacePreferences { bool nav_drawer_switch_on_focus = 5; bool show_clock = 6; SubtitlePreferences subtitles_preferences = 7; + LiveTvPreferences live_tv_preferences = 8; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d28905b..84ca35c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -389,6 +389,10 @@ bit Hz Show titles + Repeat + Show favorite channels first + Sort channels by recently watched + Color-code programs Disabled From d2e1c4683d56599f25a14e781fbe87ca63693bca Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 13 Dec 2025 16:43:49 -0500 Subject: [PATCH 056/124] Only use server provided aspect ratios if >0 (#453) Fixes #451 --- .../com/github/damontecres/wholphin/data/model/BaseItem.kt | 3 +++ .../com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt | 2 +- .../java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt | 3 +-- .../com/github/damontecres/wholphin/ui/cards/SeasonCard.kt | 2 +- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 4 +--- .../github/damontecres/wholphin/ui/playback/PlaybackPage.kt | 4 +--- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 49b85ef5..7abdc890 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -47,6 +47,9 @@ data class BaseItem( } } + @Transient + val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } + @Transient val indexNumber = data.indexNumber ?: dateAsIndex() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index a7df3434..d6c88633 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -64,7 +64,7 @@ fun EpisodeCard( } else { focusedAfterDelay = false } - val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL + val aspectRatio = item?.aspectRatio ?: AspectRatios.TALL val width = imageHeight * aspectRatio val height = imageWidth * (1f / aspectRatio) Column( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 26bc9ce5..618b9003 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -113,8 +113,7 @@ fun BannerItemRow( name = title, item = item, aspectRatio = - aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() - ?: AspectRatios.WIDE, + aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE, cornerText = item?.data?.indexNumber?.let { "E$it" }, played = item?.data?.userData?.played ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 66f93212..54041fff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -46,7 +46,7 @@ fun SeasonCard( imageWidth: Dp = Dp.Unspecified, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, showImageOverlay: Boolean = false, - aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL, + aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 66b6b414..3d2aa4c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -218,9 +218,7 @@ fun SeriesOverviewContent( item = episode, aspectRatio = episode - ?.data - ?.primaryImageAspectRatio - ?.toFloat() + ?.aspectRatio ?.coerceAtLeast(AspectRatios.FOUR_THREE) ?: (AspectRatios.WIDE), cornerText = cornerText, 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 4898103b..d414a2be 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 @@ -473,9 +473,7 @@ fun PlaybackPage( ).joinToString(" - "), description = it.data.overview, imageUrl = LocalImageUrlService.current.rememberImageUrl(it), - aspectRatio = - it.data.primaryImageAspectRatio?.toFloat() - ?: AspectRatios.WIDE, + aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, onClick = { viewModel.reportInteraction() viewModel.playNextUp() From a386028b6b74999a8f05030c8d3b8020b817a5ba Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 13 Dec 2025 17:58:30 -0500 Subject: [PATCH 057/124] Fix possible NPE during refresh rate switch (#448) Use current thread's looper or fallback to the main looper for the display change callback. This is the behavior in newer versions of Android whereas older versions assumed a non-null looper. Fixes #446 --- .../wholphin/services/RefreshRateService.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index 190bf21b..98e2d60a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.services import android.content.Context import android.hardware.display.DisplayManager import android.os.Build +import android.os.Handler +import android.os.Looper import android.view.Display import androidx.lifecycle.LiveData import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay @@ -57,12 +60,18 @@ class RefreshRateService Timber.i("Found display mode: %s, current=${display.mode}", targetMode) if (targetMode != null && targetMode != display.mode) { val listener = Listener(display.displayId) - displayManager.registerDisplayListener(listener, null) + displayManager.registerDisplayListener( + listener, + Handler(Looper.myLooper() ?: Looper.getMainLooper()), + ) _refreshRateMode.setValueOnMain(targetMode) try { - listener.latch.await(5, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - Timber.w("Timed out waiting for display change") + if (!listener.latch.await(5, TimeUnit.SECONDS)) { + Timber.w("Timed out waiting for display change") + showToast(context, "Refresh rate switch is taking a long time") + } + } catch (ex: InterruptedException) { + Timber.w(ex, "Exception waiting for refresh rate switch") } val targetRate = (targetMode.refreshRate * 100).roundToInt() val isSeamless = From 43703436c5bcf0bac63cad6ed3f2db42e72d8364 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:03:41 -0500 Subject: [PATCH 058/124] Switch back to older text input (#459) Revert some of the changes to input text fields from #327. This PR switches back to using the `value/onValueChange` variants for single line input. Fixes #413 --- .../wholphin/ui/components/EditTextBox.kt | 168 ++++++++++++++++++ .../wholphin/ui/detail/PlaylistList.kt | 27 ++- .../wholphin/ui/main/SearchPage.kt | 11 +- .../ui/playback/DownloadSubtitlesDialog.kt | 24 ++- .../ui/preferences/StringInputDialog.kt | 47 +++-- .../wholphin/ui/setup/SwitchServerContent.kt | 19 +- .../wholphin/ui/setup/SwitchUserContent.kt | 40 +++-- 7 files changed, 284 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt index 67faa648..a85d9fc9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt @@ -5,33 +5,43 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicSecureTextField import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.TextFieldDecorator import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TextFieldDefaults.Container import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor @@ -39,6 +49,7 @@ import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.google.protobuf.value @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -191,6 +202,159 @@ fun SearchEditTextBox( ) } +/** + * A modified [BasicTextField] that looks & fits better with TV material controls + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditTextBox( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + leadingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + height: Dp = 40.dp, + isInputValid: (String) -> Boolean = { true }, + supportingText: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val colors = + TextFieldDefaults.colors( + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), + disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.75f), + errorTextColor = MaterialTheme.colorScheme.onErrorContainer, + ) + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + BasicTextField( + value = value, + modifier = + modifier + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = height, + ).height(height), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = MaterialTheme.typography.bodyLarge.merge(MaterialTheme.colorScheme.onPrimaryContainer), + cursorBrush = SolidColor(colors.cursorColor), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = true, + maxLines = 1, + minLines = 1, + visualTransformation = + if (keyboardOptions.keyboardType == KeyboardType.Password || + keyboardOptions.keyboardType == KeyboardType.NumberPassword + ) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + decorationBox = + @Composable { innerTextField -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.DecorationBox( + value = value, + visualTransformation = + if (keyboardOptions.keyboardType == KeyboardType.Password || + keyboardOptions.keyboardType == KeyboardType.NumberPassword + ) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + innerTextField = innerTextField, + placeholder = placeholder, + label = null, + leadingIcon = leadingIcon, + trailingIcon = null, + prefix = null, + suffix = null, + supportingText = supportingText, + shape = CircleShape, + singleLine = true, + enabled = enabled, + isError = false, + interactionSource = interactionSource, + colors = colors, + contentPadding = + PaddingValues( + horizontal = 8.dp, + vertical = 10.dp, + ), + container = { + Container( + enabled = enabled, + isError = !isInputValid.invoke(value), + interactionSource = interactionSource, + modifier = Modifier, + colors = colors, + shape = CircleShape, + focusedIndicatorLineThickness = 4.dp, + unfocusedIndicatorLineThickness = 0.dp, + ) + }, + ) + }, + ) + } +} + +/** + * And [EditTextBox] styles for searches + */ +@Composable +fun SearchEditTextBox( + value: String, + onValueChange: (String) -> Unit, + onSearchClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + height: Dp = 40.dp, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + EditTextBox( + value, + onValueChange, + modifier, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Search, + ), + keyboardActions = + KeyboardActions( + onSearch = { + onSearchClick.invoke() + this.defaultKeyboardAction(ImeAction.Search) + }, + ), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + enabled = enabled, + readOnly = readOnly, + height = height, + interactionSource = interactionSource, + ) +} + @PreviewTvSpec @Composable private fun EditTextBoxPreview() { @@ -199,6 +363,10 @@ private fun EditTextBoxPreview() { EditTextBox( state = rememberTextFieldState("string"), ) + EditTextBox( + value = "string", + onValueChange = {}, + ) SearchEditTextBox( state = rememberTextFieldState("search query"), onSearchClick = { }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt index 65b3e43f..f94f7381 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt @@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.CircularProgressIndicator @@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -133,7 +134,7 @@ fun PlaylistList( properties = DialogProperties(usePlatformDefaultWidth = false), elevation = 10.dp, ) { - val playlistName = rememberTextFieldState() + var playlistName by rememberSaveable { mutableStateOf("") } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( @@ -148,7 +149,8 @@ fun PlaylistList( text = stringResource(R.string.name), ) EditTextBox( - state = playlistName, + value = playlistName, + onValueChange = { playlistName = it }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Words, @@ -156,10 +158,17 @@ fun PlaylistList( keyboardType = KeyboardType.Text, imeAction = ImeAction.Done, ), - onKeyboardAction = { - showCreateDialog = false - onCreatePlaylist.invoke(playlistName.text.toString()) - }, + keyboardActions = + KeyboardActions( + onDone = { + showCreateDialog = false + onCreatePlaylist.invoke(playlistName) + }, + ), + // onKeyboardAction = { +// showCreateDialog = false +// onCreatePlaylist.invoke(playlistName.text.toString()) +// }, modifier = Modifier .focusRequester(focusRequester) @@ -168,9 +177,9 @@ fun PlaylistList( Button( onClick = { showCreateDialog = false - onCreatePlaylist.invoke(playlistName.text.toString()) + onCreatePlaylist.invoke(playlistName) }, - enabled = playlistName.text.isNotNullOrBlank(), + enabled = playlistName.isNotNullOrBlank(), modifier = Modifier, ) { Text(text = stringResource(R.string.submit)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 1a39c51d..19b04d0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -172,7 +171,8 @@ fun SearchPage( val series by viewModel.series.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) - val query = rememberTextFieldState() +// val query = rememberTextFieldState() + var query by rememberSaveable { mutableStateOf("") } val focusRequester = remember { FocusRequester() } var position by rememberPosition() @@ -180,7 +180,7 @@ fun SearchPage( LaunchedEffect(query) { delay(750L) - viewModel.search(query.text.toString()) + viewModel.search(query) } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() @@ -214,9 +214,10 @@ fun SearchPage( focusManager.moveFocus(FocusDirection.Next) } SearchEditTextBox( - state = query, + value = query, + onValueChange = { query = it }, onSearchClick = { - viewModel.search(query.text.toString()) + viewModel.search(query) searchClicked = true }, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index efb0c1fe..e145d380 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -11,14 +11,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -102,7 +106,8 @@ fun DownloadSubtitlesContent( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) - val lang = rememberTextFieldState(language) +// val lang = rememberTextFieldState(language) + var lang by rememberSaveable { mutableStateOf("") } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -113,10 +118,17 @@ fun DownloadSubtitlesContent( color = MaterialTheme.colorScheme.onSurface, ) EditTextBox( - state = lang, - onKeyboardAction = { - onSearch(lang.text.toString()) - }, + value = lang, + onValueChange = { lang = it }, + keyboardActions = + KeyboardActions( + onSearch = { + onSearch(lang) + }, + ), + // onKeyboardAction = { +// onSearch(lang.text.toString()) +// }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Search, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt index 4229f78d..0e40ad24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -38,9 +39,14 @@ fun StringInputDialog( elevation: Dp = 3.dp, ) { val mutableValue = rememberTextFieldState(input.value ?: "") + var mutableText by remember { mutableStateOf(input.value ?: "") } // var mutableValue by remember { mutableStateOf(input.value ?: "") } val onDone = { - onSave.invoke(mutableValue.text.toString()) + if (input.maxLines > 1) { + onSave.invoke(mutableValue.text.toString()) + } else { + onSave.invoke(mutableText) + } } var showConfirm by remember { mutableStateOf(false) } Dialog( @@ -79,17 +85,34 @@ fun StringInputDialog( color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier, ) - EditTextBox( - state = mutableValue, - keyboardOptions = input.keyboardOptions, - onKeyboardAction = { - onDone.invoke() - }, - leadingIcon = null, - isInputValid = { true }, - maxLines = input.maxLines, - modifier = Modifier.fillMaxWidth(), - ) + if (input.maxLines > 1) { + EditTextBox( + state = mutableValue, + keyboardOptions = input.keyboardOptions, + onKeyboardAction = { + onDone.invoke() + }, + leadingIcon = null, + isInputValid = { true }, + maxLines = input.maxLines, + modifier = Modifier.fillMaxWidth(), + ) + } else { + EditTextBox( + value = mutableText, + onValueChange = { mutableText = it }, + keyboardOptions = input.keyboardOptions, + keyboardActions = + KeyboardActions( + onDone = { + onDone.invoke() + }, + ), + leadingIcon = null, + isInputValid = { true }, + modifier = Modifier.fillMaxWidth(), + ) + } Row( horizontalArrangement = Arrangement.SpaceAround, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index c1bc542e..6fafeecc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -172,9 +172,11 @@ fun SwitchServerContent( viewModel.clearAddServerState() } val state by viewModel.addServerState.observeAsState(LoadingState.Pending) - val url = rememberTextFieldState() +// val url = rememberTextFieldState() + var url by remember { mutableStateOf("") } val submit = { - viewModel.addServer(url.text.toString()) +// viewModel.addServer(url.text.toString()) + viewModel.addServer(url) } BasicDialog( onDismissRequest = { @@ -198,7 +200,8 @@ fun SwitchServerContent( text = stringResource(R.string.enter_server_url), ) EditTextBox( - state = url, + value = url, + onValueChange = { url = it }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -206,7 +209,11 @@ fun SwitchServerContent( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, ), - onKeyboardAction = { submit.invoke() }, + keyboardActions = + KeyboardActions( + onGo = { submit.invoke() }, + ), + // onKeyboardAction = { submit.invoke() }, modifier = Modifier .focusRequester(focusRequester) @@ -226,7 +233,7 @@ fun SwitchServerContent( } TextButton( onClick = { submit.invoke() }, - enabled = url.text.isNotNullOrBlank() && state == LoadingState.Pending, + enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, modifier = Modifier, ) { if (state == LoadingState.Loading) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 2134e481..c734aed2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -12,8 +12,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -215,13 +215,15 @@ fun SwitchUserContent( modifier = Modifier.align(Alignment.CenterHorizontally), ) } else { - val username = rememberTextFieldState() - val password = rememberTextFieldState() +// val username = rememberTextFieldState() +// val password = rememberTextFieldState() + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } val onSubmit = { viewModel.login( server, - username.text.toString(), - password.text.toString(), + username, + password, ) } val focusRequester = remember { FocusRequester() } @@ -242,7 +244,8 @@ fun SwitchUserContent( modifier = Modifier.padding(end = 8.dp), ) EditTextBox( - state = username, + value = username, + onValueChange = { username = it }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -250,9 +253,15 @@ fun SwitchUserContent( keyboardType = KeyboardType.Text, imeAction = ImeAction.Next, ), - onKeyboardAction = { - passwordFocusRequester.tryRequestFocus() - }, + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + // onKeyboardAction = { +// passwordFocusRequester.tryRequestFocus() +// }, isInputValid = { userState !is LoadingState.Error }, modifier = Modifier.focusRequester(focusRequester), ) @@ -265,12 +274,12 @@ fun SwitchUserContent( text = "Password", modifier = Modifier.padding(end = 8.dp), ) - LaunchedEffect(password.text) { + LaunchedEffect(password) { viewModel.clearSwitchUserState() } EditTextBox( - isPassword = true, - state = password, + value = password, + onValueChange = { password = it }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -278,7 +287,10 @@ fun SwitchUserContent( keyboardType = KeyboardType.Password, imeAction = ImeAction.Go, ), - onKeyboardAction = { onSubmit.invoke() }, + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke() }, + ), isInputValid = { userState !is LoadingState.Error }, modifier = Modifier.focusRequester(passwordFocusRequester), ) @@ -286,7 +298,7 @@ fun SwitchUserContent( TextButton( stringRes = R.string.login, onClick = { onSubmit.invoke() }, - enabled = username.text.isNotNullOrBlank(), + enabled = username.isNotNullOrBlank(), modifier = Modifier.align(Alignment.CenterHorizontally), ) } From ff43f8b1b52f15aa22f8b100f9053f52be3cbb22 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 14 Dec 2025 10:07:46 -0500 Subject: [PATCH 059/124] Use sdk provided device name (#457) Use the SDK to get the device name since it handles older Android versions and has a fallback. This should not change the device ID or name if the device was working before this change. Maybe related to #454 --- .../wholphin/services/RefreshRateService.kt | 2 ++ .../wholphin/services/hilt/AppModule.kt | 12 ++----- .../wholphin/ui/detail/DebugPage.kt | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index 98e2d60a..e34d2fa1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -32,6 +32,8 @@ class RefreshRateService private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) private val originalMode = display.mode + val displayModes get() = display.supportedModes + private val _refreshRateMode = EqualityMutableLiveData(originalMode) val refreshRateMode: LiveData = _refreshRateMode diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 580a53ae..ad778590 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -1,8 +1,6 @@ package com.github.damontecres.wholphin.services.hilt -import android.annotation.SuppressLint import android.content.Context -import android.provider.Settings import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R @@ -23,6 +21,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.android.androidDevice import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -60,14 +59,7 @@ object AppModule { @Singleton fun deviceInfo( @ApplicationContext context: Context, - ): DeviceInfo = - DeviceInfo( - id = @SuppressLint("HardwareIds") Settings.Secure.getString( - context.contentResolver, - Settings.Secure.ANDROID_ID, - ), - name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME), - ) + ): DeviceInfo = androidDevice(context) @StandardOkHttpClient @Provides diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 5f771ab1..b65f8624 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,6 +59,9 @@ class DebugViewModel constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + val refreshRateService: RefreshRateService, + val clientInfo: ClientInfo, + val deviceInfo: DeviceInfo, ) : ViewModel() { val itemPlaybacks = MutableLiveData>(listOf()) val logcat = MutableLiveData>(listOf()) @@ -220,6 +224,7 @@ fun DebugPage( listOf( "Version Name: ${pkgInfo.versionName}", "Version Code: ${pkgInfo.versionCodeLong}", + "ClientInfo: ${viewModel.clientInfo}", "Build type: ${BuildConfig.BUILD_TYPE}", "Debug enabled: ${BuildConfig.DEBUG}", "ABIs: ${Build.SUPPORTED_ABIS.toList()}", @@ -233,6 +238,32 @@ fun DebugPage( } } } + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Device Information", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + listOf( + "DeviceInfo: ${viewModel.deviceInfo}", + "Manufacturer: ${Build.MANUFACTURER}", + "Model: ${Build.MODEL}", + "Display Modes:", + *viewModel.refreshRateService.displayModes, + ).forEach { + Text( + text = it.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), From 7ab39eba579ba1c89e3c4e4b026b440781286fbc Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:04:01 -0500 Subject: [PATCH 060/124] Improve trickplay image preview speed (#467) Moves the clipping of trickplay images from the image loading layer to the composition/graphics layer. Effectively, this PR loads and renders the entire trickplay image which is then moved around to the right position and finally is clipped so only a single tile is shown. Since moving the image around on screen plsu clipping is 1) very fast and 2) done during recompositions, this means it is _much_ faster at rendering each trickplay tile image and Coil only has to load the image once. Fixes #432 --- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 2 + .../wholphin/ui/playback/PlaybackOverlay.kt | 8 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 12 +- .../wholphin/ui/playback/SeekPreviewImage.kt | 105 +++++++++++------- 4 files changed, 77 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 32dc213d..4947fda6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -53,6 +53,7 @@ fun ItemDetailsDialog( val audioLabel = stringResource(R.string.audio) val subtitleLabel = stringResource(R.string.subtitle) val bitrateLabel = stringResource(R.string.bitrate) + val unknown = stringResource(R.string.unknown) ScrollableDialog( onDismissRequest = onDismissRequest, @@ -100,6 +101,7 @@ fun ItemDetailsDialog( source.container?.let { add(containerLabel to it) } if (showFilePath) { source.path?.let { add(pathLabel to it) } + add("ID" to (source.id ?: unknown)) } source.size?.let { add(fileSizeLabel to formatBytes(it)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index eefc176b..2200b954 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -398,17 +398,13 @@ fun PlaybackOverlay( val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight val index = (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage - val imageUrl = trickplayUrlFor(index) + val imageUrl = remember(index) { trickplayUrlFor(index) } if (imageUrl != null) { SeekPreviewImage( - modifier = - Modifier, + modifier = Modifier, previewImageUrl = imageUrl, - duration = playerControls.duration, seekProgressMs = seekProgressMs, - videoWidth = trickplayInfo.width, - videoHeight = trickplayInfo.height, trickPlayInfo = trickplayInfo, ) } 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 524bcf03..88536176 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 @@ -411,7 +411,11 @@ class PlaybackViewModel trickPlayInfo?.let { trickplayInfo -> mediaSource.runTimeTicks?.ticks?.let { duration -> viewModelScope.launchIO { - prefetchTrickplay(duration, trickplayInfo) + prefetchTrickplay( + duration, + trickplayInfo, + mediaSource.id?.toUUIDOrNull(), + ) } } } @@ -737,16 +741,18 @@ class PlaybackViewModel private suspend fun prefetchTrickplay( duration: Duration, trickplayInfo: TrickplayInfo, + mediaSourceId: UUID?, ) { val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight val totalCount = (duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1 (0.. Date: Sun, 14 Dec 2025 19:21:33 -0500 Subject: [PATCH 061/124] Release v0.3.6 From ab8bbf2bd7398dc5cb8457d575d2ed04d32cd047 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:06:36 -0500 Subject: [PATCH 062/124] 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) --- .../12.json | 434 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 5 +- .../wholphin/data/ItemPlaybackDao.kt | 17 + .../wholphin/data/ItemPlaybackRepository.kt | 28 ++ .../data/model/ItemTrackModification.kt | 30 ++ .../wholphin/ui/playback/CurrentPlayback.kt | 2 + .../wholphin/ui/playback/PlaybackDialog.kt | 57 ++- .../wholphin/ui/playback/PlaybackPage.kt | 18 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 46 +- .../wholphin/ui/playback/SubtitleDelay.kt | 109 +++++ .../wholphin/util/mpv/MpvPlayer.kt | 13 +- app/src/main/res/values/strings.xml | 1 + 12 files changed, 751 insertions(+), 9 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt 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 From ebfc00c016033ae66ce11052f1e41f0c97fc0c94 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:15:17 -0500 Subject: [PATCH 063/124] Always save series audio & subtitle preferences regardless of preferred languages (#473) ## Description This PR adjust the logic for audio & subtitle track selection for TV series. Instead of only saving for future episodes if the newly selected track is a different language than the user's preferred, always save it. Basically this means if you change the audio or subtitle language for an episode, the same audio or subtitle language will be used for future episodes (if available). Also, fixes the "default" subtitle mode to take the user's preferred language into consideration. ## Issues Follow up to https://github.com/damontecres/Wholphin/issues/378 & https://github.com/damontecres/Wholphin/issues/427 Fixes #469 --- .../wholphin/data/ItemPlaybackRepository.kt | 7 +-- .../wholphin/services/StreamChoiceService.kt | 57 +++++++++---------- 2 files changed, 29 insertions(+), 35 deletions(-) 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 62e922d4..00bc0fb7 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 @@ -134,9 +134,8 @@ class ItemPlaybackRepository val seriesId = item.data.seriesId if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) { if (type == MediaStreamType.AUDIO) { - val audioLang = current.userDto.configuration?.audioLanguagePreference val stream = source.mediaStreams?.first { it.index == trackIndex } - if (stream?.language != null && stream.language != audioLang) { + if (stream?.language != null) { streamChoiceService.updateAudio(item.data, stream.language!!) } } else if (type == MediaStreamType.SUBTITLE) { @@ -147,10 +146,8 @@ class ItemPlaybackRepository subtitlesDisabled = true, ) } else { - val subtitleLang = - current.userDto.configuration?.subtitleLanguagePreference val stream = source.mediaStreams?.first { it.index == trackIndex } - if (stream?.language != null && stream.language != subtitleLang) { + if (stream?.language != null) { streamChoiceService.updateSubtitles( item.data, stream.language!!, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 1af9d11e..e1490ac0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -114,16 +114,11 @@ class StreamChoiceService if (itemPlayback?.audioIndexEnabled == true) { candidates.firstOrNull { it.index == itemPlayback.audioIndex } } else { - // TODO audio selection based on channel layout or preferences or default - val audioLanguage = - if (prefs.userConfig.audioLanguagePreference.isNotNullOrBlank()) { - // If the user has chosen a preferred language, but changed tracks on the series, use that - // Otherwise, use their preferred language - playbackLanguageChoice?.audioLanguage - ?: prefs.userConfig.audioLanguagePreference - } else { - null - } + val seriesLang = + playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } + // If the user has chosen a different language for the series, prefer that + val audioLanguage = seriesLang ?: prefs.userConfig.audioLanguagePreference + if (audioLanguage.isNotNullOrBlank()) { val sorted = candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) @@ -176,27 +171,21 @@ class StreamChoiceService } else if (itemPlayback?.subtitleIndexEnabled == true) { return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } } else { + val seriesLang = + playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } val subtitleLanguage = - if (prefs.userConfig.subtitleLanguagePreference.isNotNullOrBlank()) { - // If the user has chosen a preferred language, but changed tracks on the series, use that - // Otherwise, use their preferred language - playbackLanguageChoice?.subtitleLanguage - ?: prefs.userConfig.subtitleLanguagePreference - } else { - null - } + (seriesLang ?: prefs.userConfig.subtitleLanguagePreference) + ?.takeIf { it.isNotNullOrBlank() } val subtitleMode = when { - playbackLanguageChoice?.subtitlesDisabled == false && - playbackLanguageChoice.subtitleLanguage != null && - subtitleLanguage.isNotNullOrBlank() -> { - // User has a subtitle language preference, but has chosen a different language for the series - // So override their normal playback mode to always display subtitles + playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { + // User has chosen a series level subtitle language, so override their normal + // subtitle mode to display that language SubtitlePlaybackMode.ALWAYS } - playbackLanguageChoice?.subtitlesDisabled == true -> { + playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> { // Series level settings disables subtitles SubtitlePlaybackMode.NONE } @@ -234,12 +223,20 @@ class StreamChoiceService } SubtitlePlaybackMode.DEFAULT -> { - // TODO check for language? - ( - candidates.firstOrNull { it.isDefault && it.isForced } - ?: candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull { it.isForced } - ) + subtitleLanguage?.let { lang -> + // Find best track that is in the preferred language + ( + candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang } + ?: candidates.firstOrNull { it.isDefault && it.language == lang } + ?: candidates.firstOrNull { it.isForced && it.language == lang } + ) + } + ?: ( + // If none in preferred language, just find the best track + candidates.firstOrNull { it.isDefault && it.isForced } + ?: candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull { it.isForced } + ) } SubtitlePlaybackMode.NONE -> { From d5a55ce4adad3eecf3700af400cae9266f62c456 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:53:06 -0500 Subject: [PATCH 064/124] Fix trickplay image scaling (#477) Fix to #467 to scale the tiles by the actual calculated ratio based on the server's fixed width and our target width instead of using the general density scaling I verified this on 1080p & 4k with 4:3, 16:9, & 2.35:1 videos. Fixes https://github.com/damontecres/Wholphin/issues/432#issuecomment-3657381461 --- .../github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt index 99c3e9af..08fb7685 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt @@ -84,7 +84,7 @@ fun SeekPreviewImage( if (previewImageUrl.isNotNullOrBlank()) { val height = 160.dp val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height) - val scale = LocalDensity.current.density + val scale = with(LocalDensity.current) { width.toPx() / trickPlayInfo.width } val model = remember(previewImageUrl) { From f77d345abfad0fd98d08b54d319290fbb382a205 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 16 Dec 2025 13:52:44 -0500 Subject: [PATCH 065/124] Allow null ItemTrackModification from DB --- .../com/github/damontecres/wholphin/data/ItemPlaybackDao.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3b660c9a..dedbb8a7 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 @@ -39,7 +39,7 @@ interface ItemPlaybackDao { userId: Int, itemId: UUID, trackIndex: Int, - ): ItemTrackModification + ): ItemTrackModification? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveItem(item: ItemTrackModification): Long From 5b21e352bc4546a0cf38491691091f4e95ae2e35 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 16 Dec 2025 19:35:14 -0500 Subject: [PATCH 066/124] Update development.md with info on setting up the dev environment --- DEVELOPMENT.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1a723a9e..6856ebb2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,9 +23,17 @@ We follow GitHub's fork & pull request model for contributions. After forking and cloning your fork, you can import the project into Android Studio. -You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml). +### Development environment -### Code organization +It is recommended to use a recent version of [Android Studio](https://developer.android.com/studio). Make sure the [version is compatible](https://developer.android.com/build/releases/gradle-plugin#android_gradle_plugin_and_android_studio_compatibility) with Wholphin's AGP version. + +Code formatting should follow [ktlint's](https://github.com/pinterest/ktlint) rules. Find the `ktlint` version in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml). Optionally, install the [ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) in Android Studio to run automatically. Configure the version in `Settings->Tools->KtLint->Ruleset Version`. + +Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: https://mrmans0n.github.io/compose-rules/ktlint/#using-with-ktlint-cli-or-the-ktlint-intellij-plugin + +Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues. + +## Code organization Code is split into several packages: - `data` - app-specific data models and services From 8224f836788ddde80e6f61bee9beb9ecac89d04c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 16 Dec 2025 20:01:56 -0500 Subject: [PATCH 067/124] Add pull request template --- .github/pull_request_template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..eae1ff9c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## Description + + +### Related issues + + + +### Screenshots + + +### AI/LLM usage + From bbfbc13532a1c95fe6494d89e2a4a363f0880cfa Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:18:57 -0500 Subject: [PATCH 068/124] Backdrop improvements (#476) ## Description The backdrop image shown on most pages is now configurable in advanced settings: * Image w/ dynamic color - Shows the backdrop with dynamic color filling the entire space (new default) * Image only - Shows only the backdrop image (current Wholphin behavior) * None - Don't show any backdrops Additionally, the backdrop is retained between page loads for the same items. ## Acknowledgements Big thanks to @YogiBear12 from which the idea and implementation for the dynamic color was adapted from! Code adapted from https://github.com/YogiBear12/Halfin/blob/main/COLOR_EXTRACTION_AND_BACKDROP.md#backdrop-fading-and-transitions ## Issues Closes #221 Closes #461 Closes #442 https://github.com/user-attachments/assets/1acbc487-c697-44d7-89ed-e7e4c9169360 --- app/build.gradle.kts | 1 + .../wholphin/preferences/AppPreference.kt | 14 + .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/BackdropService.kt | 239 ++++++++++++++++++ .../ui/components/CollectionFolderGrid.kt | 22 +- .../ui/components/DetailsBackdropImage.kt | 154 ----------- .../ui/components/RecommendedContent.kt | 9 + .../ui/components/RecommendedMovie.kt | 4 +- .../ui/components/RecommendedTvShow.kt | 4 +- .../wholphin/ui/detail/PlaylistDetails.kt | 16 +- .../ui/detail/episode/EpisodeDetails.kt | 2 - .../ui/detail/episode/EpisodeViewModel.kt | 3 + .../wholphin/ui/detail/movie/MovieDetails.kt | 2 - .../ui/detail/movie/MovieViewModel.kt | 3 + .../ui/detail/series/SeriesDetails.kt | 3 - .../ui/detail/series/SeriesOverviewContent.kt | 2 - .../ui/detail/series/SeriesViewModel.kt | 5 + .../damontecres/wholphin/ui/main/HomePage.kt | 11 +- .../wholphin/ui/main/HomeViewModel.kt | 9 + .../wholphin/ui/nav/ApplicationContent.kt | 225 ++++++++++++++--- .../wholphin/ui/nav/DestinationContent.kt | 16 ++ .../damontecres/wholphin/ui/nav/NavDrawer.kt | 10 +- .../damontecres/wholphin/ui/theme/Theme.kt | 22 +- app/src/main/proto/WholphinDataStore.proto | 7 + app/src/main/res/values/strings.xml | 7 + gradle/libs.versions.toml | 2 + 26 files changed, 569 insertions(+), 224 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d80026c..55ba0024 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -223,6 +223,7 @@ dependencies { implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.preference.ktx) implementation(libs.androidx.room.testing) + implementation(libs.androidx.palette.ktx) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 19810b5c..4d6154d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -659,6 +659,19 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val BackdropStylePref = + AppChoicePreference( + title = R.string.backdrop_display, + defaultValue = BackdropStyle.BACKDROP_DYNAMIC_COLOR, + getter = { it.interfacePreferences.backdropStyle }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { backdropStyle = value } + }, + displayValues = R.array.backdrop_style_options, + indexToValue = { BackdropStyle.forNumber(it) }, + valueToIndex = { it.number }, + ) + val OneClickPause = AppSwitchPreference( title = R.string.one_click_pause, @@ -909,6 +922,7 @@ val advancedPreferences = // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, + AppPreference.BackdropStylePref, ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 0529e644..c39b9012 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -91,6 +91,7 @@ class AppPreferencesSerializer navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue showClock = AppPreference.ShowClock.defaultValue + backdropStyle = AppPreference.BackdropStylePref.defaultValue subtitlesPreferences = SubtitlePreferences diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt new file mode 100644 index 00000000..cdbb7a2a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -0,0 +1,239 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.graphics.Bitmap +import android.util.LruCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.core.graphics.drawable.toBitmap +import androidx.datastore.core.DataStore +import androidx.palette.graphics.Palette +import coil3.asDrawable +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.request.bitmapConfig +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.BackdropStyle +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@OptIn(FlowPreview::class) +class BackdropService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val imageUrlService: ImageUrlService, + private val preferences: DataStore, + ) { + private val extractedColorCache = LruCache(50) + + private val _backdropFlow = MutableStateFlow(BackdropResult.NONE) + val backdropFlow = _backdropFlow + + suspend fun submit(item: BaseItem) = + withContext(Dispatchers.IO) { + val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { + _backdropFlow.update { + it.copy( + itemId = item.id, + imageUrl = null, + ) + } + extractColors(item) + } + } + + suspend fun clearBackdrop() { + _backdropFlow.update { + BackdropResult.NONE + } + } + + private suspend fun extractColors(item: BaseItem) { + delay(500) + val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + val backdropStyle = + preferences.data + .firstOrNull() + ?.interfacePreferences + ?.backdropStyle + val dynamicEnabled = + backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || + backdropStyle == BackdropStyle.UNRECOGNIZED + val (primaryColor, secondaryColor, tertiaryColor) = + if (dynamicEnabled) { + extractColorsFromBackdrop(imageUrl) + } else { + ExtractedColors.DEFAULT + } + _backdropFlow.update { + if (it.itemId == item.id) { + BackdropResult( + itemId = item.id, + imageUrl = imageUrl, + primaryColor = primaryColor, + secondaryColor = secondaryColor, + tertiaryColor = tertiaryColor, + ) + } else { + it + } + } + } + + private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = + withContext(Dispatchers.IO) { + if (imageUrl.isNullOrBlank()) { + return@withContext ExtractedColors.DEFAULT + } + extractedColorCache.get(imageUrl)?.let { + return@withContext it + } + + try { + val loader = context.imageLoader + val request = + ImageRequest + .Builder(context) + .data(imageUrl) + .allowHardware(false) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .build() + + val result = loader.execute(request) + if (result is SuccessResult) { + val drawable = result.image.asDrawable(context.resources) + val bitmap = drawable.toBitmap(config = Bitmap.Config.ARGB_8888) + extractColorsFromBitmap(bitmap).also { + extractedColorCache.put(imageUrl, it) + } + } else { + ExtractedColors.DEFAULT + } + } catch (e: Exception) { + Timber.e(e, "Error extracting colors from URL: $imageUrl") + ExtractedColors.DEFAULT + } + } + + /** + * Helper function to determine if a color is "cool" (blue/purple/green) vs "warm" (red/orange/yellow) + * + * Cool colors have more blue/green than red + */ + private val Palette.Swatch.coolColor: Boolean + get() { + val r = (rgb shr 16) and 0xFF + val g = (rgb shr 8) and 0xFF + val b = rgb and 0xFF + return b > r && (b + g) > (r * 1.5f) + } + + private fun toColor( + swatch: Palette.Swatch?, + alpha: Float, + ): Color = swatch?.rgb?.let(::Color)?.copy(alpha = alpha) ?: Color.Transparent + + /** + * Extracts colors from a bitmap using Android's Palette API. + * + * - Primary (Bottom-Right): darkVibrant -> darkMuted -> default + * - Secondary (Top-Left): Smart selection based on color temperature (prefers cool colors) + * - Tertiary (Top-Right): vibrant -> lightVibrant -> default + * + * @param bitmap The bitmap to extract colors from + * @return ExtractedColors containing primary, secondary, and tertiary colors + */ + private suspend fun extractColorsFromBitmap(bitmap: Bitmap): ExtractedColors = + try { + val palette = Palette.from(bitmap).generate() + + val vibrant = palette.vibrantSwatch + val darkVibrant = palette.darkVibrantSwatch + val lightVibrant = palette.lightVibrantSwatch + val muted = palette.mutedSwatch + val darkMuted = palette.darkMutedSwatch + val lightMuted = palette.lightMutedSwatch + val dominant = palette.dominantSwatch + + // Primary (Bottom-Right) + val primaryColor = toColor(darkVibrant ?: darkMuted, .4f) + + // Secondary (Top-Left): Smart selection based on color properties + // If Vibrant is cool (blue/purple), use it. If Vibrant is warm (yellow/orange) and Muted is cool, use Muted. + // This ensures we get cool tones (blue/purple) for top-left when available + val secondaryColor = + when { + vibrant != null && vibrant.coolColor -> vibrant + muted != null && muted.coolColor -> muted + vibrant != null -> vibrant + muted != null -> muted + else -> null + }.let { toColor(it, .4f) } + + // Tertiary (Top-Right under image) + val tertiaryColor = toColor(vibrant ?: lightVibrant, .35f) + + Timber.v( + "Colors extracted: primary=%s, secondary=%s, tertiary=%s", + primaryColor, + secondaryColor, + tertiaryColor, + ) + ExtractedColors(primaryColor, secondaryColor, tertiaryColor) + } catch (e: Exception) { + Timber.e(e, "Error extracting palette colors") + ExtractedColors.DEFAULT + } + + fun clearColorCache() { + extractedColorCache.evictAll() + } + } + +data class BackdropResult( + val itemId: UUID?, + val imageUrl: String?, + val primaryColor: Color = Color.Unspecified, + val secondaryColor: Color = Color.Unspecified, + val tertiaryColor: Color = Color.Unspecified, +) { + val hasColors: Boolean = + primaryColor.isSpecified || + secondaryColor.isSpecified || + tertiaryColor.isSpecified + + companion object { + val NONE = + BackdropResult( + null, + null, + ) + } +} + +data class ExtractedColors( + val primary: Color, + val secondary: Color, + val tertiary: Color, +) { + companion object { + val DEFAULT = ExtractedColors(Color.Unspecified, Color.Unspecified, Color.Unspecified) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 35b5d1c3..f22e87a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AspectRatios @@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.scale @@ -123,6 +125,7 @@ class CollectionFolderViewModel private val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, val navigationManager: NavigationManager, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Loading) @@ -454,6 +457,12 @@ class CollectionFolderViewModel favoriteWatchManager.setFavorite(itemId, favorite) (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } } /** @@ -578,6 +587,7 @@ fun CollectionFolderGrid( viewOptions = viewOptions, defaultViewOptions = defaultViewOptions, onSaveViewOptions = { viewModel.saveViewOptions(it) }, + onChangeBackdrop = viewModel::updateBackdrop, playEnabled = playEnabled, onClickPlay = { _, item -> viewModel.navigationManager.navigateTo(Destination.Playback(item)) @@ -687,6 +697,7 @@ fun CollectionFolderGridContent( viewOptions: ViewOptions, onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlay: (Int, BaseItem) -> Unit, + onChangeBackdrop: (BaseItem) -> Unit, modifier: Modifier = Modifier, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, @@ -707,14 +718,13 @@ fun CollectionFolderGridContent( var position by rememberInt(0) val focusedItem = pager.getOrNull(position) + if (viewOptions.showDetails) { + LaunchedEffect(focusedItem) { + focusedItem?.let(onChangeBackdrop) + } + } Box(modifier = modifier) { - if (viewOptions.showDetails) { - DelayedDetailsBackdropImage( - item = focusedItem, - modifier = Modifier, - ) - } Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt deleted file mode 100644 index eea7fa90..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.tv.material3.MaterialTheme -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.transitionFactory -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.CrossFadeFactory -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import kotlinx.coroutines.delay -import org.jellyfin.sdk.model.api.ImageType -import kotlin.time.Duration.Companion.milliseconds - -@Composable -fun BoxScope.DetailsBackdropImage( - item: BaseItem?, - modifier: Modifier = Modifier, -) { - val imageUrlService = LocalImageUrlService.current - val backdropImageUrl = - remember(item) { - if (item != null) { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - } else { - null - } - } - DetailsBackdropImage(backdropImageUrl, modifier) -} - -@Composable -fun BoxScope.DetailsBackdropImage( - backdropImageUrl: String?, - modifier: Modifier = Modifier, -) { - if (backdropImageUrl.isNotNullOrBlank()) { - val gradientColor = MaterialTheme.colorScheme.background - AsyncImage( - model = backdropImageUrl, - contentDescription = null, - contentScale = ContentScale.Fit, - alignment = Alignment.TopEnd, - modifier = - modifier - .align(Alignment.TopEnd) - .fillMaxHeight(.85f) - .alpha(.75f) - .drawWithContent { - drawContent() - drawRect( - Brush.verticalGradient( - colors = listOf(Color.Transparent, gradientColor), - startY = size.height * .5f, - ), - ) - drawRect( - Brush.horizontalGradient( - colors = listOf(Color.Transparent, gradientColor), - endX = 0f, - startX = size.width * .75f, - ), - ) - }, - ) - } -} - -@Composable -fun BoxScope.DelayedDetailsBackdropImage( - item: BaseItem?, - modifier: Modifier = Modifier, -) { - val imageUrlService = LocalImageUrlService.current - val backdropImageUrl = - remember(item) { - if (item != null) { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - } else { - null - } - } - DelayedDetailsBackdropImage(backdropImageUrl, modifier) -} - -/** - * Shows a backdrop image, but with a crossfade & delay - * - * Used for change backdrops when change items frequently - */ -@Composable -fun BoxScope.DelayedDetailsBackdropImage( - focusedBackdropImageUrl: String?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - var backdropImageUrl by remember { mutableStateOf(null) } - LaunchedEffect(focusedBackdropImageUrl) { - backdropImageUrl = null - delay(150) - backdropImageUrl = focusedBackdropImageUrl - } - val gradientColor = MaterialTheme.colorScheme.background - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(backdropImageUrl) - .transitionFactory(CrossFadeFactory(250.milliseconds)) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - alignment = Alignment.TopEnd, - modifier = - modifier - .fillMaxHeight(.7f) - .fillMaxWidth(.7f) - .alpha(.75f) - .align(Alignment.TopEnd) - .drawWithContent { - drawContent() - drawRect( - Brush.verticalGradient( - colors = listOf(Color.Transparent, gradientColor), - startY = size.height * .33f, - ), - ) - drawRect( - Brush.horizontalGradient( - colors = listOf(gradientColor, Color.Transparent), - startX = 0f, - endX = size.width * .5f, - ), - ) - }, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 30bb369c..869a454d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -45,6 +46,7 @@ abstract class RecommendedViewModel( val context: Context, val navigationManager: NavigationManager, val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, ) : ViewModel() { abstract fun init() @@ -86,6 +88,12 @@ abstract class RecommendedViewModel( } } + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + abstract fun update( @StringRes title: Int, row: HomeRowLoadingState, @@ -152,6 +160,7 @@ fun RecommendedContent( }, onFocusPosition = onFocusPosition, showClock = preferences.appPreferences.interfacePreferences.showClock, + onUpdateBackdrop = viewModel::updateBackdrop, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 82eafee9..1272faf5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields @@ -56,7 +57,8 @@ class RecommendedMovieViewModel @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, - ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { + backdropService: BackdropService, + ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedMovieViewModel diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 80e8aa72..cb179860 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields @@ -59,7 +60,8 @@ class RecommendedTvShowViewModel @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, - ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { + backdropService: BackdropService, + ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedTvShowViewModel diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 402ee077..d27edfff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -50,10 +50,10 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.cards.ItemCardImage -import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -64,6 +64,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -88,6 +89,7 @@ class PlaylistViewModel constructor( api: ApiClient, val navigationManager: NavigationManager, + private val backdropService: BackdropService, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Pending) val items = MutableLiveData>(listOf()) @@ -111,6 +113,12 @@ class PlaylistViewModel } } } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } } @Composable @@ -146,6 +154,7 @@ fun PlaylistDetails( playlist = it, items = items, focusRequester = focusRequester, + onChangeBackdrop = viewModel::updateBackdrop, onClickIndex = { index, _ -> viewModel.navigationManager.navigateTo( Destination.PlaybackList( @@ -212,6 +221,7 @@ fun PlaylistDetailsContent( onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, + onChangeBackdrop: (BaseItem) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() }, ) { @@ -219,13 +229,15 @@ fun PlaylistDetailsContent( var focusedIndex by remember { mutableIntStateOf(savedIndex) } val focus = remember { FocusRequester() } val focusedItem = items.getOrNull(focusedIndex) + LaunchedEffect(focusedItem) { + focusedItem?.let(onChangeBackdrop) + } val playButtonFocusRequester = remember { FocusRequester() } Box( modifier = modifier, ) { - DelayedDetailsBackdropImage(focusedItem) Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index fdda6e4a..bca08e7b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -31,7 +31,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -296,7 +295,6 @@ fun EpisodeDetailsContent( focusRequesters.getOrNull(position)?.tryRequestFocus() } Box(modifier = modifier) { - DetailsBackdropImage(ep) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index ad113a9d..584d5510 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.ThemeSongVolume +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.StreamChoiceService @@ -50,6 +51,7 @@ class EpisodeViewModel private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -96,6 +98,7 @@ class EpisodeViewModel this@EpisodeViewModel.item.value = item chosenStreams.value = result loading.value = LoadingState.Success + backdropService.submit(item) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index d22774e5..24d2f98e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -54,7 +54,6 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -396,7 +395,6 @@ fun MovieDetailsContent( focusRequesters.getOrNull(position)?.tryRequestFocus() } Box(modifier = modifier) { - DetailsBackdropImage(movie) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 4634aafa..be62b4aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.ThemeSongVolume +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager @@ -63,6 +64,7 @@ class MovieViewModel private val trailerService: TrailerService, private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -118,6 +120,7 @@ class MovieViewModel this@MovieViewModel.item.value = item chosenStreams.value = result loading.value = LoadingState.Success + backdropService.submit(item) } viewModelScope.launchIO { val trailers = trailerService.getTrailers(item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index cf75ecc6..0ab81b7e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ConfirmDialog -import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -320,8 +319,6 @@ fun SeriesDetailsContent( Box( modifier = modifier, ) { - DetailsBackdropImage(series) - Column( modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 3d2aa4c7..ecb06efb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -52,7 +52,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.PersonRow -import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.SeriesName @@ -112,7 +111,6 @@ fun SeriesOverviewContent( modifier .fillMaxWidth(), ) { - DetailsBackdropImage(backdropImageUrl) Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 35837c05..5dbe62f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager @@ -74,6 +75,7 @@ class SeriesViewModel private val extrasService: ExtrasService, val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, ) : ItemViewModel(api) { private lateinit var seriesId: UUID private lateinit var prefs: UserPreferences @@ -103,6 +105,7 @@ class SeriesViewModel ) + Dispatchers.IO, ) { val item = fetchItem(seriesId) + backdropService.submit(item) val seasons = getSeasons(item) // If a particular season was requested, fetch those episodes, otherwise get the first season @@ -332,6 +335,8 @@ class SeriesViewModel episodes.value = eps } } + // Kind of hack to ensure the backdrop is reloaded if needed + item.value?.let { backdropService.submit(it) } } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 12bd8ff6..67b4200b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress -import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails @@ -167,6 +166,7 @@ fun HomePage( }, loadingState = refreshing, showClock = preferences.appPreferences.interfacePreferences.showClock, + onUpdateBackdrop = viewModel::updateBackdrop, modifier = modifier, ) dialog?.let { params -> @@ -203,6 +203,7 @@ fun HomePageContent( onLongClickItem: (RowColumn, BaseItem) -> Unit, onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, + onUpdateBackdrop: (BaseItem) -> Unit, modifier: Modifier = Modifier, onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, @@ -249,12 +250,10 @@ fun HomePageContent( LaunchedEffect(position) { listState.animateScrollToItem(position.row) } + LaunchedEffect(focusedItem) { + focusedItem?.let(onUpdateBackdrop) + } Box(modifier = modifier) { - DelayedDetailsBackdropImage( - item = focusedItem, - modifier = Modifier, - ) - Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( item = focusedItem, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 834b0102..38c1ff4f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -9,10 +9,12 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,6 +60,7 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, + private val backdropService: BackdropService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) val refreshState = MutableLiveData(LoadingState.Pending) @@ -316,6 +319,12 @@ class HomeViewModel init(preferences) } } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } } val supportedLatestCollectionTypes = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 8e546fdb..2d3200ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -1,17 +1,59 @@ package com.github.damontecres.wholphin.ui.nav +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.transitionFactory import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.launchIO +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class ApplicationContentViewModel + @Inject + constructor( + val backdropService: BackdropService, + ) : ViewModel() { + fun clearBackdrop() { + viewModelScope.launchIO { backdropService.clearBackdrop() } + } + } /** * This is generally the root composable of the of the app @@ -25,37 +67,158 @@ fun ApplicationContent( navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, + viewModel: ApplicationContentViewModel = hiltViewModel(), ) { - NavDisplay( - backStack = navigationManager.backStack, - onBack = { navigationManager.goBack() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as Destination - val contentKey = "${key}_${server?.id}_${user?.id}" - NavEntry(key, contentKey = contentKey) { - if (key.fullScreen) { - DestinationContent( - destination = key, - preferences = preferences, - modifier = modifier.fillMaxSize(), - ) - } else if (user != null && server != null) { - NavDrawer( - destination = key, - preferences = preferences, - user = user, - server = server, - modifier = modifier, - ) - } else { - ErrorMessage("Trying to go to $key without a user logged in", null) - } + val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() + val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle + Box( + modifier = modifier, + ) { + val baseBackgroundColor = MaterialTheme.colorScheme.background + if (backdrop.hasColors && + (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) + ) { + val animPrimary by animateColorAsState( + backdrop.primaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_primary", + ) + val animSecondary by animateColorAsState( + backdrop.secondaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_secondary", + ) + val animTertiary by animateColorAsState( + backdrop.tertiaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_tertiary", + ) + Box( + modifier = + Modifier + .fillMaxSize() + .drawBehind { + drawRect(color = baseBackgroundColor) + // Top Left (Vibrant/Muted) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animSecondary, Color.Transparent), + center = Offset(0f, 0f), + radius = size.width * 0.8f, + ), + ) + // Bottom Right (DarkVibrant/DarkMuted) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animPrimary, Color.Transparent), + center = Offset(size.width, size.height), + radius = size.width * 0.8f, + ), + ) + // Bottom Left (Dark / Bridge) + drawRect( + brush = + Brush.radialGradient( + colors = + listOf( + baseBackgroundColor, + Color.Transparent, + ), + center = Offset(0f, size.height), + radius = size.width * 0.8f, + ), + ) + // Top Right (Under Image - Vibrant/Bright) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animTertiary, Color.Transparent), + center = Offset(size.width, 0f), + radius = size.width * 0.8f, + ), + ) + }, + ) + } + if (backdropStyle != BackdropStyle.BACKDROP_NONE) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(backdrop.imageUrl) + .transitionFactory(CrossFadeFactory(800.milliseconds)) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd, + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight(.7f) + .fillMaxWidth(.7f) + .alpha(.95f) + .drawWithContent { + drawContent() + drawRect( + brush = + Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = 0f, + endX = size.width * 0.6f, + ), + blendMode = BlendMode.DstIn, + ) + drawRect( + brush = + Brush.verticalGradient( + colors = listOf(Color.Black, Color.Transparent), + startY = 0f, + endY = size.height, + ), + blendMode = BlendMode.DstIn, + ) + }, + ) } - }, - ) + } + NavDisplay( + backStack = navigationManager.backStack, + onBack = { navigationManager.goBack() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as Destination + val contentKey = "${key}_${server?.id}_${user?.id}" + NavEntry(key, contentKey = contentKey) { + if (key.fullScreen) { + DestinationContent( + destination = key, + preferences = preferences, + onClearBackdrop = viewModel::clearBackdrop, + modifier = Modifier.fillMaxSize(), + ) + } else if (user != null && server != null) { + NavDrawer( + destination = key, + preferences = preferences, + user = user, + server = server, + onClearBackdrop = viewModel::clearBackdrop, + modifier = Modifier.fillMaxSize(), + ) + } else { + ErrorMessage("Trying to go to $key without a user logged in", null) + } + } + }, + ) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 47d62e22..2f6feb7a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions @@ -41,8 +42,12 @@ import timber.log.Timber fun DestinationContent( destination: Destination, preferences: UserPreferences, + onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, ) { + if (destination.fullScreen) { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + } when (destination) { is Destination.Home -> { HomePage( @@ -122,6 +127,7 @@ fun DestinationContent( } BaseItemKind.BOX_SET -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } CollectionFolderBoxSet( preferences = preferences, itemId = destination.itemId, @@ -133,6 +139,7 @@ fun DestinationContent( } BaseItemKind.PLAYLIST -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } PlaylistDetails( destination = destination, modifier = modifier, @@ -140,6 +147,7 @@ fun DestinationContent( } BaseItemKind.COLLECTION_FOLDER -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } CollectionFolder( preferences = preferences, destination = destination, @@ -151,6 +159,7 @@ fun DestinationContent( } BaseItemKind.FOLDER -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } CollectionFolder( preferences = preferences, destination = destination, @@ -162,6 +171,7 @@ fun DestinationContent( } BaseItemKind.USER_VIEW -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } CollectionFolder( preferences = preferences, destination = destination, @@ -173,6 +183,7 @@ fun DestinationContent( } BaseItemKind.PERSON -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } PersonPage( preferences, destination, @@ -188,6 +199,7 @@ fun DestinationContent( } is Destination.FilteredCollection -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } CollectionFolderGeneric( preferences = preferences, itemId = destination.itemId, @@ -201,6 +213,7 @@ fun DestinationContent( } is Destination.Recordings -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } CollectionFolderRecordings( preferences, destination.itemId, @@ -210,6 +223,7 @@ fun DestinationContent( } is Destination.ItemGrid -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } ItemGrid( destination, modifier, @@ -217,6 +231,7 @@ fun DestinationContent( } Destination.Favorites -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } FavoritesPage( preferences = preferences, modifier = modifier, @@ -232,6 +247,7 @@ fun DestinationContent( } Destination.Search -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } SearchPage( userPreferences = preferences, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 62a37ca6..9a7d3559 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -77,6 +77,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay @@ -105,6 +106,7 @@ class NavDrawerViewModel constructor( private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, + val backdropService: BackdropService, ) : ViewModel() { private var all: List? = null val moreLibraries = MutableLiveData>(null) @@ -210,6 +212,7 @@ fun NavDrawer( preferences: UserPreferences, user: JellyfinUser, server: JellyfinServer, + onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = hiltViewModel( @@ -300,11 +303,9 @@ fun NavDrawer( val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp) val drawerBackground by animateColorAsState( if (drawerState.isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation( - 1.dp, - ) - } else { MaterialTheme.colorScheme.surface + } else { + Color.Transparent }, ) val spacedBy = 4.dp @@ -528,6 +529,7 @@ fun NavDrawer( DestinationContent( destination = destination, preferences = preferences, + onClearBackdrop = onClearBackdrop, modifier = Modifier .fillMaxSize(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt index 112d8817..53c10bd2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt @@ -15,22 +15,24 @@ import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors val LocalTheme = compositionLocalOf { AppThemeColors.PURPLE } +fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = + when (appThemeColors) { + AppThemeColors.PURPLE -> PurpleThemeColors + AppThemeColors.BLUE -> BlueThemeColors + AppThemeColors.GREEN -> GreenThemeColors + AppThemeColors.ORANGE -> OrangeThemeColors + AppThemeColors.OLED_BLACK -> OledThemeColors + AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors + AppThemeColors.UNRECOGNIZED -> PurpleThemeColors + } + @Composable fun WholphinTheme( darkTheme: Boolean = true, appThemeColors: AppThemeColors = AppThemeColors.PURPLE, content: @Composable () -> Unit, ) { - val themeColors = - when (appThemeColors) { - AppThemeColors.PURPLE -> PurpleThemeColors - AppThemeColors.BLUE -> BlueThemeColors - AppThemeColors.GREEN -> GreenThemeColors - AppThemeColors.ORANGE -> OrangeThemeColors - AppThemeColors.OLED_BLACK -> OledThemeColors - AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors - AppThemeColors.UNRECOGNIZED -> PurpleThemeColors - } + val themeColors = getThemeColors(appThemeColors) val colorScheme = when { diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index a003bbcc..f10b49c5 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -132,6 +132,12 @@ message LiveTvPreferences { bool color_code_programs = 4; } +enum BackdropStyle{ + BACKDROP_DYNAMIC_COLOR = 0; + BACKDROP_IMAGE_ONLY = 1; + BACKDROP_NONE = 2; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -141,6 +147,7 @@ message InterfacePreferences { bool show_clock = 6; SubtitlePreferences subtitles_preferences = 7; LiveTvPreferences live_tv_preferences = 8; + BackdropStyle backdrop_style = 9; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6532b865..a6647d22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -394,6 +394,7 @@ Sort channels by recently watched Color-code programs Subtitle delay + Backdrop style Disabled @@ -482,4 +483,10 @@ + + Image with dynamic color + Image only + None + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94708ac2..b899dc14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ protobuf-javalite = "4.33.1" hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" +paletteKtx = "1.0.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -100,6 +101,7 @@ slf4j2-timber = { module = "uk.kulikov:slf4j2-timber", version.ref = "slf4j2Timb timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 801ff5e67bef468cf3837529596a6a1551d48ad5 Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Thu, 18 Dec 2025 03:56:19 +1100 Subject: [PATCH 069/124] Redesign Select Server and Select User screens (#478) Redesign Select Server and Select User screens with horizontal card layout, similar to traditional streaming services - Replace vertical lists with horizontal list of icons - Center align text in Quick Connect and Username/Password modals - Display Jellyfin profile images - Fallback to first letter of username if no image is available - Filter out duplicate server entries in the auto-discovery list --------- Co-authored-by: Damontecres --- .../wholphin/services/ImageUrlService.kt | 2 + .../damontecres/wholphin/ui/UiConstants.kt | 1 + .../wholphin/ui/setup/ServerList.kt | 269 ++++++++++++ .../wholphin/ui/setup/SwitchServerContent.kt | 415 ++++++++++++------ .../wholphin/ui/setup/SwitchUserContent.kt | 52 +-- .../wholphin/ui/setup/SwitchUserViewModel.kt | 26 +- .../damontecres/wholphin/ui/setup/UserList.kt | 344 ++++++++++++--- app/src/main/res/values/strings.xml | 2 + 8 files changed, 886 insertions(+), 225 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index ee7a4348..821794e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -145,6 +145,8 @@ class ImageUrlService imageIndex = imageIndex, ) + fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId) + /** * Just a convenient way to get the image URL and remember it */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 5af35e03..9557de96 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -68,6 +68,7 @@ val SlimItemFields = object Cards { val height2x3 = 172.dp val playedPercentHeight = 6.dp + val serverUserCircle = height2x3 * .75f } object AspectRatios { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 08957981..4ab990de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -1,8 +1,17 @@ package com.github.damontecres.wholphin.ui.setup +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete @@ -13,18 +22,26 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon import androidx.tv.material3.IconButtonDefaults import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -157,3 +174,255 @@ fun ServerList( ) } } + +/** + * Generate a consistent color for a UUID + */ +@Composable +fun rememberIdColor(id: UUID): Color = + remember(id) { + // Generate a color based on the server ID hash, fallback to URL hash + val hash = id.hashCode() + val hue = (hash % 360).toFloat() + val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0 + val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors) + + // Convert HSV to RGB + val c = brightness * saturation + val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1)) + val m = brightness - c + + val (r, g, b) = + when { + hue < 60 -> Triple(c, x, 0f) + hue < 120 -> Triple(x, c, 0f) + hue < 180 -> Triple(0f, c, x) + hue < 240 -> Triple(0f, x, c) + hue < 300 -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + + Color( + red = (r + m).coerceIn(0f, 1f), + green = (g + m).coerceIn(0f, 1f), + blue = (b + m).coerceIn(0f, 1f), + ) + } + +/** + * Server icon card component - displays a circular card with server name/letter + */ +@Composable +fun ServerIconCard( + server: JellyfinServer, + connectionStatus: ServerConnectionStatus, + isCurrentServer: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + allowDelete: Boolean = false, +) { + val interactionSource = remember { MutableInteractionSource() } + + // Generate unique color for this server + val serverColor = rememberIdColor(server.id) + + // Card dimensions - circular card + val cardSize = Cards.serverUserCircle + + val displayText = + remember(server) { + (server.name ?: server.url.replace(Regex("^https?://"), "")) + .firstOrNull() + ?.uppercase() + ?: "?" + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Circular card with colored background + Surface( + onClick = onClick, + onLongClick = if (allowDelete) onLongClick else null, + interactionSource = interactionSource, + modifier = Modifier.size(cardSize), + shape = ClickableSurfaceDefaults.shape(shape = CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = + if (isCurrentServer) { + serverColor.copy(alpha = 0.7f) + } else { + serverColor.copy(alpha = 0.5f) + }, + focusedContainerColor = + if (isCurrentServer) { + serverColor.copy(alpha = 0.9f) + } else { + serverColor.copy(alpha = 0.7f) + }, + ), + border = + ClickableSurfaceDefaults.border( + focusedBorder = + Border( + border = + BorderStroke( + width = 3.dp, + color = MaterialTheme.colorScheme.onSurface, + ), + shape = CircleShape, + ), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + // Show connection status indicator or server name/letter + when (connectionStatus) { + is ServerConnectionStatus.Success -> { + // Show server name/letter + + Text( + text = displayText, + style = + MaterialTheme.typography.displayLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + ServerConnectionStatus.Pending -> { + CircularProgress( + modifier = Modifier.size(cardSize * 0.4f), + ) + } + + is ServerConnectionStatus.Error -> { + // Show warning icon with server letter below + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = connectionStatus.message, + tint = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.size(cardSize * 0.3f), + ) + Text( + text = displayText, + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } + } + } + } + + // Server name below the card + Text( + text = server.name?.ifBlank { null } ?: server.url, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .width(cardSize) + .padding(horizontal = 4.dp), + ) + } +} + +/** + * Add Server card component - displays a + icon in a circle + */ +@Composable +fun AddServerCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + + // Use a neutral gray color for the add server card + val addServerColor = MaterialTheme.colorScheme.surfaceVariant + + // Card dimensions - circular card (same as server cards) + val cardSize = Cards.height2x3 * 0.75f // ~120dp + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Circular card with colored background + Surface( + onClick = onClick, + interactionSource = interactionSource, + modifier = Modifier.size(cardSize), + shape = ClickableSurfaceDefaults.shape(shape = CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = addServerColor.copy(alpha = 0.4f), + focusedContainerColor = addServerColor.copy(alpha = 0.6f), + ), + border = + ClickableSurfaceDefaults.border( + focusedBorder = + Border( + border = + BorderStroke( + width = 3.dp, + color = MaterialTheme.colorScheme.onSurface, + ), + shape = CircleShape, + ), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_server), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon + ) + } + } + + // "Add Server" text below the card + Text( + text = stringResource(R.string.add_server), + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .width(cardSize) + .padding(horizontal = 4.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 6fafeecc..4902ca06 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -1,16 +1,23 @@ package com.github.damontecres.wholphin.ui.setup -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,6 +29,8 @@ 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.focus.focusRestorer +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -29,19 +38,21 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState -import org.jellyfin.sdk.model.api.PublicSystemInfo @Composable fun SwitchServerContent( @@ -51,143 +62,185 @@ fun SwitchServerContent( val servers by viewModel.servers.observeAsState(listOf()) val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) - val discoveredServers by viewModel.discoveredServers.observeAsState(listOf()) - var showAddServer by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.init() - viewModel.discoverServers() } Box( - modifier = modifier.dimAndBlur(showAddServer), + modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), ) { - Row( + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier + .fillMaxWidth() + // Center the content like the Select User screen .align(Alignment.Center) - .padding(32.dp), + .padding(16.dp), ) { + // Match SwitchUser header height (title + subtitle) to align icons vertically across screens Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .padding(16.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(16.dp), - ), ) { Text( text = stringResource(R.string.select_server), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) - ServerList( - servers = servers, - connectionStatus = serverStatus, - onSwitchServer = { - viewModel.switchServer(it) - }, - onTestServer = { - viewModel.testServer(it) - }, - onAddServer = { - showAddServer = true - }, - onRemoveServer = { - viewModel.removeServer(it) - }, - allowAdd = true, - allowDelete = true, - modifier = - Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - shape = RoundedCornerShape(16.dp), - ), + // Invisible subtitle placeholder to mirror the server name line on the Select User screen + Text( + text = "Server placeholder", + style = MaterialTheme.typography.titleLarge, + color = Color.Transparent, ) } - // Discover - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + + // Horizontal scrollable list of server icons - centered + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val focusRequester = remember { FocusRequester() } + val firstServerFocus = remember { FocusRequester() } + if (servers.isNotEmpty()) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), + modifier = + Modifier + .wrapContentWidth() + .focusRestorer(firstServerFocus) + .focusRequester(focusRequester), + ) { + itemsIndexed(servers) { index, server -> + val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending + ServerIconCard( + server = server, + connectionStatus = status, + isCurrentServer = false, // TODO: Determine current server if needed + onClick = { + when (status) { + is ServerConnectionStatus.Success -> { + viewModel.switchServer(server) + } + + ServerConnectionStatus.Pending -> { + // Do nothing while pending + } + + is ServerConnectionStatus.Error -> { + viewModel.testServer(server) + } + } + }, + onLongClick = { + showDeleteDialog = server + }, + allowDelete = true, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)), + ) + } + // Add Server card - always rightmost + item { + AddServerCard( + onClick = { showAddServer = true }, + ) + } + } + } + // Non-focusable spacer to mirror the space occupied by the "Switch Servers" button + Spacer( modifier = Modifier .fillMaxWidth() - .weight(1f) - .padding(16.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(16.dp), - ), - ) { - Text( - text = stringResource(R.string.discovered_servers), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - if (discoveredServers.isEmpty()) { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - ServerList( - servers = discoveredServers, - connectionStatus = - discoveredServers - .map { it.id } - .associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) }, - onSwitchServer = { - viewModel.addServer(it.url) + .height(56.dp), + // approximate TV button height + ) + } + + // Delete server dialog + showDeleteDialog?.let { server -> + DialogPopup( + showDialog = true, + title = server.name ?: server.url, + dialogItems = + listOf( + DialogItem( + stringResource(R.string.switch_servers), + R.string.fa_arrow_left_arrow_right, + ) { + viewModel.switchServer(server) + showDeleteDialog = null }, - onTestServer = { - viewModel.testServer(it) + DialogItem( + stringResource(R.string.delete), + Icons.Default.Delete, + Color.Red.copy(alpha = .8f), + ) { + viewModel.removeServer(server) + showDeleteDialog = null }, - onAddServer = {}, - onRemoveServer = {}, - allowAdd = false, - allowDelete = false, - modifier = - Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - shape = RoundedCornerShape(16.dp), - ), - ) - } - } + ), + onDismissRequest = { showDeleteDialog = null }, + dismissOnClick = true, + waitToLoad = true, + properties = DialogProperties(), + elevation = 5.dp, + ) } if (showAddServer) { + var showEnterAddress by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { viewModel.clearAddServerState() + if (!showEnterAddress) { + viewModel.discoverServers() + } } - val state by viewModel.addServerState.observeAsState(LoadingState.Pending) -// val url = rememberTextFieldState() - var url by remember { mutableStateOf("") } - val submit = { -// viewModel.addServer(url.text.toString()) - viewModel.addServer(url) + + val discoveredServers by viewModel.discoveredServers.observeAsState(listOf()) + + // Filter out duplicates within the discovered servers list (same URL appearing multiple times) + val filteredDiscoveredServers = + remember(discoveredServers) { + val seenUrls = mutableSetOf() + discoveredServers.filter { server -> + val normalizedUrl = server.url.lowercase().trim() + if (normalizedUrl in seenUrls) { + false // Duplicate, filter it out + } else { + seenUrls.add(normalizedUrl) + true // First occurrence, keep it + } + } + } + + val firstDiscoveredServerFocusRequester = remember { FocusRequester() } + + // Default focus to first discovered server if available + LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) { + if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) { + firstDiscoveredServerFocusRequester.tryRequestFocus() + } } + BasicDialog( onDismissRequest = { showAddServer = false + showEnterAddress = false viewModel.clearAddServerState() }, properties = DialogProperties(usePlatformDefaultWidth = false), elevation = 10.dp, ) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), @@ -196,50 +249,134 @@ fun SwitchServerContent( .padding(16.dp) .fillMaxWidth(.4f), ) { - Text( - text = stringResource(R.string.enter_server_url), - ) - EditTextBox( - value = url, - onValueChange = { url = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { submit.invoke() }, - ), - // onKeyboardAction = { submit.invoke() }, - modifier = - Modifier - .focusRequester(focusRequester) - .fillMaxWidth(), - ) - when (val st = state) { - is LoadingState.Error -> { + if (!showEnterAddress) { + // Show discovered servers first + Text( + text = stringResource(R.string.discovered_servers), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) { Text( - text = - st.message ?: st.exception?.localizedMessage - ?: "An error occurred", - color = MaterialTheme.colorScheme.error, + text = stringResource(R.string.searching), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) + } else if (filteredDiscoveredServers.isEmpty()) { + Text( + text = stringResource(R.string.no_servers_found), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + ) { + items( + filteredDiscoveredServers.size, + key = { filteredDiscoveredServers[it].url }, + ) { index -> + val server = filteredDiscoveredServers[index] + val focusRequester = + if (index == 0) { + firstDiscoveredServerFocusRequester + } else { + remember { FocusRequester() } + } + + ListItem( + enabled = true, + selected = false, + headlineContent = { + Text( + text = server.name?.ifBlank { null } ?: server.url, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + text = server.url, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = { + viewModel.addServer(server.url) + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } } - else -> {} - } - TextButton( - onClick = { submit.invoke() }, - enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, - modifier = Modifier, - ) { - if (state == LoadingState.Loading) { - CircularProgress(Modifier.size(32.dp)) - } else { - Text(text = stringResource(R.string.submit)) + TextButton( + onClick = { + showEnterAddress = true + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(text = stringResource(R.string.enter_server_address)) + } + } else { + // Show enter server address form + val state by viewModel.addServerState.observeAsState(LoadingState.Pending) + var url by remember { mutableStateOf("") } + val submit = { + viewModel.addServer(url) + } + val textBoxFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + textBoxFocusRequester.tryRequestFocus() + } + + Text( + text = stringResource(R.string.enter_server_url), + ) + EditTextBox( + value = url, + onValueChange = { url = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { submit.invoke() }, + ), + modifier = + Modifier + .focusRequester(textBoxFocusRequester) + .fillMaxWidth(), + ) + when (val st = state) { + is LoadingState.Error -> { + Text( + text = + st.message ?: st.exception?.localizedMessage + ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + ) + } + + else -> {} + } + TextButton( + onClick = { submit.invoke() }, + enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, + modifier = Modifier, + ) { + if (state == LoadingState.Loading) { + CircularProgress(Modifier.size(32.dp)) + } else { + Text(text = stringResource(R.string.submit)) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index c734aed2..19c00f18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.ui.setup import android.widget.Toast -import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable @@ -36,7 +34,6 @@ import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -94,27 +91,28 @@ fun SwitchUserContent( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .fillMaxWidth(.5f) + .fillMaxWidth() .align(Alignment.Center) - .padding(16.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(16.dp), - ), + .padding(16.dp), ) { - Text( - text = stringResource(R.string.select_user), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = server.name ?: server.url, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.select_user), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = server.name ?: server.url, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } UserList( users = users, currentUser = currentUser, @@ -134,13 +132,7 @@ fun SwitchUserContent( onSwitchServer = { viewModel.navigationManager.navigateTo(Destination.ServerList) }, - modifier = - Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - shape = RoundedCornerShape(16.dp), - ), + modifier = Modifier.fillMaxWidth(), ) } } @@ -170,7 +162,7 @@ fun SwitchUserContent( Modifier .focusGroup() .padding(16.dp) - .fillMaxWidth(.66f), + .fillMaxWidth(.4f), ) { if (useQuickConnect) { if (quickConnect == null && userState !is LoadingState.Error) { @@ -196,6 +188,8 @@ fun SwitchUserContent( text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) Text( text = quickConnect?.code ?: "Failed to get code", @@ -233,6 +227,8 @@ fun SwitchUserContent( text = "Enter username/password to login to ${server.name ?: server.url}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) UserStateError(userState) Row( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 56d7ab98..7da64ca7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain @@ -25,6 +26,7 @@ import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.authenticateUserByName +import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi @@ -41,6 +43,7 @@ class SwitchUserViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: NavigationManager, + val imageUrlService: ImageUrlService, @Assisted val server: JellyfinServer, ) : ViewModel() { @AssistedFactory @@ -50,7 +53,7 @@ class SwitchUserViewModel val serverQuickConnect = MutableLiveData(false) - val users = MutableLiveData>(listOf()) + val users = MutableLiveData>(listOf()) val quickConnectState = MutableLiveData(null) private var quickConnectJob: Job? = null @@ -78,8 +81,7 @@ class SwitchUserViewModel quickConnectJob?.cancel() viewModelScope.launchIO { users.setValueOnMain(listOf()) - val serverUsers = - serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf() + val serverUsers = getUsers() withContext(Dispatchers.Main) { users.setValueOnMain(serverUsers) } @@ -215,14 +217,23 @@ class SwitchUserViewModel fun removeUser(user: JellyfinUser) { viewModelScope.launchIO { serverRepository.removeUser(user) - val serverUsers = - serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf() + val serverUsers = getUsers() withContext(Dispatchers.Main) { users.value = serverUsers } } } + private suspend fun getUsers(): List { + val api = jellyfin.createApi(server.url) + return serverDao + .getServer(server.id) + ?.users + ?.sortedBy { it.name } + ?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) } + .orEmpty() + } + private suspend fun setError( msg: String? = null, ex: Exception? = null, @@ -231,3 +242,8 @@ class SwitchUserViewModel switchUserState.value = LoadingState.Error(msg, ex) } } + +data class JellyfinUserAndImage( + val user: JellyfinUser, + val imageUrl: String?, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index c4fb035e..d8b1b096 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -1,37 +1,68 @@ package com.github.damontecres.wholphin.ui.setup -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.Border +import androidx.tv.material3.Button +import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon -import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface import androidx.tv.material3.Text +import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.tryRequestFocus /** * Display a list of users plus option to add a new one or switch servers + * Redesigned to match streaming service style with horizontal scrollable user icons */ @Composable fun UserList( - users: List, + users: List, currentUser: JellyfinUser?, onSwitchUser: (JellyfinUser) -> Unit, onAddUser: () -> Unit, @@ -41,59 +72,76 @@ fun UserList( ) { var showDeleteDialog by remember { mutableStateOf(null) } - LazyColumn(modifier = modifier) { - items(users) { user -> - ListItem( - enabled = true, - selected = user == currentUser, - headlineContent = { Text(text = user.name ?: user.id.toString()) }, - leadingContent = { - if (user.id == currentUser?.id) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "current user", - ) - } - }, - onClick = { onSwitchUser.invoke(user) }, - onLongClick = { - showDeleteDialog = user - }, - modifier = Modifier, - ) - } - item { - HorizontalDivider() - ListItem( - enabled = true, - selected = false, - headlineContent = { Text(text = stringResource(R.string.add_user)) }, - leadingContent = { - Icon( - imageVector = Icons.Default.Add, - tint = Color.Green.copy(alpha = .8f), - contentDescription = null, + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Horizontal scrollable list of user icons - centered + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + if (users.isNotEmpty()) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale + contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale + modifier = + Modifier + .wrapContentWidth() + .focusRestorer(firstFocusRequester) + .focusRequester(focusRequester), + ) { + itemsIndexed(users) { index, user -> + UserIconCard( + user = user, + isCurrentUser = user.user.id == currentUser?.id, + onClick = { onSwitchUser.invoke(user.user) }, + onLongClick = { showDeleteDialog = user.user }, + modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, ) - }, - onClick = { onAddUser.invoke() }, - modifier = Modifier, - ) + } + // Add User card - always rightmost + item { + AddUserCard( + onClick = { onAddUser.invoke() }, + ) + } + } } - item { - HorizontalDivider() - ListItem( - enabled = true, - selected = false, - headlineContent = { Text(text = stringResource(R.string.switch_servers)) }, - leadingContent = { + + // Switch servers button below user list - centered + Row( + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Button( + onClick = { onSwitchServer.invoke() }, + modifier = Modifier.width(200.dp), // Fixed width for consistency + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = stringResource(R.string.fa_arrow_left_arrow_right), fontFamily = FontAwesome, + modifier = Modifier.padding(end = 8.dp), ) - }, - onClick = { onSwitchServer.invoke() }, - modifier = Modifier, - ) + Text( + text = stringResource(R.string.switch_servers), + textAlign = TextAlign.Center, + ) + } + } } } showDeleteDialog?.let { user -> @@ -124,3 +172,193 @@ fun UserList( ) } } + +@Composable +private fun UserIconCard( + user: JellyfinUserAndImage, + isCurrentUser: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + // Generate unique color for this user + val userColor = rememberIdColor(user.user.id) + + // Track image loading errors + var imageError by remember { mutableStateOf(false) } + + // Card dimensions - circular card + val cardSize = Cards.serverUserCircle + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Circular card with colored background + Surface( + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + modifier = Modifier.size(cardSize), + shape = ClickableSurfaceDefaults.shape(shape = CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = + if (isCurrentUser) { + userColor.copy(alpha = 0.7f) + } else { + userColor.copy(alpha = 0.5f) + }, + focusedContainerColor = + if (isCurrentUser) { + userColor.copy(alpha = 0.9f) + } else { + userColor.copy(alpha = 0.7f) + }, + ), + border = + ClickableSurfaceDefaults.border( + focusedBorder = + Border( + border = + BorderStroke( + width = 3.dp, + color = MaterialTheme.colorScheme.onSurface, + ), + shape = CircleShape, + ), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (user.imageUrl.isNotNullOrBlank() && !imageError) { + AsyncImage( + model = user.imageUrl, + contentDescription = user.user.name, + contentScale = ContentScale.Crop, + onError = { imageError = true }, + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } else { + // Show big bold first letter of username + val firstLetter = + remember(user) { + user.user.let { + (it.name ?: it.id.toString()).firstOrNull()?.uppercase() + } ?: "?" + } + Text( + text = firstLetter, + style = + MaterialTheme.typography.displayLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } + } + + // Username below the card + Text( + text = user.user.name ?: user.user.id.toString(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .width(cardSize) + .padding(horizontal = 4.dp), + ) + } +} + +/** + * Add User card component - displays a + icon in a circle + */ +@Composable +private fun AddUserCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + + // Use a neutral gray color for the add user card + val addUserColor = MaterialTheme.colorScheme.surfaceVariant + + // Card dimensions - circular card (same as user cards) + val cardSize = Cards.height2x3 * 0.75f // ~120dp + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), // Increased to accommodate 20% scale + ) { + // Circular card with colored background + Surface( + onClick = onClick, + interactionSource = interactionSource, + modifier = Modifier.size(cardSize), + shape = ClickableSurfaceDefaults.shape(shape = CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = addUserColor.copy(alpha = 0.4f), + focusedContainerColor = addUserColor.copy(alpha = 0.6f), + ), + border = + ClickableSurfaceDefaults.border( + focusedBorder = + Border( + border = + BorderStroke( + width = 3.dp, + color = MaterialTheme.colorScheme.onSurface, + ), + shape = CircleShape, + ), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_user), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon + ) + } + } + + // "Add User" text below the card + Text( + text = stringResource(R.string.add_user), + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .width(cardSize) + .padding(horizontal = 4.dp), + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6647d22..d399631a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,7 @@ Downloading… Enabled Enter Server IP or URL + Enter server address Episodes Error loading collection %1$s External @@ -69,6 +70,7 @@ Next Up No data No results + No servers found No scheduled recordings No update available None From 2908f539e2eafff70ebfbfbfda219894b01d4f5d Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Thu, 18 Dec 2025 04:16:46 +1100 Subject: [PATCH 070/124] Redesign alphabet picker to fit all letters on screen (#480) - Switch to tv.material3.Button with ButtonDefaults for better styling - Add opacity changes based on focus (0.85f focused, 0.2f unfocused) - Keep selected letter fully visible when picker is unfocused - Reduce button size from 24.dp to 14.dp to fit all letters - Reduce spacing between letters (1.1.dp vertical, 2.dp horizontal) - Use transparent backgrounds for unfocused letters - Use border color for selected letter when focused, tertiary when unfocused - Add Box with CircleShape clip to prevent focus indicator overflow - Reduce font size by 15% - Add 16.dp end padding to push picker away from screen edge When using the 'Show details' view option, the alphabet picker will still overflow the bottom edge of the screen. I find this an acceptable option though, as the backdrop pushes the card grid further down the page. Closes https://github.com/damontecres/Wholphin/issues/386 >[!NOTE] > AI was used in the making of this PR --------- Co-authored-by: Damontecres --- .../wholphin/ui/detail/CardGrid.kt | 118 +++++++++++++----- 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 8ffba496..eeb16031 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -33,11 +34,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -49,6 +53,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -57,8 +63,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.cards.GridCard -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.playback.isBackwardButton import com.github.damontecres.wholphin.ui.playback.isForwardButton @@ -366,7 +370,11 @@ fun CardGrid( AlphabetButtons( letters = letters, currentLetter = currentLetter, - modifier = Modifier.align(Alignment.CenterVertically), + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(end = 16.dp), + // Add end padding to push away from edge letterClicked = { letter -> scope.launch(ExceptionHandler()) { val jumpPosition = @@ -375,6 +383,7 @@ fun CardGrid( } Timber.d("Alphabet jump to $jumpPosition") if (jumpPosition >= 0) { + pager.getOrNull(jumpPosition) gridState.scrollToItem(jumpPosition) focusOn(jumpPosition) alphabetFocus = true @@ -428,7 +437,6 @@ fun AlphabetButtons( letterClicked: (Char) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -440,51 +448,97 @@ fun AlphabetButtons( listState.layoutInfo.visibleItemsInfo .lastOrNull() ?.index ?: -1 - if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) { + if (index !in firstVisibleItemIndex..lastVisibleItemIndex) { listState.animateScrollToItem(index) } } } + // Focus & interaction states for each letter button val focusRequesters = remember { List(letters.length) { FocusRequester() } } + val interactionSources = remember { List(letters.length) { MutableInteractionSource() } } + + // Track if the entire alphabet picker component has focus + var alphabetPickerFocused by remember { mutableStateOf(false) } + LazyColumn( - contentPadding = PaddingValues(4.dp), + contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp), + verticalArrangement = Arrangement.spacedBy(1.1.dp), state = listState, modifier = - modifier.focusProperties { - onEnter = { - focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus() - } - }, + modifier + .onFocusChanged { focusState -> + alphabetPickerFocused = focusState.hasFocus + }.focusProperties { + onEnter = { + focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus() + } + }, ) { items( letters.length, key = { letters[it] }, ) { index -> - val interactionSource = remember { MutableInteractionSource() } + val interactionSource = interactionSources[index] val focused by interactionSource.collectIsFocusedAsState() - TextButton( + + val isCurrentLetter = letters[index] == currentLetter + // Apply alpha to individual items, but keep selected letter fully visible when picker is unfocused + val itemAlpha = + when { + isCurrentLetter && !alphabetPickerFocused -> 1f + alphabetPickerFocused -> .85f + else -> .25f + } + + // Only show circle background for the current letter (or when focused) + // Wrap in Box with clipping to prevent focus indicator from overflowing + Box( modifier = Modifier - .size(24.dp) - .focusRequester(focusRequesters[index]), - contentPadding = PaddingValues(2.dp), - interactionSource = interactionSource, - onClick = { - letterClicked.invoke(letters[index]) - }, + .size(14.dp) + .clip(CircleShape) + .alpha(itemAlpha), ) { - val color = - if (!focused && letters[index] == currentLetter) { - MaterialTheme.colorScheme.tertiary - } else { - LocalContentColor.current - } - Text( - text = letters[index].toString(), - color = color, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) + Button( + modifier = + Modifier + .size(14.dp) + .focusRequester(focusRequesters[index]), + contentPadding = PaddingValues(0.dp), // No padding to maximize text space + interactionSource = interactionSource, + onClick = { + letterClicked.invoke(letters[index]) + }, + colors = + if (isCurrentLetter || focused) { + // Use default button colors for current letter or focused + ButtonDefaults.colors() + } else { + // Transparent background for non-current letters (no circle) + ButtonDefaults.colors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = MaterialTheme.colorScheme.primaryContainer, + focusedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + ) { + // Use border color for selected letter when focused, tertiary for unfocused-selected + val color = + when { + isCurrentLetter && focused -> MaterialTheme.colorScheme.border + isCurrentLetter -> MaterialTheme.colorScheme.tertiary + focused -> LocalContentColor.current + else -> MaterialTheme.colorScheme.onSurface + } + Text( + text = letters[index].toString(), + color = color, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + ) + } } } } From 7581b05b97ce1a35752eeb111a51e2e9f5508afb Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:25:36 -0500 Subject: [PATCH 071/124] Move time tracking into a CompositionLocal (#493) ## Description Creates a `CompositionLocal` which tracks the current time in a single place and is usable anywhere in the app. Can be used such as: ```kotlin val now = LocalClock.current.now val details = remember(item, now) { // Create detail strings based the item and current time // This will be recomposed/updated whenever the time changes } ``` ### Related issues Related to #475 --- .../damontecres/wholphin/MainActivity.kt | 17 ++++---- .../wholphin/ui/components/TimeDisplay.kt | 19 +-------- .../wholphin/ui/util/LocalClock.kt | 42 +++++++++++++++++++ 3 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index ef90f38a..52a63020 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -222,13 +223,15 @@ class MainActivity : AppCompatActivity() { ) } } - ApplicationContent( - user = current?.user, - server = current?.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) + ProvideLocalClock { + ApplicationContent( + user = current?.user, + server = current?.server, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index 54c2d75c..79bd2f73 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -3,33 +3,18 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.TimeFormatter -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import java.time.LocalTime +import com.github.damontecres.wholphin.ui.util.LocalClock @Composable fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { - var now by remember { mutableStateOf(LocalTime.now()) } - LaunchedEffect(Unit) { - while (isActive) { - now = LocalTime.now() - delay(1000L) - } - } Text( - text = TimeFormatter.format(now), + text = LocalClock.current.timeString, fontSize = 18.sp, color = MaterialTheme.colorScheme.onSurface, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt new file mode 100644 index 00000000..53cd4d9b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -0,0 +1,42 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.damontecres.wholphin.ui.TimeFormatter +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.time.LocalDateTime + +val LocalClock = compositionLocalOf { throw IllegalStateException() } + +/** + * Represents the current time + */ +data class Clock( + /** + * The current [LocalDateTime] + */ + val now: LocalDateTime, + /** + * The current time formatted as a string with [TimeFormatter] + */ + val timeString: String, +) + +@Composable +fun ProvideLocalClock(content: @Composable () -> Unit) { + var clock by remember { mutableStateOf(LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }) } + LaunchedEffect(Unit) { + while (isActive) { + clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) } + delay(1_000) + } + } + CompositionLocalProvider(LocalClock provides clock, content) +} From 6776640087aefe7bbe73d837459d50ce3e955723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Thu, 11 Dec 2025 23:13:15 +0000 Subject: [PATCH 072/124] Translated using Weblate (Estonian) Currently translated at 99.6% (271 of 272 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 873bdbbe..cf550687 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -315,4 +315,5 @@ Automaatne Pruugi kasutajanime ja salasõna Logi sisse + From 18a2f648ca3e92655ef7e73627e531dddec01dd3 Mon Sep 17 00:00:00 2001 From: ymir Date: Thu, 11 Dec 2025 22:30:24 +0000 Subject: [PATCH 073/124] Translated using Weblate (French) Currently translated at 100.0% (272 of 272 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1a3afd7e..36dff01f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -304,4 +304,32 @@ Dolby Atmos Remplacer la lecture Marge + MPV: Utiliser gpu-next + Éditer mpv.conf + Annuler les modifications? + Journalisation détaillée + Saisir le code PIN + Connexion automatique + Connexion via serveur + Appuyez au centre pour confirmer + Code PIN requis pour le profil + Confirmer le code PIN + Erroné + Le code PIN doit comporter au moins 4 éléments + Supprimera le code PIN + Taille du cache disque de l\'image (Mo) + Options d\'affichage + Colonnes + Espacement + Format d\'image + Afficher les détails + + Invités spéciaux + Taille des bordures + Changement de fréquence de rafraîchissement + Automatique + Utiliser un nom d\'utilisateur/mot de passe + Connexion + Afficher les titres + Type d\'image From 4fac067fb0cd69a643cfbc679b7d84ee1f9b8a87 Mon Sep 17 00:00:00 2001 From: ianvm Date: Thu, 11 Dec 2025 22:09:05 +0000 Subject: [PATCH 074/124] Translated using Weblate (Dutch) Currently translated at 100.0% (272 of 272 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a793ee8f..2bfd70e5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -315,4 +315,5 @@ Automatisch Gebruikersnaam/wachtwoord gebruiken Inloggen + Titels weergeven From 6e77afe6728287f8b75bea6c8bf6cdaed9d3a7d7 Mon Sep 17 00:00:00 2001 From: Codeberg Translate Date: Fri, 12 Dec 2025 02:30:15 +0000 Subject: [PATCH 075/124] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-et/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index cf550687..873bdbbe 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -315,5 +315,4 @@ Automaatne Pruugi kasutajanime ja salasõna Logi sisse - From 8bdd030f427cad4d95625e872ad4d6cdad2589c1 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 12 Dec 2025 02:30:15 +0000 Subject: [PATCH 076/124] Added translation using Weblate (Chinese (Simplified Han script)) --- app/src/main/res/values-zh-rCN/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-zh-rCN/strings.xml diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From ca7c8b2e5f646f75330c8929f889c43754cc4eb7 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Fri, 12 Dec 2025 16:49:25 +0000 Subject: [PATCH 077/124] Translated using Weblate (Portuguese) Currently translated at 90.0% (245 of 272 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 246 +++++++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 34a61f80..6757e505 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -78,4 +78,250 @@ Lista de Reprodução Listas de Reprodução Pré-visualização + Classificação da Comunidade + Classificação da Crítica + Definições de Utilizador + Fila + Recapitulação + Adicionado recentemente em %1$s + Adicionado recentemente + Gravado Recentemente + Lançado Recentemente + Recomendado + Gravar Programa + Gravar Série + Desmarcar como favorito + Reiniciar + Resumir + Guardar + + Pesquisar + A pesquisar… + Selecionar Servidor + Selecionar Utilizador + Mostrar informação de Depuração + Mostrar o próximo + Mostrar + Aleatório + Saltar + Data Adicionado + Data Episódio Adicionado + Data Reproduzido + Data de Lançamento + Nome + Aleatória + Estúdios + Submeter + A transferência está a demorar muito, pode ser necessário reiniciar a reprodução + Legenda + Legendas + Sugestões + Mudar Servidores + Mudar + Mais Votados Não Assistidos + Trailer + + Trailers + + + + Programação DVR + Guia + Temporada + Temporadas + Séries TV + Interface + Desconhecido + Atualizações + Versão + Escala do Vídeo + Vídeo + Vídeos + Ver em directo + %1$d anos + Reproduzir com transcodificação + Mostrar Relógio + Ordem de emissão + Criar nova lista de reprodução + Adicionar a lista de reprodução + Pausar com um clique + Pressione o centro do D-Pad para pausar/reproduzir + Itálico + Fonte + Fundo + Êxito + Classificação Parental + Duração + Extras + + Outro + + + + + Nos Bastidores + + + + + Músicas-tema + + + + + Vídeos Temáticos + + + + + Clipes + + + + + Cenas Excluídas + + + + + Entrevistas + + + + + Cenas + + + + + Amostras + + + + + Curta-metragens + + + + + Curtos + + + + + %s transferido + %s transferidos + %s transferidos + + + %d hora + %d horas + %d horas + + + %d item + %d itens + %d itens + + + %d segundo + %d segundos + %d segundos + + Publicidade + Caminho + Dispositivo suporta AC3/Dolby Digital + Definições Avançadas + Interface Avançado + Tema da Aplicação + Verificar automaticamente se há atualizações + Atraso antes de reproduzir o próximo + Reproduzir automaticamente o próximo + Verificar se há atualizações + Aplica-se apenas a Séries de TV + + Reprodução direta de legendas ASS + Reprodução direta de legendas PGS + Fazer sempre a mistura para estéreo + Usar o módulo decodificador FFmpeg + Escala de conteúdo predefinida + Instalar atualização + Versão instalada + Máximo de itens por fila no inicio + Escolha os itens padrão a serem exibidos, os demais serão ocultados + Personalizar itens da barra de navegação + Clique para mudar de página + Alternar páginas da barra de navegação ao focar + Protecção Contra Inatividade + Reproduzir música temática + Substituições de reprodução + Lembrar separadores selecionados + Permitir voltar a ver em ”Continuar a ver\" + Passos da barra de pesquisa + Útil para depuração + Enviar registos da aplicação para o servidor atual + Tentará enviar para o último servidor conectado + Enviar Relatórios de Falhas + Definições + Retroceder ao retomar a reprodução + Retroceder + Comportamento de ignorar anúncios + Avançar + Comportamento de saltar introdução + Comportamento de saltar créditos finais + Comportamento de saltar pré-visualizações + Comportamento de saltar recapitulação + Atualização disponível + URL usado para verificar atualizações da aplicação + URL de atualizações + Tamanho da fonte + Cor da fonte + Opacidade da fonte + Estilo da borda + Cor da borda + Opacidade do fundo + Estilo do fundo + Estilo da legenda + Cor do fundo + Repor + Fonte em negrito + Motor de reprodução + MPV: Usa descodificação por hardware + Desative se ocorrerem falhas + Opções do MPV + Opções do ExoPlayer + Saltar Segmento + Saltar Publicidade + Saltar pré-visualização + Saltar Recapitulação + Saltar Créditos Finais + Saltar Introdução + Reproduzido + Filtrar + Ano + Década + Remover + Dolby Vision + Dolby Atmos + MPV: Usar gpu-next + Editar mpv.conf + Anular alterações? + Margem + Registro detalhado + Inserir PIN + Entrar automaticamente + Iniciar sessão através do servidor + Pressione centro para confirmar + Exigir PIN para o perfil + Confirmar PIN + Incorrecto + O PIN deve ter 4 dígitos ou mais + Irá remover o PIN + Tamanho da cache de imagens no disco (MB) + Ver opções + Colunas + Espaçamento + Proporção da imagem + Mostrar detalhes + Tipo de imagem From 56416da48665c7da26c1fcc59f207ca1225c3581 Mon Sep 17 00:00:00 2001 From: ymir Date: Fri, 12 Dec 2025 08:45:12 +0000 Subject: [PATCH 078/124] Translated using Weblate (French) Currently translated at 100.0% (272 of 272 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 36dff01f..79789fc7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -306,7 +306,7 @@ Marge MPV: Utiliser gpu-next Éditer mpv.conf - Annuler les modifications? + Annuler les modifications ? Journalisation détaillée Saisir le code PIN Connexion automatique From 8b20c392f33ec76f84aba72c5577523aaa46a207 Mon Sep 17 00:00:00 2001 From: Vistaus Date: Fri, 12 Dec 2025 09:27:38 +0000 Subject: [PATCH 079/124] Translated using Weblate (Dutch) Currently translated at 100.0% (272 of 272 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2bfd70e5..2deafc6a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -315,5 +315,5 @@ Automatisch Gebruikersnaam/wachtwoord gebruiken Inloggen - Titels weergeven + Titels tonen From 1c890f64aa2f2b1c40777bd6a0298021f277a8b9 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 12 Dec 2025 07:06:54 +0000 Subject: [PATCH 080/124] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (272 of 272 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 302 ++++++++++++++++++++- 1 file changed, 301 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 55344e51..4d1b3c31 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,3 +1,303 @@ - \ No newline at end of file + 关于 + 正在录制 + 收藏 + 添加服务器 + 添加用户 + 音频 + 出生地 + 比特率 + 出生日期 + 取消录制 + 取消剧集录制 + 取消 + 章节 + 选择 %1$s + 清除图片缓存 + 合集 + 合集 + 广告 + 社区评分 + 发生错误!请按下按钮将日志发送到您的服务器。 + 确认 + 继续观看 + 影评人评分 + %.1f 秒 + 默认 + 删除 + 去世 + 由 %1$s 执导 + 导演 + 已禁用 + 已发现的服务器 + + 正在下载… + 已启用 + 输入服务器 IP 或 URL + 剧集 + 加载合集 %1$s 时出错 + 外部 + 收藏 + 大小 + 强制 + 类型 + 前往剧集 + 前往 + 隐藏播放控件 + 隐藏调试信息 + 隐藏 + 首页 + 立即 + 片头 + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + 媒体库 + 许可信息 + 电视直播 + 正在加载… + 将整部剧标记为已观看? + 将整部剧标记为未观看? + 标记为未观看 + 标记为已观看 + 最大比特率 + 类似推荐 + 更多 + 电影 + 名称 + 即将播放 + 无数据 + 无结果 + 无计划录制 + 无可用更新 + + 片尾 + 路径 + 演职人员 + 播放次数 + 从此处播放 + 播放 + 显示播放调试信息 + 播放速度 + 播放 + 播放列表 + 播放列表 + 预览 + 用户配置设置 + 队列 + 前情提要 + 最近添加至 %1$s + 最近添加 + 最近录制 + 最近发行 + 推荐 + 录制节目 + 录制剧集 + 取消收藏 + 从头播放 + 继续播放 + 保存 + + 搜索 + 正在搜索… + 选择服务器 + 选择用户 + 显示调试信息 + 显示即将播放 + 显示 + 随机播放 + 跳过 + 添加日期 + 剧集添加日期 + 播放日期 + 发行日期 + 名称 + 随机 + 制片公司 + 提交 + 下载时间过长,可能需要重新开始播放 + 字幕 + 字幕 + 建议 + 切换服务器 + 切换 + 高分未看 + 预告片 + + 预告片 + + DVR 计划 + 指南 + + + 电视节目 + 界面 + 未知 + 更新 + 版本 + 视频比例 + 视频 + 视频 + 观看直播 + %1$d 岁 + 转码播放 + 显示时钟 + 剧集播出顺序 + 创建新的播放列表 + 添加到播放列表 + 一键暂停 + 按方向键中心暂停/播放 + 斜体 + 字体 + 背景 + 成功 + 年龄分级 + 播放时长 + 附加内容 + + 其他 + + + 幕后花絮 + + + 主题曲 + + + 主题视频 + + + 片段 + + + 删减片段 + + + 访谈 + + + %s 次下载 + + + %d 小时 + + + %d 个项目 + + + %d 秒 + + + 场景 + + + 样片 + + + 特辑 + + + 短片 + + 设备支持 AC3/杜比数字 + 高级设置 + 高级用户界面 + 应用程序主题 + 自动检查更新 + 播放下一项前的延迟时间 + 自动播放下一项 + 检查更新 + 仅适用于电视剧 + + 直接播放 ASS 字幕 + 直接播放 PGS 字幕 + 始终降混为立体声 + 使用 FFmpeg 解码模块 + 默认内容比例 + 安装更新 + 安装版本 + 首页每行最大项目数 + 选择默认显示的项目,其他项目将隐藏 + 自定义抽屉式导航栏项目 + 点击切换页面 + 获得焦点时切换抽屉式导航栏页面 + 防休眠保护 + 播放主题音乐 + 播放覆盖设置 + 记住所选标签页 + 在即将播放中重复观看 + 进度条步进值 + 用于调试 + 将应用日志发送到当前服务器 + 将尝试发送到最后连接的服务器 + 发送崩溃报告 + 设置 + 恢复播放时快退 + 快退 + 跳过广告方式 + 快进 + 跳过片头方式 + 跳过片尾方式 + 跳过预览方式 + 跳过前情提要方式 + 有可用更新 + 用于检查应用更新的 URL + 更新 URL + 字体大小 + 字体颜色 + 字体不透明度 + 边缘样式 + 边缘颜色 + 背景不透明度 + 背景样式 + 字幕样式 + 背景颜色 + 重置 + 粗体 + 播放后端 + MPV:使用硬件解码 + 如果遇到崩溃,请禁用 + MPV 选项 + ExoPlayer 选项 + 跳过片段 + 跳过广告 + 跳过预览 + 跳过前情提要 + 跳过片尾 + 跳过片头 + 已观看 + 筛选 + 年份 + 年代 + 移除 + 杜比视界 + 杜比全景声 + MPV:使用 gpu-next + 编辑 mpv.conf + 舍弃更改? + 边距 + 详细日志记录 + 输入 PIN 码 + 自动登录 + 通过服务器登录 + 按中心键确认 + 配置需要 PIN 码 + 确认 PIN 码 + 错误 + PIN 码必须至少包含 4 个字符 + 将移除 PIN 码 + 图片磁盘缓存大小(MB) + 查看选项 + + 间距 + 宽高比 + 显示详情 + 图片类型 + + 客串演员 + 边缘尺寸 + 刷新率切换 + 自动 + 使用用户名/密码 + 登录 + 显示标题 + From eef886b2853c9a42e8fbc9d5b626fce103215037 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Fri, 12 Dec 2025 20:05:38 +0000 Subject: [PATCH 081/124] Translated using Weblate (Portuguese) Currently translated at 99.3% (304 of 306 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 121 +++++++++++++++++-------- 1 file changed, 81 insertions(+), 40 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6757e505..6f4f4029 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ - Sobre + O Wholphin Favorito Adicionar Servidor Adicionar Utilizador @@ -19,7 +19,7 @@ Realizado por %1$s Realizador Desativado - A transferir + A transferir… Activado Episódios Externo @@ -32,12 +32,12 @@ Bitrate Nascido Cancelar Gravação da Série - Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor + Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor. Continuar a ver Morreu Servidores Detectados - Introduz IP e URL do Servidor + Introduza o IP ou URL do Servidor Erro ao carregar a colecção %1$s Géneros Ir para a Série @@ -52,8 +52,8 @@ Informação da licença TV em Directo A carregar… - Marcar série como vista? - Marcar toda a série como não vista? + Marcar toda a série como reproduzida? + Marcar toda a série como não reproduzida? Marcar como não visto Marcar como visto Bitrate Máximo @@ -61,7 +61,7 @@ Mais Filmes Nome - Próximo + A Seguir Sem dados Sem resultados Sem gravações programadas @@ -70,7 +70,7 @@ Créditos finais Pessoas Reproduções - Reproduzir daqui + Reproduzir a partir daqui Reproduzir Mostrar depuração da reprodução Velocidade da Reprodução @@ -78,8 +78,8 @@ Lista de Reprodução Listas de Reprodução Pré-visualização - Classificação da Comunidade - Classificação da Crítica + Avaliação da Comunidade + Avaliação da Crítica Definições de Utilizador Fila Recapitulação @@ -100,7 +100,7 @@ Selecionar Servidor Selecionar Utilizador Mostrar informação de Depuração - Mostrar o próximo + Mostrar \'A Seguir\' Mostrar Aleatório Saltar @@ -122,8 +122,8 @@ Trailer Trailers - - + Trailers + Trailers Programação DVR Guia @@ -155,58 +155,58 @@ Extras Outro - - + + Nos Bastidores - - + + Músicas-tema - - + + Vídeos Temáticos - - + + Clipes - - + + Cenas Excluídas - - + + Entrevistas - - + + Cenas - - + + Amostras - - + + Curta-metragens - - + + Curtos - - + + %s transferido @@ -229,20 +229,20 @@ %d segundos Publicidade - Caminho + Localização Dispositivo suporta AC3/Dolby Digital Definições Avançadas Interface Avançado Tema da Aplicação Verificar automaticamente se há atualizações - Atraso antes de reproduzir o próximo + Atraso antes de reproduzir \'A Seguir\' Reproduzir automaticamente o próximo Verificar se há atualizações Aplica-se apenas a Séries de TV - + Reprodução direta de legendas ASS Reprodução direta de legendas PGS - Fazer sempre a mistura para estéreo + Conversão sempre em estéreo Usar o módulo decodificador FFmpeg Escala de conteúdo predefinida Instalar atualização @@ -256,7 +256,7 @@ Reproduzir música temática Substituições de reprodução Lembrar separadores selecionados - Permitir voltar a ver em ”Continuar a ver\" + Activar a revisão em \'A seguir\' Passos da barra de pesquisa Útil para depuração Enviar registos da aplicação para o servidor atual @@ -324,4 +324,45 @@ Proporção da imagem Mostrar detalhes Tipo de imagem + + Estrelas convidadas + Dimensão da borda + Mudança da taxa de atualização + Automático + Usar utilizador/palavra-passe + Entrar + Mostrar títulos + Informação da Media + Geral + Título + Codec + Perfil + Nível + Resolução + Anamórfico + Intercalado + Taxa de fotogramas + Profundidade de bit + Gama de video + Tipo de gama de vídeo + Espaço de cor + Transferência de cor + Cores primárias + Formato dos pixeis + Quadros de referência + NAL + Idioma + Disposição + Canais + Taxa de amostragem + AVC + Sim + Não + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz From 4b3efc097e69639e980ebe299ff077a93b2d97db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Fri, 12 Dec 2025 21:41:01 +0000 Subject: [PATCH 082/124] Translated using Weblate (Estonian) Currently translated at 100.0% (306 of 306 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 873bdbbe..be59d972 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -315,4 +315,39 @@ Automaatne Pruugi kasutajanime ja salasõna Logi sisse + Näita pealkirju + Meediumi teave + Üldist + Konteiner + Pealkiri + Koodek + Profiil + Tase + Resolutsioon + Jah + Ei + HDR + HDR10 + bitti + HLG + Hz + HDR10+ + SDR + AVC + Anamorfne + Ülereaskaneering + Kaadrisagedus + Bitisügavus + Video bitisügavus + Video bitisügavuse tüüp + Värviruum + Värvide ülekanne + Keel + Paigutus + Kanaleid + Diskreetimissagedus + Põhivärvused + Pikslivorming + Aluskaadreid + NAL From b8891eee3523614602b27fe105d3923cdda144b7 Mon Sep 17 00:00:00 2001 From: Geekice Date: Sat, 13 Dec 2025 00:16:49 +0000 Subject: [PATCH 083/124] Translated using Weblate (Italian) Currently translated at 97.0% (297 of 306 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ed33256b..512e8e0a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -331,4 +331,30 @@ Automatico Cambio frequenza di aggiornamento Usa nome utente/password + Informazioni sui media + Generali + Titolo + Codifica + Profilo + Livello + Risoluzione + Anamorfico + Interlacciato + Range del video + Spazio colore + trasferimento colore + Colori primari + Formato pixel + Lingua + Canali + Si + No + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Mostra titolo From 9fde9c70edcc378af2f4fea53c393589bc2e26d5 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Sat, 13 Dec 2025 01:57:37 +0000 Subject: [PATCH 084/124] Translated using Weblate (Portuguese) Currently translated at 99.6% (305 of 306 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6f4f4029..3ef0ce7f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -365,4 +365,5 @@ HLG bit Hz + Formato From 1caa7b32c424c9c9d8e3eabfaaf36c47a12a5477 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sat, 13 Dec 2025 03:04:10 +0000 Subject: [PATCH 085/124] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 91.5% (280 of 306 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4d1b3c31..ab8dae21 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -300,4 +300,12 @@ 使用用户名/密码 登录 显示标题 + 媒体信息 + 常规 + 容器格式 + 标题 + 编解码器 + 配置 + 等级 + 分辨率 From 1c38979c48287090b1bf7dad4cc10a5e99d05d64 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Sat, 13 Dec 2025 16:05:54 +0000 Subject: [PATCH 086/124] Translated using Weblate (Portuguese) Currently translated at 100.0% (310 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 56 ++++++++++++++------------ 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3ef0ce7f..61fb336b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -141,7 +141,7 @@ %1$d anos Reproduzir com transcodificação Mostrar Relógio - Ordem de emissão + Ordem de Emissão Criar nova lista de reprodução Adicionar a lista de reprodução Pausar com um clique @@ -155,58 +155,58 @@ Extras Outro - - + Outros + Outros Nos Bastidores - - + Nos Bastidores + Nos Bastidores - Músicas-tema - - + Músicas temáticas + Músicas temáticas + Músicas temáticas Vídeos Temáticos - - + Vídeos Temáticos + Vídeos Temáticos Clipes - - + Clipes + Clipes Cenas Excluídas - - + Cenas Excluídas + Cenas Excluídas Entrevistas - - + Entrevistas + Entrevistas Cenas - - + Cenas + Cenas Amostras - - + Amostras + Amostras Curta-metragens - - + Curta-metragens + Curta-metragens Curtos - - + Curtos + Curtos %s transferido @@ -286,7 +286,7 @@ Repor Fonte em negrito Motor de reprodução - MPV: Usa descodificação por hardware + MPV: Usar descodificação por hardware Desative se ocorrerem falhas Opções do MPV Opções do ExoPlayer @@ -332,7 +332,7 @@ Usar utilizador/palavra-passe Entrar Mostrar títulos - Informação da Media + Informação da Multimédia Geral Título Codec @@ -366,4 +366,8 @@ bit Hz Formato + Repetir + Mostrar primeiro os canais favoritos + Ordenar canais por visualizações recentes + Codificação de cores para os programas From 5dbc2049cbc0011662c8594721768ea96e8e35c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sat, 13 Dec 2025 07:41:10 +0000 Subject: [PATCH 087/124] Translated using Weblate (Estonian) Currently translated at 100.0% (310 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index be59d972..c29a70ad 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -350,4 +350,8 @@ Pikslivorming Aluskaadreid NAL + Kordus + Näita esmalt lemmikkanaleid + Järjesta kanalid viimase vaatamise alusel + Märgi programmid värvidega From dcd9560113d46e2a0984475580fe03012dea73de Mon Sep 17 00:00:00 2001 From: opakholis Date: Sat, 13 Dec 2025 18:49:27 +0000 Subject: [PATCH 088/124] Translated using Weblate (Indonesian) Currently translated at 84.1% (261 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index cf05326b..ce460a3c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -271,4 +271,22 @@ Hapus Dolby Vision Dolby Atmos + Informasi Media + MPV: Gunakan gpu-next + Edit mpv.conf + Batalkan perubahan? + Masukkan PIN + Masuk secara otomatis + Masuk via server + Tekan tengah untuk konfirmasi + Konfirmasi PIN + Salah + PIN harus 4 digit atau lebih + Ukuran cache disk gambar (MB) + Kolom + Rasio Aspek + Tampilkan detail + Jenis gambar + + Pemeran Tamu From c0a68c5daaa7e6e29f093112c5f771a6415b0db3 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Sat, 13 Dec 2025 16:30:37 +0000 Subject: [PATCH 089/124] Translated using Weblate (French) Currently translated at 98.0% (304 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 79789fc7..3066213a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -332,4 +332,36 @@ Connexion Afficher les titres Type d\'image + Informations sur le média + Général + Conteneur + Titre + Codec + Profil + Niveau + Résolution + Anamorphique + Entrelacé + Fréquence d\'images + Profondeur de bits + Plage vidéo + Type de plage vidéo + Espace colorimétrique + Transfert des couleurs + Format de pixel + Langue + Mise en page + Chaînes + Taux d’échantillonnage + AVC + Oui + Non + SDR + HDR + HDR10 + HDR10+ + bit + Hz + Répéter + Afficher les chaînes favorites en premier From a68e9d8eb941aa32ad179783003cf4061bf681d1 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sat, 13 Dec 2025 11:26:17 +0000 Subject: [PATCH 090/124] Translated using Weblate (Italian) Currently translated at 100.0% (310 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 512e8e0a..d2b90b54 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -357,4 +357,17 @@ bit Hz Mostra titolo + AVC + Frequenza fotogrammi + Profondità bit + Contenitore + Tipo di gamma video + NAL + Frequenza di campionamento + Frame di riferimento + Disposizione + Ripeti + Mostra prima i canali preferiti + Ordina canali per ultimo guardato + Codifica colori per i programmi From 79b84cfe6b9622c748a0bf905ffc717c119a0f4f Mon Sep 17 00:00:00 2001 From: Vistaus Date: Sat, 13 Dec 2025 09:04:42 +0000 Subject: [PATCH 091/124] Translated using Weblate (Dutch) Currently translated at 100.0% (310 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2deafc6a..be440d27 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -316,4 +316,42 @@ Gebruikersnaam/wachtwoord gebruiken Inloggen Titels tonen + Media-informatie + Algemeen + Container + Titel + Codec + Profiel + Niveau + Resolutie + Anamorfisch + Met interlatie + Framesnelheid + Bitdiepte + Videobereik + Soort videobereik + Kleurruimte + Kleuroverdracht + Voorkeurskleuren + Pixelformaat + Referentieframes + NAL + Taal + Indeling + Kanalen + Samplesnelheid + AVC + Ja + Nee + Sdr + Hdr + Hdr10 + Hdr10+ + Hlg + bit + Hz + Herhalen + Favoriete kanalen eerst tonen + Kanalen sorteren op onlangs bekeken + Programma\'s voorzien van kleurcode From cae07b0acddea5b4d66efa14a0d2b270e372fc7e Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sat, 13 Dec 2025 03:24:59 +0000 Subject: [PATCH 092/124] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (310 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ab8dae21..d76d17a5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -308,4 +308,34 @@ 配置 等级 分辨率 + 变形宽银幕 + 隔行扫描 + 帧速率 + 位深度 + 视频范围 + 视频范围类型 + 色彩空间 + 色彩传递 + 色彩原色 + 像素格式 + 参考帧数 + NAL + 语言 + 布局 + 频道 + 采样率 + AVC + + + SDR + HDR + HDR10 + HDR10+ + HLG + + Hz + 重复 + 优先显示收藏频道 + 按最近观看的频道排序 + 节目颜色标记 From d0000e48119617cc670f3d83265899fcbaa9cf07 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Sat, 13 Dec 2025 23:23:32 +0000 Subject: [PATCH 093/124] Translated using Weblate (French) Currently translated at 98.7% (306 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3066213a..6077604d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -364,4 +364,6 @@ Hz Répéter Afficher les chaînes favorites en premier + NAL + HLG From 6a7a2a5c79e596188dbb65e7017bf2c9af9658a6 Mon Sep 17 00:00:00 2001 From: opakholis Date: Sun, 14 Dec 2025 02:38:21 +0000 Subject: [PATCH 094/124] Translated using Weblate (Indonesian) Currently translated at 98.0% (304 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ce460a3c..fc1f6866 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -289,4 +289,47 @@ Jenis gambar Pemeran Tamu + Anamorfik + Interlace + Framerate + Kedalaman Bit + Rentang Video + Jenis rentang video + Color space + Format piksel + NAL + Bahasa + AVC + Ya + Tidak + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Tampilkan judul + Ulangi + Tampilkan saluran favorit dahulu + Urutkan saluran berdasarkan yang baru ditonton + Program kode warna + Pencatatan rinci + Membutuhkan PIN untuk profil + PIN akan dihapus + Opsi tampilan + Ukuran garis tepi + Otomatis + Gunakan nama pengguna/kata sandi + Batas tepi + Masuk + Umum + Kontainer + Judul + Codec + Profil + Level + Resolusi + Sampel rate + Jarak From 1497a0718b8432734fcebabe3ce9c5e33e78a9f4 Mon Sep 17 00:00:00 2001 From: Rand0mB0y Date: Sun, 14 Dec 2025 17:51:26 +0000 Subject: [PATCH 095/124] Added translation using Weblate (Greek) --- app/src/main/res/values-el/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-el/strings.xml diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-el/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 7da9189b4d927fdffa0fcfaaf90b3c14891ae5bc Mon Sep 17 00:00:00 2001 From: Rand0mB0y Date: Sun, 14 Dec 2025 22:44:49 +0000 Subject: [PATCH 096/124] Translated using Weblate (Greek) Currently translated at 20.3% (63 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/el/ --- app/src/main/res/values-el/strings.xml | 71 +++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 55344e51..812ce8e3 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,3 +1,72 @@ - \ No newline at end of file + Ταινίες + Προφίλ + Αρχική + + Γλώσσα + Θέμα εφαρμογής + Υπότιτλοι + Υπότιτλος + Κανάλια + Ναι + Όχι + Προηγμένες ρυθμίσεις + Γεννήθηκε + Τόπος γέννησης + Ήχος + Το βίντεο + Τα βίντεο + Παρασκήνιο + + Θεματικά τραγούδια + + + + Θεματικά βίντεο + + + Επόμενο + Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\' + Γραμματοσειρά + Μέγεθος Κειμένου + Χρώμα Κειμένου + Φωτεινότητα κειμένου + Βιβλιοθήκης + Σειρές + Είδη + Συλλογή + Συλλογές + Αγαπημένο + Αγαπημένα + Προτάσεις + Σκηνοθέτης + Στούντιο + SDR + + Περισσότερα Σαν Αυτό + Επεισόδια + #ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ + + %s λήψη + %s λήψεις + + λήψη… + Διαδρομή + Όνομα + Δοχείο + Μέγεθος + Κωδικοποιητής + AVC + Ρυθμός bit + Βάθος bit + bit + Εξαναγκασμός + Σχέδιο + Προεπιλογή + Εξωτερικό + Προσθήκη χρήστη + Ακύρωση + Κεφάλαια + Διάλεξε %1$s + From 131b44c439120157e41efcdc29a439ed97356621 Mon Sep 17 00:00:00 2001 From: Codeberg Translate Date: Mon, 15 Dec 2025 00:04:07 +0000 Subject: [PATCH 097/124] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-el/strings.xml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 812ce8e3..40c36de9 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -3,7 +3,6 @@ Ταινίες Προφίλ Αρχική - Γλώσσα Θέμα εφαρμογής Υπότιτλοι @@ -20,11 +19,11 @@ Παρασκήνιο Θεματικά τραγούδια - + Θεματικά βίντεο - + Επόμενο Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\' From f2e3ae2447c3ca53f51c35cf74e81070326ee3f9 Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 15 Dec 2025 06:31:35 +0000 Subject: [PATCH 098/124] Translated using Weblate (French) Currently translated at 100.0% (310 of 310 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6077604d..ba3fb676 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -366,4 +366,8 @@ Afficher les chaînes favorites en premier NAL HLG + Couleurs primaires + Images de référence + Trier les chaînes par date de visionnage + Classer les programmes par code couleur From cb62344e532dca1d74f151dd9b671f5705ea1ff1 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 15 Dec 2025 17:09:19 +0000 Subject: [PATCH 099/124] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 87.1% (271 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7e65e85b..ef0da683 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -272,4 +272,31 @@ 邊距 詳細日誌記錄 使用 PIN 鍵登入 + 媒體資訊 + MPV:使用 gpu-next + 編輯 mpv.conf + 放棄變更? + 輸入 PIN 碼 + 自動登入 + 透過伺服器登入 + 按下中間鍵確認 + 確認 PIN 碼 + 錯誤 + PIN 碼至少需 4 碼 + 將移除 PIN 碼 + 圖片快取大小 (MB) + 檢視選項 + 欄數 + 間距 + 長寬比 + 詳細資訊 + 圖片類型 + + 客串演員 + 邊框大小 + 畫面更新率切換 + 自動 + 使用帳號/密碼 + 登入 + 字幕同步 From a67bd0f83f887d8575ac427a1ea44b02cf3f0340 Mon Sep 17 00:00:00 2001 From: efko Date: Mon, 15 Dec 2025 16:36:45 +0000 Subject: [PATCH 100/124] Translated using Weblate (Greek) Currently translated at 44.6% (139 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/el/ --- app/src/main/res/values-el/strings.xml | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 40c36de9..a26ab567 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -68,4 +68,80 @@ Ακύρωση Κεφάλαια Διάλεξε %1$s + Προσθήκη διακομιστή + Ακύρωση καταχώρησης + Ακύρωση σειρά καταχώρησης + Καθαρισμός μνήμης εικόνας + Διαφήμιση + Αξιολόγηση κοινού + Ένα σφάλμα εμφανίστηκε! Πατήστε το κομπί για να στείλετε αρχείο καταγραφής στο διακομιστή. + Επικύρωση + Συνεχίστε να βλέπετε + Κριτική + %.1f δευτερόλεπτα + Διαγραφή + Πέθανε + Σκηνοθετήθηκε από %1$s + Απενεργοποιημένος + Ενεργοποιημένος + Καταχώρηση διακομιστή IP or URL + Σφάλμα φόρτωσης συλλογής %1$s + Πηγαίνετε στις σειρές + Πηγαίνετε στο + Απόκρυψη χειρισμού αναπαραγωγής + Απόκρυψη πληροφοριών εντοπισμού σφαλμάτων + Απόκρυψη + Άμεσος + Εισαγωγή + Πληροφορίες άδειας + Ζωντανή Τηλεόραση + Φόρτωση… + Επισήμανση ολόκληρης σειράς ως αναπαραγμένης? + Επισήμανση ολόκληρης σειράς ως μη αναπαραγμένης? + Επισήμανση ως μη ολοκληρωμένο + Επισήμανση ως ολοκληρωμένο + Μέγιστος ρυθμός μετάδοσης δεδομένων + Περισσότερα + Χωρίς δεδομένα + Χωρίς αποτελέσματα + Χωρίς προγραμματισμένες καταχωρήσεις + Χωρίς διαθέσιμη ενημέρωση + Έναρξη από εδώ + Έναρξη + Άτομα + Ταχύτητα αναπαραγωγής + Αναπαραγωγή + Λίστα αναπαραγωγής + Λίστες αναπαραγωγής + Παρουσίαση + Ρυθμίσεις προφίλ χρήστη + Ουρά + Ανακεφαλαίωση + Πρόσφατα πρόσθετα σε %1$s + Πρόσφατα πρόσθετα + Πρόσφατα καταγεγραμμένο + Πρόσφατα κυκλοφόρησε + Προτεινόμενο + Πρόγραμμα καταγραφής + Σειρά καταγραφής + Κατάργηση από τα αγαπημένα + Επανεκκίνηση + Περίληψη + Αποθήκευση + Αναζήτηση + Αναζήτηση… + Επιλογή διακομιστή + Επιλογή χρήστη + Εμφάνιση πληροφοριών εντοπισμού σφαλμάτων + Εμφάνιση επόμενου + Εμφάνιση + Τυχαία αναπαραγωγή + Αναπήδηση + Προσθήκη ημερομηνίας + Προσθήκη ημερομηνίας επεισοδίου + Ημερομηνία αναπαραγωγής + Ημερομηνία κυκλοφορίας + Όνομα + Τυχαία + Υποβολή From 5f3a7ebff138c3c4d05adc7f81435959c83f20a9 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 15 Dec 2025 18:15:48 +0000 Subject: [PATCH 101/124] Translated using Weblate (Portuguese) Currently translated at 100.0% (311 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 61fb336b..89c4b105 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -370,4 +370,5 @@ Mostrar primeiro os canais favoritos Ordenar canais por visualizações recentes Codificação de cores para os programas + Atraso da legenda From da12095434f158d0b39656f5eb104adb6e8cd2fe Mon Sep 17 00:00:00 2001 From: arcker95 Date: Mon, 15 Dec 2025 18:57:56 +0000 Subject: [PATCH 102/124] Translated using Weblate (Italian) Currently translated at 100.0% (311 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d2b90b54..faf958b2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -356,7 +356,7 @@ HLG bit Hz - Mostra titolo + Mostra titoli AVC Frequenza fotogrammi Profondità bit @@ -370,4 +370,5 @@ Mostra prima i canali preferiti Ordina canali per ultimo guardato Codifica colori per i programmi + Ritardo sottotitoli From e816151651bf9ac43c042252e09039b58009a89f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 16 Dec 2025 01:00:12 +0000 Subject: [PATCH 103/124] Translated using Weblate (Estonian) Currently translated at 100.0% (311 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index c29a70ad..0126c1f6 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -354,4 +354,5 @@ Näita esmalt lemmikkanaleid Järjesta kanalid viimase vaatamise alusel Märgi programmid värvidega + Subtiitrite viivitus From 5ca5826cb3b0e9df8279228c2bb2e1fa99381775 Mon Sep 17 00:00:00 2001 From: DragoWing Date: Mon, 15 Dec 2025 22:40:04 +0000 Subject: [PATCH 104/124] Translated using Weblate (Dutch) Currently translated at 99.3% (309 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index be440d27..7068d379 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -311,7 +311,7 @@ Gastrollen Randbreedte - Andere ververssnelheid toepassen + Automatische ververssnelheid toepassen Automatisch Gebruikersnaam/wachtwoord gebruiken Inloggen From 461caaa8fdcb060690ac53fd935025afbb60330e Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Tue, 16 Dec 2025 02:30:16 +0000 Subject: [PATCH 105/124] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (311 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d76d17a5..2d317635 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -338,4 +338,5 @@ 优先显示收藏频道 按最近观看的频道排序 节目颜色标记 + 字幕延迟 From c94dcbf3dd34634dcde3e402c7795f9e006b7c2e Mon Sep 17 00:00:00 2001 From: efko Date: Tue, 16 Dec 2025 15:05:07 +0000 Subject: [PATCH 106/124] Translated using Weblate (Greek) Currently translated at 57.8% (180 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/el/ --- app/src/main/res/values-el/strings.xml | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index a26ab567..4d7b297d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -144,4 +144,69 @@ Όνομα Τυχαία Υποβολή + Ενεργές καταγραφές + Κανένας + Εμφάνιση πληροφοριών αναπαραγωγής σφαλμάτων + Λήψη αρχείων διαρκεί πολύ χρονο, ίσως χρειαστεί να επανεκκινήσετε την αναπαραγωγή + Αλλαγή διακομιστή + Αντικατάσταση + Κορυφαίες βαθμολογίες σε ταινίες που δεν έχω δει + Οδηγός + Σεζόν + Περισσότερες Σεζόν + Άγνωστο + Αναβαθμίσεις + Εκδοχή + Κλίμακα βίντεο + Ζωντανή μετάδοση + Πληροφορίες μέσων + Εμφάνιση ώρας + Δημιουργία νέας λίστας αναπαραγωγής + Προσθήκη στη λίστα αναπαραγωγής + Παύση με ένα κουμπί + Πλάγια γραμματοσειρά + Επιτυχής + Γονεική αξιολόγηση + Χρόνος εκτέλεσης + Επιπρόσθετα + + Άλλο + + + + Παρασκήνια + + + + Αποσπάσματα + + + + Διαγραμμένες σκηνές + + + + Συνεντεύξεις + + + + Σκηνές + + + + Δείγματα + + + + Χαρακτηριστικά + + + Αυτόματος έλεγχος για ενημερώσεις + Καθυστέρηση πριν την έναρξη του επόμενου + Αυτόματη έναρξη επόμενου + Έλεχγος για ενημερώσεις + Ισχύει μόνο για τηλεοπτικές σειρές + Προεπιλεγμένη αναπαραγωγή περιεχομένου + Εγκατάσταση ενημέρωσης + Εγκατεστημένη εκδοχή From 0ed15a32515c86edbab21124a0e378b49f81dcf5 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Tue, 16 Dec 2025 22:20:54 +0000 Subject: [PATCH 107/124] Added translation using Weblate (Norwegian Nynorsk) --- app/src/main/res/values-nn/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-nn/strings.xml diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-nn/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From e19cf0de2c56e443cd91c63e75c1ddbfe772f3ae Mon Sep 17 00:00:00 2001 From: oyvhov Date: Tue, 16 Dec 2025 22:27:41 +0000 Subject: [PATCH 108/124] Translated using Weblate (Norwegian Nynorsk) Currently translated at 8.0% (25 of 311 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 55344e51..fd8c6cce 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -1,3 +1,24 @@ - \ No newline at end of file + Om + Aktive opptak + Favoritt + Legg til Server + Legg til Brukar + Lyd + Fødestad + Bitrate + Født + Avbryt opptak + Avbryt opptak av serie + Avbryt + Kapittel + Vel %1$s + Samling + Samlingar + Bekreft + Standard + Slett + Dødd + Regissert av %1$s + From badb56880bd00addd2cd02fcf51c91abab6d6bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Wed, 17 Dec 2025 17:39:16 +0000 Subject: [PATCH 109/124] Translated using Weblate (Estonian) Currently translated at 100.0% (314 of 314 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 0126c1f6..59aa1f7b 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -355,4 +355,7 @@ Järjesta kanalid viimase vaatamise alusel Märgi programmid värvidega Subtiitrite viivitus + Sisesta serveri aadress + Ühtegi serverit ei leidu + Tausta dekoratsioon From 8479d8621dc70e2f11ad6d240e94493ba438fc53 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:00:15 -0500 Subject: [PATCH 110/124] Bump dependency versions, notably media3 (#496) ## Description Bumps dependency versions, notably media3/ExoPlayer to `1.9.0` ### Related issues Fixes #108 Fixes #355 Fixes #479 --- .gitignore | 4 +++- .../github/damontecres/wholphin/util/mpv/MpvPlayer.kt | 8 ++++++++ gradle/libs.versions.toml | 10 +++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index a5248a5b..695422b8 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,9 @@ app/libs/ ffmpeg_decoder/ app/release/ app/debug/ -<<<<<<< HEAD +.vscode +.kotlin +docs/ # mpv app/src/main/obj/ 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 daef054b..f0b358be 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 @@ -447,6 +447,14 @@ class MpvPlayer( override fun getVolume(): Float = 1f + override fun mute() { + volume = 0f + } + + override fun unmute() { + volume = 1f + } + override fun clearVideoSurface(): Unit = throw UnsupportedOperationException() override fun clearVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b899dc14..79d1ae5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] aboutLibraries = "13.1.0" acra = "5.13.1" -agp = "8.13.1" +agp = "8.13.2" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" @@ -10,7 +10,7 @@ kotlin = "2.2.21" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2025.12.00" +composeBom = "2025.12.01" multiplatformMarkdownRenderer = "0.38.1" programguide = "1.6.0" slf4j2Timber = "1.2" @@ -18,8 +18,8 @@ timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.1" -androidx-media3 = "1.8.0" +activityCompose = "1.12.2" +androidx-media3 = "1.9.0" coil = "3.3.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" @@ -28,7 +28,7 @@ material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.5" datastore = "1.2.0" kotlinx-serialization = "1.9.0" -protobuf-javalite = "4.33.1" +protobuf-javalite = "4.33.2" hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" From 8c0defb772890265f94e7e711d39aa299edceff0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:01:08 -0500 Subject: [PATCH 111/124] Add translations as of 2025-12-17 (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds another batch of translated strings. Thank you to all of the contributors! --------- Co-authored-by: Priit Jõerüüt Co-authored-by: ymir Co-authored-by: ianvm Co-authored-by: Codeberg Translate Co-authored-by: Outbreak2096 Co-authored-by: American_Jesus Co-authored-by: Vistaus Co-authored-by: Geekice Co-authored-by: opakholis Co-authored-by: RabSsS Co-authored-by: arcker95 Co-authored-by: Rand0mB0y Co-authored-by: SimonHung Co-authored-by: efko Co-authored-by: DragoWing Co-authored-by: oyvhov --- app/src/main/res/values-el/strings.xml | 212 +++++++++++++ app/src/main/res/values-et/strings.xml | 43 +++ app/src/main/res/values-fr/strings.xml | 66 ++++ app/src/main/res/values-in/strings.xml | 61 ++++ app/src/main/res/values-it/strings.xml | 40 +++ app/src/main/res/values-nl/strings.xml | 41 ++- app/src/main/res/values-nn/strings.xml | 24 ++ app/src/main/res/values-pt/strings.xml | 309 ++++++++++++++++++- app/src/main/res/values-zh-rCN/strings.xml | 342 +++++++++++++++++++++ app/src/main/res/values-zh-rTW/strings.xml | 27 ++ 10 files changed, 1156 insertions(+), 9 deletions(-) create mode 100644 app/src/main/res/values-el/strings.xml create mode 100644 app/src/main/res/values-nn/strings.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml new file mode 100644 index 00000000..4d7b297d --- /dev/null +++ b/app/src/main/res/values-el/strings.xml @@ -0,0 +1,212 @@ + + + Ταινίες + Προφίλ + Αρχική + Γλώσσα + Θέμα εφαρμογής + Υπότιτλοι + Υπότιτλος + Κανάλια + Ναι + Όχι + Προηγμένες ρυθμίσεις + Γεννήθηκε + Τόπος γέννησης + Ήχος + Το βίντεο + Τα βίντεο + Παρασκήνιο + + Θεματικά τραγούδια + + + + Θεματικά βίντεο + + + Επόμενο + Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\' + Γραμματοσειρά + Μέγεθος Κειμένου + Χρώμα Κειμένου + Φωτεινότητα κειμένου + Βιβλιοθήκης + Σειρές + Είδη + Συλλογή + Συλλογές + Αγαπημένο + Αγαπημένα + Προτάσεις + Σκηνοθέτης + Στούντιο + SDR + + Περισσότερα Σαν Αυτό + Επεισόδια + #ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ + + %s λήψη + %s λήψεις + + λήψη… + Διαδρομή + Όνομα + Δοχείο + Μέγεθος + Κωδικοποιητής + AVC + Ρυθμός bit + Βάθος bit + bit + Εξαναγκασμός + Σχέδιο + Προεπιλογή + Εξωτερικό + Προσθήκη χρήστη + Ακύρωση + Κεφάλαια + Διάλεξε %1$s + Προσθήκη διακομιστή + Ακύρωση καταχώρησης + Ακύρωση σειρά καταχώρησης + Καθαρισμός μνήμης εικόνας + Διαφήμιση + Αξιολόγηση κοινού + Ένα σφάλμα εμφανίστηκε! Πατήστε το κομπί για να στείλετε αρχείο καταγραφής στο διακομιστή. + Επικύρωση + Συνεχίστε να βλέπετε + Κριτική + %.1f δευτερόλεπτα + Διαγραφή + Πέθανε + Σκηνοθετήθηκε από %1$s + Απενεργοποιημένος + Ενεργοποιημένος + Καταχώρηση διακομιστή IP or URL + Σφάλμα φόρτωσης συλλογής %1$s + Πηγαίνετε στις σειρές + Πηγαίνετε στο + Απόκρυψη χειρισμού αναπαραγωγής + Απόκρυψη πληροφοριών εντοπισμού σφαλμάτων + Απόκρυψη + Άμεσος + Εισαγωγή + Πληροφορίες άδειας + Ζωντανή Τηλεόραση + Φόρτωση… + Επισήμανση ολόκληρης σειράς ως αναπαραγμένης? + Επισήμανση ολόκληρης σειράς ως μη αναπαραγμένης? + Επισήμανση ως μη ολοκληρωμένο + Επισήμανση ως ολοκληρωμένο + Μέγιστος ρυθμός μετάδοσης δεδομένων + Περισσότερα + Χωρίς δεδομένα + Χωρίς αποτελέσματα + Χωρίς προγραμματισμένες καταχωρήσεις + Χωρίς διαθέσιμη ενημέρωση + Έναρξη από εδώ + Έναρξη + Άτομα + Ταχύτητα αναπαραγωγής + Αναπαραγωγή + Λίστα αναπαραγωγής + Λίστες αναπαραγωγής + Παρουσίαση + Ρυθμίσεις προφίλ χρήστη + Ουρά + Ανακεφαλαίωση + Πρόσφατα πρόσθετα σε %1$s + Πρόσφατα πρόσθετα + Πρόσφατα καταγεγραμμένο + Πρόσφατα κυκλοφόρησε + Προτεινόμενο + Πρόγραμμα καταγραφής + Σειρά καταγραφής + Κατάργηση από τα αγαπημένα + Επανεκκίνηση + Περίληψη + Αποθήκευση + Αναζήτηση + Αναζήτηση… + Επιλογή διακομιστή + Επιλογή χρήστη + Εμφάνιση πληροφοριών εντοπισμού σφαλμάτων + Εμφάνιση επόμενου + Εμφάνιση + Τυχαία αναπαραγωγή + Αναπήδηση + Προσθήκη ημερομηνίας + Προσθήκη ημερομηνίας επεισοδίου + Ημερομηνία αναπαραγωγής + Ημερομηνία κυκλοφορίας + Όνομα + Τυχαία + Υποβολή + Ενεργές καταγραφές + Κανένας + Εμφάνιση πληροφοριών αναπαραγωγής σφαλμάτων + Λήψη αρχείων διαρκεί πολύ χρονο, ίσως χρειαστεί να επανεκκινήσετε την αναπαραγωγή + Αλλαγή διακομιστή + Αντικατάσταση + Κορυφαίες βαθμολογίες σε ταινίες που δεν έχω δει + Οδηγός + Σεζόν + Περισσότερες Σεζόν + Άγνωστο + Αναβαθμίσεις + Εκδοχή + Κλίμακα βίντεο + Ζωντανή μετάδοση + Πληροφορίες μέσων + Εμφάνιση ώρας + Δημιουργία νέας λίστας αναπαραγωγής + Προσθήκη στη λίστα αναπαραγωγής + Παύση με ένα κουμπί + Πλάγια γραμματοσειρά + Επιτυχής + Γονεική αξιολόγηση + Χρόνος εκτέλεσης + Επιπρόσθετα + + Άλλο + + + + Παρασκήνια + + + + Αποσπάσματα + + + + Διαγραμμένες σκηνές + + + + Συνεντεύξεις + + + + Σκηνές + + + + Δείγματα + + + + Χαρακτηριστικά + + + Αυτόματος έλεγχος για ενημερώσεις + Καθυστέρηση πριν την έναρξη του επόμενου + Αυτόματη έναρξη επόμενου + Έλεχγος για ενημερώσεις + Ισχύει μόνο για τηλεοπτικές σειρές + Προεπιλεγμένη αναπαραγωγή περιεχομένου + Εγκατάσταση ενημέρωσης + Εγκατεστημένη εκδοχή + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 873bdbbe..59aa1f7b 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -315,4 +315,47 @@ Automaatne Pruugi kasutajanime ja salasõna Logi sisse + Näita pealkirju + Meediumi teave + Üldist + Konteiner + Pealkiri + Koodek + Profiil + Tase + Resolutsioon + Jah + Ei + HDR + HDR10 + bitti + HLG + Hz + HDR10+ + SDR + AVC + Anamorfne + Ülereaskaneering + Kaadrisagedus + Bitisügavus + Video bitisügavus + Video bitisügavuse tüüp + Värviruum + Värvide ülekanne + Keel + Paigutus + Kanaleid + Diskreetimissagedus + Põhivärvused + Pikslivorming + Aluskaadreid + NAL + Kordus + Näita esmalt lemmikkanaleid + Järjesta kanalid viimase vaatamise alusel + Märgi programmid värvidega + Subtiitrite viivitus + Sisesta serveri aadress + Ühtegi serverit ei leidu + Tausta dekoratsioon diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1a3afd7e..ba3fb676 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -304,4 +304,70 @@ Dolby Atmos Remplacer la lecture Marge + MPV: Utiliser gpu-next + Éditer mpv.conf + Annuler les modifications ? + Journalisation détaillée + Saisir le code PIN + Connexion automatique + Connexion via serveur + Appuyez au centre pour confirmer + Code PIN requis pour le profil + Confirmer le code PIN + Erroné + Le code PIN doit comporter au moins 4 éléments + Supprimera le code PIN + Taille du cache disque de l\'image (Mo) + Options d\'affichage + Colonnes + Espacement + Format d\'image + Afficher les détails + + Invités spéciaux + Taille des bordures + Changement de fréquence de rafraîchissement + Automatique + Utiliser un nom d\'utilisateur/mot de passe + Connexion + Afficher les titres + Type d\'image + Informations sur le média + Général + Conteneur + Titre + Codec + Profil + Niveau + Résolution + Anamorphique + Entrelacé + Fréquence d\'images + Profondeur de bits + Plage vidéo + Type de plage vidéo + Espace colorimétrique + Transfert des couleurs + Format de pixel + Langue + Mise en page + Chaînes + Taux d’échantillonnage + AVC + Oui + Non + SDR + HDR + HDR10 + HDR10+ + bit + Hz + Répéter + Afficher les chaînes favorites en premier + NAL + HLG + Couleurs primaires + Images de référence + Trier les chaînes par date de visionnage + Classer les programmes par code couleur diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index cf05326b..fc1f6866 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -271,4 +271,65 @@ Hapus Dolby Vision Dolby Atmos + Informasi Media + MPV: Gunakan gpu-next + Edit mpv.conf + Batalkan perubahan? + Masukkan PIN + Masuk secara otomatis + Masuk via server + Tekan tengah untuk konfirmasi + Konfirmasi PIN + Salah + PIN harus 4 digit atau lebih + Ukuran cache disk gambar (MB) + Kolom + Rasio Aspek + Tampilkan detail + Jenis gambar + + Pemeran Tamu + Anamorfik + Interlace + Framerate + Kedalaman Bit + Rentang Video + Jenis rentang video + Color space + Format piksel + NAL + Bahasa + AVC + Ya + Tidak + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Tampilkan judul + Ulangi + Tampilkan saluran favorit dahulu + Urutkan saluran berdasarkan yang baru ditonton + Program kode warna + Pencatatan rinci + Membutuhkan PIN untuk profil + PIN akan dihapus + Opsi tampilan + Ukuran garis tepi + Otomatis + Gunakan nama pengguna/kata sandi + Batas tepi + Masuk + Umum + Kontainer + Judul + Codec + Profil + Level + Resolusi + Sampel rate + Jarak diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ed33256b..faf958b2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -331,4 +331,44 @@ Automatico Cambio frequenza di aggiornamento Usa nome utente/password + Informazioni sui media + Generali + Titolo + Codifica + Profilo + Livello + Risoluzione + Anamorfico + Interlacciato + Range del video + Spazio colore + trasferimento colore + Colori primari + Formato pixel + Lingua + Canali + Si + No + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Mostra titoli + AVC + Frequenza fotogrammi + Profondità bit + Contenitore + Tipo di gamma video + NAL + Frequenza di campionamento + Frame di riferimento + Disposizione + Ripeti + Mostra prima i canali preferiti + Ordina canali per ultimo guardato + Codifica colori per i programmi + Ritardo sottotitoli diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a793ee8f..7068d379 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -311,8 +311,47 @@ Gastrollen Randbreedte - Andere ververssnelheid toepassen + Automatische ververssnelheid toepassen Automatisch Gebruikersnaam/wachtwoord gebruiken Inloggen + Titels tonen + Media-informatie + Algemeen + Container + Titel + Codec + Profiel + Niveau + Resolutie + Anamorfisch + Met interlatie + Framesnelheid + Bitdiepte + Videobereik + Soort videobereik + Kleurruimte + Kleuroverdracht + Voorkeurskleuren + Pixelformaat + Referentieframes + NAL + Taal + Indeling + Kanalen + Samplesnelheid + AVC + Ja + Nee + Sdr + Hdr + Hdr10 + Hdr10+ + Hlg + bit + Hz + Herhalen + Favoriete kanalen eerst tonen + Kanalen sorteren op onlangs bekeken + Programma\'s voorzien van kleurcode diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml new file mode 100644 index 00000000..fd8c6cce --- /dev/null +++ b/app/src/main/res/values-nn/strings.xml @@ -0,0 +1,24 @@ + + + Om + Aktive opptak + Favoritt + Legg til Server + Legg til Brukar + Lyd + Fødestad + Bitrate + Født + Avbryt opptak + Avbryt opptak av serie + Avbryt + Kapittel + Vel %1$s + Samling + Samlingar + Bekreft + Standard + Slett + Dødd + Regissert av %1$s + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 34a61f80..89c4b105 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ - Sobre + O Wholphin Favorito Adicionar Servidor Adicionar Utilizador @@ -19,7 +19,7 @@ Realizado por %1$s Realizador Desativado - A transferir + A transferir… Activado Episódios Externo @@ -32,12 +32,12 @@ Bitrate Nascido Cancelar Gravação da Série - Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor + Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor. Continuar a ver Morreu Servidores Detectados - Introduz IP e URL do Servidor + Introduza o IP ou URL do Servidor Erro ao carregar a colecção %1$s Géneros Ir para a Série @@ -52,8 +52,8 @@ Informação da licença TV em Directo A carregar… - Marcar série como vista? - Marcar toda a série como não vista? + Marcar toda a série como reproduzida? + Marcar toda a série como não reproduzida? Marcar como não visto Marcar como visto Bitrate Máximo @@ -61,7 +61,7 @@ Mais Filmes Nome - Próximo + A Seguir Sem dados Sem resultados Sem gravações programadas @@ -70,7 +70,7 @@ Créditos finais Pessoas Reproduções - Reproduzir daqui + Reproduzir a partir daqui Reproduzir Mostrar depuração da reprodução Velocidade da Reprodução @@ -78,4 +78,297 @@ Lista de Reprodução Listas de Reprodução Pré-visualização + Avaliação da Comunidade + Avaliação da Crítica + Definições de Utilizador + Fila + Recapitulação + Adicionado recentemente em %1$s + Adicionado recentemente + Gravado Recentemente + Lançado Recentemente + Recomendado + Gravar Programa + Gravar Série + Desmarcar como favorito + Reiniciar + Resumir + Guardar + + Pesquisar + A pesquisar… + Selecionar Servidor + Selecionar Utilizador + Mostrar informação de Depuração + Mostrar \'A Seguir\' + Mostrar + Aleatório + Saltar + Data Adicionado + Data Episódio Adicionado + Data Reproduzido + Data de Lançamento + Nome + Aleatória + Estúdios + Submeter + A transferência está a demorar muito, pode ser necessário reiniciar a reprodução + Legenda + Legendas + Sugestões + Mudar Servidores + Mudar + Mais Votados Não Assistidos + Trailer + + Trailers + Trailers + Trailers + + Programação DVR + Guia + Temporada + Temporadas + Séries TV + Interface + Desconhecido + Atualizações + Versão + Escala do Vídeo + Vídeo + Vídeos + Ver em directo + %1$d anos + Reproduzir com transcodificação + Mostrar Relógio + Ordem de Emissão + Criar nova lista de reprodução + Adicionar a lista de reprodução + Pausar com um clique + Pressione o centro do D-Pad para pausar/reproduzir + Itálico + Fonte + Fundo + Êxito + Classificação Parental + Duração + Extras + + Outro + Outros + Outros + + + Nos Bastidores + Nos Bastidores + Nos Bastidores + + + Músicas temáticas + Músicas temáticas + Músicas temáticas + + + Vídeos Temáticos + Vídeos Temáticos + Vídeos Temáticos + + + Clipes + Clipes + Clipes + + + Cenas Excluídas + Cenas Excluídas + Cenas Excluídas + + + Entrevistas + Entrevistas + Entrevistas + + + Cenas + Cenas + Cenas + + + Amostras + Amostras + Amostras + + + Curta-metragens + Curta-metragens + Curta-metragens + + + Curtos + Curtos + Curtos + + + %s transferido + %s transferidos + %s transferidos + + + %d hora + %d horas + %d horas + + + %d item + %d itens + %d itens + + + %d segundo + %d segundos + %d segundos + + Publicidade + Localização + Dispositivo suporta AC3/Dolby Digital + Definições Avançadas + Interface Avançado + Tema da Aplicação + Verificar automaticamente se há atualizações + Atraso antes de reproduzir \'A Seguir\' + Reproduzir automaticamente o próximo + Verificar se há atualizações + Aplica-se apenas a Séries de TV + + Reprodução direta de legendas ASS + Reprodução direta de legendas PGS + Conversão sempre em estéreo + Usar o módulo decodificador FFmpeg + Escala de conteúdo predefinida + Instalar atualização + Versão instalada + Máximo de itens por fila no inicio + Escolha os itens padrão a serem exibidos, os demais serão ocultados + Personalizar itens da barra de navegação + Clique para mudar de página + Alternar páginas da barra de navegação ao focar + Protecção Contra Inatividade + Reproduzir música temática + Substituições de reprodução + Lembrar separadores selecionados + Activar a revisão em \'A seguir\' + Passos da barra de pesquisa + Útil para depuração + Enviar registos da aplicação para o servidor atual + Tentará enviar para o último servidor conectado + Enviar Relatórios de Falhas + Definições + Retroceder ao retomar a reprodução + Retroceder + Comportamento de ignorar anúncios + Avançar + Comportamento de saltar introdução + Comportamento de saltar créditos finais + Comportamento de saltar pré-visualizações + Comportamento de saltar recapitulação + Atualização disponível + URL usado para verificar atualizações da aplicação + URL de atualizações + Tamanho da fonte + Cor da fonte + Opacidade da fonte + Estilo da borda + Cor da borda + Opacidade do fundo + Estilo do fundo + Estilo da legenda + Cor do fundo + Repor + Fonte em negrito + Motor de reprodução + MPV: Usar descodificação por hardware + Desative se ocorrerem falhas + Opções do MPV + Opções do ExoPlayer + Saltar Segmento + Saltar Publicidade + Saltar pré-visualização + Saltar Recapitulação + Saltar Créditos Finais + Saltar Introdução + Reproduzido + Filtrar + Ano + Década + Remover + Dolby Vision + Dolby Atmos + MPV: Usar gpu-next + Editar mpv.conf + Anular alterações? + Margem + Registro detalhado + Inserir PIN + Entrar automaticamente + Iniciar sessão através do servidor + Pressione centro para confirmar + Exigir PIN para o perfil + Confirmar PIN + Incorrecto + O PIN deve ter 4 dígitos ou mais + Irá remover o PIN + Tamanho da cache de imagens no disco (MB) + Ver opções + Colunas + Espaçamento + Proporção da imagem + Mostrar detalhes + Tipo de imagem + + Estrelas convidadas + Dimensão da borda + Mudança da taxa de atualização + Automático + Usar utilizador/palavra-passe + Entrar + Mostrar títulos + Informação da Multimédia + Geral + Título + Codec + Perfil + Nível + Resolução + Anamórfico + Intercalado + Taxa de fotogramas + Profundidade de bit + Gama de video + Tipo de gama de vídeo + Espaço de cor + Transferência de cor + Cores primárias + Formato dos pixeis + Quadros de referência + NAL + Idioma + Disposição + Canais + Taxa de amostragem + AVC + Sim + Não + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Formato + Repetir + Mostrar primeiro os canais favoritos + Ordenar canais por visualizações recentes + Codificação de cores para os programas + Atraso da legenda diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000..2d317635 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,342 @@ + + + 关于 + 正在录制 + 收藏 + 添加服务器 + 添加用户 + 音频 + 出生地 + 比特率 + 出生日期 + 取消录制 + 取消剧集录制 + 取消 + 章节 + 选择 %1$s + 清除图片缓存 + 合集 + 合集 + 广告 + 社区评分 + 发生错误!请按下按钮将日志发送到您的服务器。 + 确认 + 继续观看 + 影评人评分 + %.1f 秒 + 默认 + 删除 + 去世 + 由 %1$s 执导 + 导演 + 已禁用 + 已发现的服务器 + + 正在下载… + 已启用 + 输入服务器 IP 或 URL + 剧集 + 加载合集 %1$s 时出错 + 外部 + 收藏 + 大小 + 强制 + 类型 + 前往剧集 + 前往 + 隐藏播放控件 + 隐藏调试信息 + 隐藏 + 首页 + 立即 + 片头 + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + 媒体库 + 许可信息 + 电视直播 + 正在加载… + 将整部剧标记为已观看? + 将整部剧标记为未观看? + 标记为未观看 + 标记为已观看 + 最大比特率 + 类似推荐 + 更多 + 电影 + 名称 + 即将播放 + 无数据 + 无结果 + 无计划录制 + 无可用更新 + + 片尾 + 路径 + 演职人员 + 播放次数 + 从此处播放 + 播放 + 显示播放调试信息 + 播放速度 + 播放 + 播放列表 + 播放列表 + 预览 + 用户配置设置 + 队列 + 前情提要 + 最近添加至 %1$s + 最近添加 + 最近录制 + 最近发行 + 推荐 + 录制节目 + 录制剧集 + 取消收藏 + 从头播放 + 继续播放 + 保存 + + 搜索 + 正在搜索… + 选择服务器 + 选择用户 + 显示调试信息 + 显示即将播放 + 显示 + 随机播放 + 跳过 + 添加日期 + 剧集添加日期 + 播放日期 + 发行日期 + 名称 + 随机 + 制片公司 + 提交 + 下载时间过长,可能需要重新开始播放 + 字幕 + 字幕 + 建议 + 切换服务器 + 切换 + 高分未看 + 预告片 + + 预告片 + + DVR 计划 + 指南 + + + 电视节目 + 界面 + 未知 + 更新 + 版本 + 视频比例 + 视频 + 视频 + 观看直播 + %1$d 岁 + 转码播放 + 显示时钟 + 剧集播出顺序 + 创建新的播放列表 + 添加到播放列表 + 一键暂停 + 按方向键中心暂停/播放 + 斜体 + 字体 + 背景 + 成功 + 年龄分级 + 播放时长 + 附加内容 + + 其他 + + + 幕后花絮 + + + 主题曲 + + + 主题视频 + + + 片段 + + + 删减片段 + + + 访谈 + + + %s 次下载 + + + %d 小时 + + + %d 个项目 + + + %d 秒 + + + 场景 + + + 样片 + + + 特辑 + + + 短片 + + 设备支持 AC3/杜比数字 + 高级设置 + 高级用户界面 + 应用程序主题 + 自动检查更新 + 播放下一项前的延迟时间 + 自动播放下一项 + 检查更新 + 仅适用于电视剧 + + 直接播放 ASS 字幕 + 直接播放 PGS 字幕 + 始终降混为立体声 + 使用 FFmpeg 解码模块 + 默认内容比例 + 安装更新 + 安装版本 + 首页每行最大项目数 + 选择默认显示的项目,其他项目将隐藏 + 自定义抽屉式导航栏项目 + 点击切换页面 + 获得焦点时切换抽屉式导航栏页面 + 防休眠保护 + 播放主题音乐 + 播放覆盖设置 + 记住所选标签页 + 在即将播放中重复观看 + 进度条步进值 + 用于调试 + 将应用日志发送到当前服务器 + 将尝试发送到最后连接的服务器 + 发送崩溃报告 + 设置 + 恢复播放时快退 + 快退 + 跳过广告方式 + 快进 + 跳过片头方式 + 跳过片尾方式 + 跳过预览方式 + 跳过前情提要方式 + 有可用更新 + 用于检查应用更新的 URL + 更新 URL + 字体大小 + 字体颜色 + 字体不透明度 + 边缘样式 + 边缘颜色 + 背景不透明度 + 背景样式 + 字幕样式 + 背景颜色 + 重置 + 粗体 + 播放后端 + MPV:使用硬件解码 + 如果遇到崩溃,请禁用 + MPV 选项 + ExoPlayer 选项 + 跳过片段 + 跳过广告 + 跳过预览 + 跳过前情提要 + 跳过片尾 + 跳过片头 + 已观看 + 筛选 + 年份 + 年代 + 移除 + 杜比视界 + 杜比全景声 + MPV:使用 gpu-next + 编辑 mpv.conf + 舍弃更改? + 边距 + 详细日志记录 + 输入 PIN 码 + 自动登录 + 通过服务器登录 + 按中心键确认 + 配置需要 PIN 码 + 确认 PIN 码 + 错误 + PIN 码必须至少包含 4 个字符 + 将移除 PIN 码 + 图片磁盘缓存大小(MB) + 查看选项 + + 间距 + 宽高比 + 显示详情 + 图片类型 + + 客串演员 + 边缘尺寸 + 刷新率切换 + 自动 + 使用用户名/密码 + 登录 + 显示标题 + 媒体信息 + 常规 + 容器格式 + 标题 + 编解码器 + 配置 + 等级 + 分辨率 + 变形宽银幕 + 隔行扫描 + 帧速率 + 位深度 + 视频范围 + 视频范围类型 + 色彩空间 + 色彩传递 + 色彩原色 + 像素格式 + 参考帧数 + NAL + 语言 + 布局 + 频道 + 采样率 + AVC + + + SDR + HDR + HDR10 + HDR10+ + HLG + + Hz + 重复 + 优先显示收藏频道 + 按最近观看的频道排序 + 节目颜色标记 + 字幕延迟 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7e65e85b..ef0da683 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -272,4 +272,31 @@ 邊距 詳細日誌記錄 使用 PIN 鍵登入 + 媒體資訊 + MPV:使用 gpu-next + 編輯 mpv.conf + 放棄變更? + 輸入 PIN 碼 + 自動登入 + 透過伺服器登入 + 按下中間鍵確認 + 確認 PIN 碼 + 錯誤 + PIN 碼至少需 4 碼 + 將移除 PIN 碼 + 圖片快取大小 (MB) + 檢視選項 + 欄數 + 間距 + 長寬比 + 詳細資訊 + 圖片類型 + + 客串演員 + 邊框大小 + 畫面更新率切換 + 自動 + 使用帳號/密碼 + 登入 + 字幕同步 From 8b40e89b8a43be528d67369905e0e78e112575a9 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 17 Dec 2025 18:24:25 -0500 Subject: [PATCH 112/124] Update MPV playback threading model (#481) There's no user facing changes in this PR This PR updates `MpvPlayer` to use a threading model more similar to [ExoPlayer's model](https://developer.android.com/reference/androidx/media3/exoplayer/ExoPlayer#threading-model). There's now a dedicated thread looper message queue between the UI interactions on the main thread and calls to `libmpv`. This is necessary because previously, the main thread would make the calls into `libmpv`. `libmpv` uses internal mutex locking for some operations which meant that the main thread would be blocked momentarily. Now, the UI issues a command (eg seek to X position) and the command is added to the thread looper queue and processed on that separate thread. This uses the standard Android Handler/Looper mechanisms. Related to #235 --- .../wholphin/services/AppUpgradeHandler.kt | 10 + .../wholphin/util/mpv/MpvLogger.kt | 21 + .../wholphin/util/mpv/MpvPlayer.kt | 546 ++++++++++++------ app/src/main/jni/log.h | 2 +- scripts/mpv/README.md | 14 +- 5 files changed, 411 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 67b55a1b..08dc0d77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -187,4 +187,14 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { + if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt new file mode 100644 index 00000000..300d36f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt @@ -0,0 +1,21 @@ +package com.github.damontecres.wholphin.util.mpv + +import timber.log.Timber + +class MpvLogger : MPVLib.LogObserver { + override fun logMessage( + prefix: String, + level: Int, + text: String, + ) { + // https://github.com/mpv-player/mpv/blob/122abdfec3124bfc92a2918a70ca8150eee68338/include/mpv/client.h#L1423 + when { + level <= 10 -> Timber.wtf("%s, %s", prefix, text) + level <= 20 -> Timber.e("%s, %s", prefix, text) + level <= 30 -> Timber.w("%s, %s", prefix, text) + level <= 40 -> Timber.i("%s, %s", prefix, text) + level <= 50 -> Timber.d("%s, %s", prefix, text) + else -> Timber.v("%s, %s", prefix, text) + } + } +} 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 f0b358be..a31638c4 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 @@ -3,7 +3,10 @@ package com.github.damontecres.wholphin.util.mpv import android.content.Context import android.os.Build import android.os.Handler +import android.os.HandlerThread import android.os.Looper +import android.os.Message +import android.os.Process import android.view.Surface import android.view.SurfaceHolder import android.view.SurfaceView @@ -35,6 +38,7 @@ import androidx.media3.common.util.Util import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP @@ -42,9 +46,12 @@ import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_ import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART +import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_START_FILE import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG import timber.log.Timber +import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.update import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -57,20 +64,22 @@ import kotlin.time.Duration.Companion.seconds @OptIn(UnstableApi::class) class MpvPlayer( private val context: Context, - enableHardwareDecoding: Boolean, - useGpuNext: Boolean, + private val enableHardwareDecoding: Boolean, + private val useGpuNext: Boolean, ) : BasePlayer(), MPVLib.EventObserver, - TrackSelector.InvalidationListener { + TrackSelector.InvalidationListener, + Handler.Callback { companion object { private const val DEBUG = false } - private var isPaused: Boolean = true private var surface: Surface? = null + private val playbackState = AtomicReference(PlaybackState.EMPTY) + // This looper will sent events to the main thread private val looper = Util.getCurrentOrMainLooper() - private val handler = Handler(looper) + private val mainHandler = Handler(looper) private val listeners = ListenerSet( looper, @@ -81,19 +90,16 @@ class MpvPlayer( private val availableCommands: Player.Commands private val trackSelector = DefaultTrackSelector(context) - private var mediaItem: MediaItem? = null - private var startPositionMs: Long = 0L - private var durationMs: Long = 0L - private var positionMs: Long = -1L - private var playbackState: Int = STATE_READY + // This thread/looper will receive commands from the main thread to execute + private val thread: HandlerThread = + HandlerThread("MpvPlayer:Playback", Process.THREAD_PRIORITY_AUDIO) + .also { it.start() } + private val internalHandler: Handler = Handler(thread.looper, this) @Volatile var isReleased = false private set - @Volatile - private var isLoadingFile = false - init { Timber.v("config-dir=${context.filesDir.path}") MPVLib.create(context) @@ -120,8 +126,11 @@ class MpvPlayer( MPVLib.setOptionString("idle", "yes") // MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) - MPVLib.addObserver(this) - MPVProperty.observedProperties.forEach(MPVLib::observeProperty) + internalHandler.post { + MPVLib.addObserver(this@MpvPlayer) + MPVProperty.observedProperties.forEach(MPVLib::observeProperty) + MPVLib.addLogObserver(MpvLogger()) + } availableCommands = Player.Commands @@ -167,13 +176,11 @@ class MpvPlayer( mediaItems: List, resetPosition: Boolean, ) { - throwIfReleased() - if (DEBUG) Timber.v("setMediaItems") + throwIfReleased() mediaItems.firstOrNull()?.let { - mediaItem = it if (surface != null) { - loadFile(it) + sendCommand(MpvCommand.LOAD_FILE, MediaAndPosition(it, C.TIME_UNSET)) } } } @@ -184,8 +191,11 @@ class MpvPlayer( startPositionMs: Long, ) { if (DEBUG) Timber.v("setMediaItems") - this.startPositionMs = startPositionMs - setMediaItems(mediaItems.subList(startIndex, mediaItems.size), false) + throwIfReleased() + sendCommand( + MpvCommand.LOAD_FILE, + MediaAndPosition(mediaItems[startIndex], startPositionMs), + ) } override fun addMediaItems( @@ -214,17 +224,19 @@ class MpvPlayer( override fun prepare() { if (DEBUG) Timber.v("prepare") - durationMs = 0L - positionMs = -1L - playbackState = STATE_READY + playbackState.update { + it.copy( + state = Player.STATE_READY, + ) + } } override fun getPlaybackState(): Int { if (DEBUG) Timber.v("getPlaybackState") - return playbackState + return playbackState.load().state } - override fun getPlaybackSuppressionReason(): Int = Player.PLAYBACK_SUPPRESSION_REASON_NONE + override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE override fun getPlayerError(): PlaybackException? { TODO("Not yet implemented") @@ -232,32 +244,21 @@ class MpvPlayer( override fun setPlayWhenReady(playWhenReady: Boolean) { if (isReleased) return - if (DEBUG) Timber.v("setPlayWhenReady") - if (playWhenReady) { - MPVLib.setPropertyBoolean("pause", false) - } else { - MPVLib.setPropertyBoolean("pause", true) - } - notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) { - onPlayWhenReadyChanged( - playWhenReady, - PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, - ) - } + if (DEBUG) Timber.v("setPlayWhenReady: $playWhenReady") + sendCommand(MpvCommand.PLAY_PAUSE, playWhenReady) } override fun getPlayWhenReady(): Boolean { if (DEBUG) Timber.v("getPlayWhenReady") if (isReleased) return false - val isPaused = MPVLib.getPropertyBoolean("pause") ?: this.isPaused - return !isPaused + return !playbackState.load().isPaused } override fun setRepeatMode(repeatMode: Int) { if (DEBUG) Timber.v("setRepeatMode") } - override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF + override fun getRepeatMode(): Int = REPEAT_MODE_OFF override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { if (DEBUG) Timber.v("setShuffleModeEnabled") @@ -265,7 +266,7 @@ class MpvPlayer( override fun getShuffleModeEnabled(): Boolean = false - override fun isLoading(): Boolean = isLoadingFile + override fun isLoading(): Boolean = playbackState.load().isLoadingFile override fun getSeekBackIncrement(): Long = 10_000 @@ -275,30 +276,34 @@ class MpvPlayer( override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { if (DEBUG) Timber.v("setPlaybackParameters") - MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble()) + sendCommand(MpvCommand.SET_SPEED, playbackParameters.speed) } override fun getPlaybackParameters(): PlaybackParameters { if (DEBUG) Timber.v("getPlaybackParameters") - val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f - return PlaybackParameters(speed) + return PlaybackParameters(playbackState.load().speed) } override fun stop() { if (DEBUG) Timber.v("stop") if (isReleased) return pause() - mediaItem = null - positionMs = -1L - durationMs = 0L - playbackState = STATE_IDLE + internalHandler.removeCallbacks(updatePlaybackState) + playbackState.update { + PlaybackState.EMPTY + } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) } } override fun release() { Timber.i("release") + internalHandler.removeCallbacks(updatePlaybackState) + playbackState.update { + PlaybackState.EMPTY + } if (!isReleased) { + thread.quit() MPVLib.removeObserver(this) clearVideoSurfaceView(null) MPVLib.destroy() @@ -309,7 +314,7 @@ class MpvPlayer( override fun getCurrentTracks(): Tracks { if (DEBUG) Timber.v("getCurrentTracks") if (isReleased) return Tracks.EMPTY - return getTracks() + return playbackState.load().tracks } override fun getTrackSelectionParameters(): TrackSelectionParameters { @@ -323,16 +328,16 @@ class MpvPlayer( override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) { Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters) if (isReleased) return - val tracks = getTracks() + val tracks = playbackState.load().tracks if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) { // Subtitles disabled Timber.v("TrackSelection: disabling subtitles") - MPVLib.setPropertyString("sid", "no") + sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("sid", "no")) } if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) { // Audio disabled Timber.v("TrackSelection: disabling audio") - MPVLib.setPropertyString("aid", "no") + sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("aid", "no")) } Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides") parameters.overrides.forEach { (trackGroup, trackSelectionOverride) -> @@ -350,7 +355,10 @@ class MpvPlayer( } Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id) if (trackId != null && propertyName != null) { - MPVLib.setPropertyString(propertyName, trackId) + sendCommand( + MpvCommand.SET_TRACK_SELECTION, + TrackSelection(propertyName, trackId), + ) true } else { false @@ -397,24 +405,13 @@ class MpvPlayer( override fun getDuration(): Long { if (DEBUG) Timber.v("getDuration") - if (isReleased) { - return durationMs - } - val duration = - MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds - ?: durationMs - return duration + return playbackState.load().durationMs } override fun getCurrentPosition(): Long { if (DEBUG) Timber.v("getCurrentPosition") - if (isReleased) { - return positionMs - } - val position = - MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds - ?: positionMs - return position + val state = playbackState.load() + return state.positionMs } override fun getBufferedPosition(): Long { @@ -425,7 +422,7 @@ class MpvPlayer( override fun getTotalBufferedDuration(): Long { if (DEBUG) Timber.v("getTotalBufferedDuration") if (isReleased) return 0 - return MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds ?: 0 + return playbackState.load().bufferMs } override fun isPlayingAd(): Boolean { @@ -475,9 +472,8 @@ class MpvPlayer( MPVLib.attachSurface(surface) MPVLib.setOptionString("force-window", "yes") Timber.d("Attached surface") - mediaItem?.let(::loadFile) - if (mediaItem == null) { - Timber.w("mediaItem is null in setVideoSurfaceView") + playbackState.load().media?.let { + sendCommand(MpvCommand.LOAD_FILE, it) } } else { clearVideoSurfaceView(null) @@ -485,11 +481,14 @@ class MpvPlayer( } override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { - Timber.d("clearVideoSurfaceView") - MPVLib.detachSurface() - MPVLib.setPropertyString("vo", "null") - MPVLib.setPropertyString("force-window", "no") - mediaItem = null + if (surface == surfaceView?.holder?.surface) { + Timber.d("clearVideoSurfaceView") + MPVLib.detachSurface() + MPVLib.setPropertyString("vo", "null") + MPVLib.setPropertyString("force-window", "no") + } else { + Timber.w("clearVideoSurfaceView called with different surface") + } } override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() @@ -499,13 +498,7 @@ class MpvPlayer( override fun getVideoSize(): VideoSize { if (DEBUG) Timber.v("getVideoSize") if (isReleased) return VideoSize.UNKNOWN - val width = MPVLib.getPropertyInt("width") - val height = MPVLib.getPropertyInt("height") - return if (width != null && height != null) { - VideoSize(width, height) - } else { - VideoSize.UNKNOWN - } + return playbackState.load().videoSize } override fun getSurfaceSize(): Size = throw UnsupportedOperationException() @@ -566,7 +559,11 @@ class MpvPlayer( if (mediaItemIndex == C.INDEX_UNSET) { return } - MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) + sendCommand(MpvCommand.SEEK, positionMs) + } + + override fun onTrackSelectionsInvalidated() { + // no-op } override fun eventProperty(property: String) { @@ -579,7 +576,11 @@ class MpvPlayer( ) { if (DEBUG) Timber.v("eventPropertyLong: $property=$value") when (property) { - MPVProperty.POSITION -> positionMs = value.seconds.inWholeMilliseconds + MPVProperty.POSITION -> { + playbackState.update { + it.copy(positionMs = value.seconds.inWholeMilliseconds) + } + } } } @@ -590,7 +591,9 @@ class MpvPlayer( if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value") when (property) { MPVProperty.PAUSED -> { - isPaused = value + playbackState.update { + it.copy(isPaused = value) + } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) } } } @@ -609,52 +612,58 @@ class MpvPlayer( ) { Timber.v("eventPropertyDouble: $property=$value") when (property) { - MPVProperty.DURATION -> durationMs = value.seconds.inWholeMilliseconds + MPVProperty.DURATION -> { + playbackState.update { + it.copy(durationMs = value.seconds.inWholeMilliseconds) + } + } } } override fun event(eventId: Int) { + Timber.v("event: thread=${Thread.currentThread().name}, eventId=$eventId") when (eventId) { -// MPV_EVENT_START_FILE -> { -// } + MPV_EVENT_START_FILE -> { + internalHandler.post(updatePlaybackState) + } + MPV_EVENT_FILE_LOADED -> { - isLoadingFile = false + playbackState.update { + it.copy(isLoadingFile = false) + } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } Timber.d("event: MPV_EVENT_FILE_LOADED") - mediaItem?.let { - it.localConfiguration?.subtitleConfigurations?.forEach { + internalHandler.post(updatePlaybackState) + playbackState.load().media?.mediaItem?.let { media -> + media.localConfiguration?.subtitleConfigurations?.forEach { val url = it.uri.toString() val title = it.label ?: "External Subtitles" Timber.v("Adding external subtitle track '$title'") - MPVLib.command(arrayOf("sub-add", url, "auto", title)) + if (it.language.isNotNullOrBlank()) { + MPVLib.command(arrayOf("sub-add", url, "auto", title, it.language!!)) + } else { + MPVLib.command(arrayOf("sub-add", url, "auto", title)) + } } } notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) } - getTracks().let { - notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } - } + updateTracksAndNotify() } MPV_EVENT_PLAYBACK_RESTART -> { Timber.d("event: MPV_EVENT_PLAYBACK_RESTART") - getTracks().let { - notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } - } + updateTracksAndNotify() } MPV_EVENT_AUDIO_RECONFIG -> { Timber.d("event: MPV_EVENT_AUDIO_RECONFIG") - getTracks().let { - notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } - } + updateTracksAndNotify() } MPV_EVENT_VIDEO_RECONFIG -> { Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") - getTracks().let { - notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } - } + updateTracksAndNotify() } MPV_EVENT_END_FILE -> { @@ -676,6 +685,9 @@ class MpvPlayer( notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } when (reason) { MPV_END_FILE_REASON_EOF -> { + playbackState.update { + it.copy(state = Player.STATE_ENDED) + } notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_ENDED) } @@ -704,25 +716,44 @@ class MpvPlayer( } } - private fun loadFile(mediaItem: MediaItem) { - isLoadingFile = true + private fun updateTracksAndNotify() { + val tracks = createTracks() + playbackState.update { + it.copy(tracks = tracks) + } + notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) } + } + + private fun loadFile(media: MediaAndPosition) { + Timber.v("loadFile: media=$media") + playbackState.update { + it.copy( + isLoadingFile = true, + media = media, + ) + } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } - val url = mediaItem.localConfiguration?.uri.toString() - if (startPositionMs > 0) { + val url = + media.mediaItem.localConfiguration + ?.uri + .toString() + if (media.startPositionMs > 0) { MPVLib.command( arrayOf( "loadfile", url, "replace", "-1", - "start=${startPositionMs / 1000.0}", + "start=${media.startPositionMs / 1000.0}", ), ) } else { MPVLib.command(arrayOf("loadfile", url, "replace", "-1")) } - MPVLib.setPropertyString("vo", "gpu") + if (enableHardwareDecoding) { + MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu") + } Timber.d("Called loadfile") } @@ -736,7 +767,7 @@ class MpvPlayer( eventId: Int, block: Player.Listener.() -> Unit, ) { - handler.post { + mainHandler.post { listeners.queueEvent(eventId) { block.invoke(it) } @@ -744,78 +775,14 @@ class MpvPlayer( } } - private fun getTracks(): Tracks { - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY - val groups = - (0.. - val type = MPVLib.getPropertyString("track-list/$idx/type") - val id = MPVLib.getPropertyInt("track-list/$idx/id") - val lang = MPVLib.getPropertyString("track-list/$idx/lang") - val codec = MPVLib.getPropertyString("track-list/$idx/codec") - val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc") - val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false - val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false - val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false - val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false - val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count") - val title = MPVLib.getPropertyString("track-list/$idx/title") - - if (type != null && id != null) { - // TODO do we need the real mimetypes? - val mimeType = - when (type) { - "video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo" - "audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo" - "sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo" - else -> "unknown/todo" - } - var flags = 0 - if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT - if (isForced) flags = flags or C.SELECTION_FLAG_FORCED - val builder = - Format - .Builder() - .setId("$idx:$id") - .setCodecs(codec) - .setSampleMimeType(mimeType) - .setLanguage(lang) - .setLabel(listOfNotNull(title, codecDescription).joinToString(",")) - .setSelectionFlags(flags) - if (type == "video" && isSelected) { - builder.setWidth(MPVLib.getPropertyInt("width") ?: -1) - builder.setHeight(MPVLib.getPropertyInt("height") ?: -1) - } - channelCount?.let(builder::setChannelCount) - val format = builder.build() - - val trackGroup = TrackGroup(format) - val group = - Tracks.Group( - trackGroup, - false, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(isSelected), - ) - group - } else { - null - } - } - return Tracks(groups) - } - - override fun onTrackSelectionsInvalidated() { - // no-op - } - var subtitleDelaySeconds: Double get() { if (isReleased) return 0.0 - return MPVLib.getPropertyDouble("sub-delay") ?: 0.0 + return playbackState.load().subtitleDelay } set(value) { if (isReleased) return - MPVLib.setPropertyDouble("sub-delay", value) + sendCommand(MpvCommand.SET_SUBTITLE_DELAY, value) } var subtitleDelay: Duration @@ -827,11 +794,230 @@ class MpvPlayer( if (isReleased) return subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0 } + + private val updatePlaybackState: Runnable = + Runnable { + if (playbackState.load().media == null) { + return@Runnable + } + val positionMs = + MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds + ?: C.TIME_UNSET + val bufferMs = + MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds + ?: C.TIME_UNSET + val durationMs = + MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds + ?: C.TIME_UNSET + val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f + val paused = MPVLib.getPropertyBoolean("pause") ?: false + val width = MPVLib.getPropertyInt("width") + val height = MPVLib.getPropertyInt("height") + val videoSize = + if (width != null && height != null) { + VideoSize(width, height) + } else { + VideoSize.UNKNOWN + } + + playbackState.update { + it.copy( + timestamp = System.currentTimeMillis(), + positionMs = positionMs, + bufferMs = bufferMs, + durationMs = durationMs, + speed = speed, + isPaused = paused, + videoSize = videoSize, + ) + } + } + + private fun sendCommand( + cmd: MpvCommand, + obj: Any?, + ) { + internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget() + } + + override fun handleMessage(msg: Message): Boolean { + val cmd = MpvCommand.entries[msg.what] + Timber.v("handleMessage: cmd=$cmd") + when (cmd) { + MpvCommand.PLAY_PAUSE -> { + val playWhenReady = msg.obj as Boolean + MPVLib.setPropertyBoolean("pause", !playWhenReady) + playbackState.update { + it.copy(isPaused = !playWhenReady) + } + notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) { + onPlayWhenReadyChanged( + playWhenReady, + PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ) + } + } + + MpvCommand.SET_TRACK_SELECTION -> { + val (propertyName, trackId) = msg.obj as TrackSelection + MPVLib.setPropertyString(propertyName, trackId) + updateTracksAndNotify() + } + + MpvCommand.SEEK -> { + val positionMs = msg.obj as Long + MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) + playbackState.update { + it.copy(positionMs = positionMs) + } + } + + MpvCommand.SET_SPEED -> { + val value = msg.obj as Float + MPVLib.setPropertyDouble("speed", value.toDouble()) + playbackState.update { + it.copy(speed = value) + } + } + + MpvCommand.SET_SUBTITLE_DELAY -> { + val value = msg.obj as Double + MPVLib.setPropertyDouble("sub-delay", value) + playbackState.update { + it.copy(subtitleDelay = value) + } + } + + MpvCommand.LOAD_FILE -> { + loadFile(msg.obj as MediaAndPosition) + } + } + return true + } } fun MPVLib.setPropertyColor( property: String, color: Color, -) = MPVLib.setPropertyString(property, color.mpvFormat) +) = setPropertyString(property, color.mpvFormat) private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" + +@OptIn(UnstableApi::class) +private fun createTracks(): Tracks { + val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY + val groups = + (0.. + val type = MPVLib.getPropertyString("track-list/$idx/type") + val id = MPVLib.getPropertyInt("track-list/$idx/id") + val lang = MPVLib.getPropertyString("track-list/$idx/lang") + val codec = MPVLib.getPropertyString("track-list/$idx/codec") + val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc") + val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false + val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false + val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false + val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false + val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count") + val title = MPVLib.getPropertyString("track-list/$idx/title") + + if (type != null && id != null) { + // TODO do we need the real mimetypes? + val mimeType = + when (type) { + "video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo" + "audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo" + "sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo" + else -> "unknown/todo" + } + var flags = 0 + if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT + if (isForced) flags = flags or C.SELECTION_FLAG_FORCED + val builder = + Format + .Builder() + .apply { + if (isExternal) { + setId("$idx:e:$id") + } else { + setId("$idx:$id") + } + }.setCodecs(codec) + .setSampleMimeType(mimeType) + .setLanguage(lang) + .setLabel(listOfNotNull(title, codecDescription).joinToString(",")) + .setSelectionFlags(flags) + if (type == "video" && isSelected) { + builder.setWidth(MPVLib.getPropertyInt("width") ?: -1) + builder.setHeight(MPVLib.getPropertyInt("height") ?: -1) + } + channelCount?.let(builder::setChannelCount) + val format = builder.build() +// Timber.v("$idx=$format") + + val trackGroup = TrackGroup(format) + val group = + Tracks.Group( + trackGroup, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(isSelected), + ) + group + } else { + null + } + } + return Tracks(groups) +} + +private data class PlaybackState( + val timestamp: Long, + val isLoadingFile: Boolean, + val media: MediaAndPosition?, + val positionMs: Long, + val bufferMs: Long, + val durationMs: Long, + val isPaused: Boolean, + val speed: Float, + val subtitleDelay: Double, + val videoSize: VideoSize, + @param:Player.State val state: Int, + val tracks: Tracks, +) { + companion object { + val EMPTY = + PlaybackState( + timestamp = C.TIME_UNSET, + isLoadingFile = false, + media = null, + positionMs = C.TIME_UNSET, + durationMs = C.TIME_UNSET, + tracks = Tracks.EMPTY, + bufferMs = C.TIME_UNSET, + isPaused = false, + speed = 1f, + videoSize = VideoSize.UNKNOWN, + state = Player.STATE_IDLE, + subtitleDelay = 0.0, + ) + } +} + +private data class TrackSelection( + val property: String, + val trackId: String, +) + +private data class MediaAndPosition( + val mediaItem: MediaItem, + val startPositionMs: Long, +) + +enum class MpvCommand { + PLAY_PAUSE, + SEEK, + SET_TRACK_SELECTION, + SET_SPEED, + SET_SUBTITLE_DELAY, + LOAD_FILE, +} diff --git a/app/src/main/jni/log.h b/app/src/main/jni/log.h index 58fbda05..bef941b2 100644 --- a/app/src/main/jni/log.h +++ b/app/src/main/jni/log.h @@ -2,7 +2,7 @@ #include -#define DEBUG 1 +#define DEBUG 0 #define LOG_TAG "mpv" #define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md index 80eeb407..4d41c03a 100644 --- a/scripts/mpv/README.md +++ b/scripts/mpv/README.md @@ -4,4 +4,16 @@ This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae ## Instructions -TODO +```bash +cd scripts/mpv +./get_dependencies + +export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865 +# Build arm64 +PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh --clean --arch arm64 mpv +# Build arm32 +PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh mpv + +cd ../.. +env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" "$NDK_PATH/ndk-build" -C app/src/main -j && cp -fr app/src/main/libs/ app/src/main/jnilibs/ +``` From 644bf51aff62d4e8edc7c3d27bb15d0d87b96716 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:01:21 -0500 Subject: [PATCH 113/124] Fix backdrop not clearing between tabs (#500) ## Description Follow up to #476 to clear the backdrop when switching tabs ### Related issues Fixes #499 --- .../wholphin/ui/detail/CollectionFolderLiveTv.kt | 3 +++ .../wholphin/ui/detail/CollectionFolderMovie.kt | 1 + .../damontecres/wholphin/ui/detail/CollectionFolderTv.kt | 1 + .../damontecres/wholphin/ui/detail/FavoritesPage.kt | 1 + .../wholphin/ui/detail/series/SeriesOverview.kt | 8 -------- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 1 - .../wholphin/ui/preferences/PreferencesViewModel.kt | 2 ++ 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index a006b168..2b69c105 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -59,6 +60,7 @@ class LiveTvCollectionViewModel val serverRepository: ServerRepository, val navigationManager: NavigationManager, val rememberTabManager: RememberTabManager, + val backdropService: BackdropService, ) : ViewModel(), RememberTabManager by rememberTabManager { val recordingFolders = MutableLiveData>() @@ -106,6 +108,7 @@ fun CollectionFolderLiveTv( LaunchedEffect(selectedTabIndex) { logTab("livetv", selectedTabIndex) viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + viewModel.backdropService.clearBackdrop() } val onClickItem = { position: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 1fad983d..798683aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -65,6 +65,7 @@ fun CollectionFolderMovie( LaunchedEffect(selectedTabIndex) { logTab("movie", selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + preferencesViewModel.backdropService.clearBackdrop() } var showHeader by rememberSaveable { mutableStateOf(true) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index e3fae08e..b32189d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -64,6 +64,7 @@ fun CollectionFolderTv( LaunchedEffect(selectedTabIndex) { logTab("tv", selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + preferencesViewModel.backdropService.clearBackdrop() } val onClickItem = { item: BaseItem -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index dff167c1..5157f7d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -82,6 +82,7 @@ fun FavoritesPage( NavDrawerItem.Favorites.id, selectedTabIndex, ) + preferencesViewModel.backdropService.clearBackdrop() } var showHeader by rememberSaveable { mutableStateOf(true) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index d5520c87..73f489c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -46,7 +46,6 @@ import com.github.damontecres.wholphin.util.LoadingState import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks @@ -298,13 +297,6 @@ fun SeriesOverview( chosenStreams = chosenStreams, peopleInEpisode = peopleInEpisode, position = position, - backdropImageUrl = - remember { - viewModel.imageUrl( - series.id, - ImageType.BACKDROP, - ) - }, firstItemFocusRequester = firstItemFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index ecb06efb..fa225688 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -74,7 +74,6 @@ fun SeriesOverviewContent( chosenStreams: ChosenStreams?, peopleInEpisode: List, position: SeriesOverviewPosition, - backdropImageUrl: String?, firstItemFocusRequester: FocusRequester, episodeRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 6f4ea0dd..d1e2baa2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO @@ -37,6 +38,7 @@ class PreferencesViewModel private val api: ApiClient, val preferenceDataStore: DataStore, val navigationManager: NavigationManager, + val backdropService: BackdropService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val navDrawerItemRepository: NavDrawerItemRepository, From 1855c4666b389962100eb0696bb3d01f54e6f99a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:28:22 -0500 Subject: [PATCH 114/124] Always show video as Dolby Vision if it has DoVi title (#502) ## Description Always show the "Dolby Vision" label instead of just HDR if the video has a Dolby Vision title. This matches the behavior of the official app. ### Related issues Fixes #491 --- .../ui/components/VideoStreamDetails.kt | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 0aff93fc..a557d7f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.audioStreamCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount @@ -84,7 +85,7 @@ fun VideoStreamDetails( } else { null } - val range = formatVideoRange(context, it.videoRange, it.videoRangeType) + val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) listOfNotNull( resName.concatWithSpace(range), it.codec?.uppercase(), @@ -255,6 +256,7 @@ fun formatVideoRange( context: Context, videoRange: VideoRange?, type: VideoRangeType?, + doviTitle: String?, ): String? = when (videoRange) { VideoRange.UNKNOWN, @@ -264,23 +266,27 @@ fun formatVideoRange( } VideoRange.HDR -> { - when (type) { - VideoRangeType.UNKNOWN, - VideoRangeType.SDR, - null, - -> null + if (doviTitle.isNotNullOrBlank()) { + context.getString(R.string.dolby_vision) + } else { + when (type) { + VideoRangeType.UNKNOWN, + VideoRangeType.SDR, + null, + -> null - VideoRangeType.HDR10 -> "HDR10" + VideoRangeType.HDR10 -> "HDR10" - VideoRangeType.HDR10_PLUS -> "HDR10+" + VideoRangeType.HDR10_PLUS -> "HDR10+" - VideoRangeType.HLG -> "HLG" + VideoRangeType.HLG -> "HLG" - VideoRangeType.DOVI, - VideoRangeType.DOVI_WITH_HDR10, - VideoRangeType.DOVI_WITH_HLG, - VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) + VideoRangeType.DOVI, + VideoRangeType.DOVI_WITH_HDR10, + VideoRangeType.DOVI_WITH_HLG, + VideoRangeType.DOVI_WITH_SDR, + -> context.getString(R.string.dolby_vision) + } } } } From fd260f2709c07e798e3813cd283f7f3d48169c8f Mon Sep 17 00:00:00 2001 From: Amar Sandhu Date: Thu, 18 Dec 2025 13:52:27 -0700 Subject: [PATCH 115/124] Add "Ends at" time display. (#475) Addresses #441 . --------- Co-authored-by: Damontecres --- .../wholphin/ui/components/MovieComponents.kt | 35 +++++++++++++++---- .../ui/components/SeriesComponents.kt | 22 +++++------- .../wholphin/ui/detail/PlaylistDetails.kt | 22 +++++++++--- app/src/main/res/values/strings.xml | 1 + 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt index 30e0a902..e25f50be 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt @@ -1,29 +1,32 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.tv.material3.MaterialTheme +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.timeRemaining +import com.github.damontecres.wholphin.ui.util.LocalClock import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.extensions.ticks +import java.time.LocalDateTime @Composable fun MovieQuickDetails( dto: BaseItemDto?, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val now = LocalClock.current.now val details = - remember(dto) { + remember(dto, now) { buildList { dto?.productionYear?.let { add(it.toString()) } - dto?.runTimeTicks?.ticks?.roundMinutes?.let { - add(it.toString()) - } - dto?.timeRemaining?.roundMinutes?.let { - add("$it left") - } + addRuntimeDetails(context, now, dto) dto?.officialRating?.let(::add) } } @@ -36,3 +39,21 @@ fun MovieQuickDetails( modifier = modifier, ) } + +fun MutableList.addRuntimeDetails( + context: Context, + now: LocalDateTime, + dto: BaseItemDto?, +) { + val runtime = dto?.runTimeTicks?.ticks + runtime?.let { duration -> + add(duration.roundMinutes.toString()) + } + dto?.timeRemaining?.roundMinutes?.let { + add("$it left") + } + (dto?.timeRemaining ?: runtime)?.let { remaining -> + val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) + add(context.getString(R.string.ends_at, endTimeStr)) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index bdb94ba5..de79bd5a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.MaterialTheme @@ -11,7 +12,7 @@ import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seriesProductionYears -import com.github.damontecres.wholphin.ui.timeRemaining +import com.github.damontecres.wholphin.ui.util.LocalClock import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.extensions.ticks @@ -57,17 +58,14 @@ fun EpisodeQuickDetails( dto: BaseItemDto?, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val now = LocalClock.current.now val details = - remember(dto) { + remember(dto, now) { buildList { dto?.seasonEpisode?.let(::add) dto?.premiereDate?.let { add(formatDateTime(it)) } - val duration = dto?.runTimeTicks?.ticks - duration - ?.roundMinutes - ?.toString() - ?.let(::add) - dto?.timeRemaining?.roundMinutes?.let { add("$it left") } + addRuntimeDetails(context, now, dto) dto?.officialRating?.let(::add) } } @@ -89,11 +87,9 @@ fun SeriesQuickDetails( remember(dto) { buildList { dto?.seriesProductionYears?.let(::add) - val duration = dto?.runTimeTicks?.ticks - duration - ?.roundMinutes - ?.toString() - ?.let(::add) + dto?.runTimeTicks?.ticks?.roundMinutes?.let { + add(it.toString()) + } dto?.officialRating?.let(::add) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index d27edfff..150228a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams @@ -68,6 +69,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler @@ -407,10 +409,22 @@ fun PlaylistItem( ) }, trailingContent = { - item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { - Text( - text = it.toString(), - ) + item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> + val now = LocalClock.current.now + val endTimeStr = + remember(item, now) { + val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) + TimeFormatter.format(endTime) + } + Column { + Text( + text = duration.toString(), + ) + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = MaterialTheme.typography.bodySmall, + ) + } } }, leadingContent = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d399631a..47122cca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,7 @@ Downloading… Enabled + Ends at %1$s Enter Server IP or URL Enter server address Episodes From c5214d886e3ce04c8d06f2dba332142e922c35ec Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:20:24 -0500 Subject: [PATCH 116/124] Fix backdrop not clearing on favorite tab changes (#507) ## Description Fixes the backdrop not being cleared when switching tabs on the favorites page by ensuring each has a unique view model. Also clears the backdrop if the view option to show details is turned off. ### Related issues #476 #499 --- .../wholphin/ui/components/CollectionFolderGrid.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index f22e87a2..7ff7fdcf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -205,6 +205,9 @@ class CollectionFolderViewModel this.viewOptions.value = viewOptions viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { saveLibraryDisplayInfo(viewOptions = viewOptions) + if (!viewOptions.showDetails) { + backdropService.clearBackdrop() + } } } @@ -514,7 +517,7 @@ fun CollectionFolderGrid( playEnabled: Boolean, defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, - viewModel: CollectionFolderViewModel = hiltViewModel(), + viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, From dda1c39dd2a8497186906469bfbbbfcf8897e1a2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:20:57 -0500 Subject: [PATCH 117/124] Add Amazon app store link --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7cf0c692..45c96f5a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin Get Wholphin on Google Play + +Get Wholphin on Amazon AppStore + +

From 0c823b91cd8d0b810ff602b837132305382cff06 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 18 Dec 2025 18:26:24 -0500 Subject: [PATCH 118/124] Release v0.3.7 From e60748a0d0ed483ecb1ae25fdce12f15b537025e Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Fri, 19 Dec 2025 09:42:34 -0600 Subject: [PATCH 119/124] [UI] Add playback overlay dark gradient background (#505) ## Description This PR adds a background dimming effect behind the playback controls to fix readability issues against bright video content. It implements a full-screen vertical gradient that ensures white text and buttons remain legible. I added a vertical gradient fading from transparent at the top to ~95% black at the bottom which adds contrast for UI elements without obscuring the center of the video. ### Related issues Solves [#485](https://github.com/damontecres/Wholphin/issues/485) ### Screenshots playback no_gradient ### AI/LLM usage This change was developed with the assistance of AI, but was not wholly generated by AI. --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Damontecres Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../wholphin/ui/components/TimeDisplay.kt | 1 + .../wholphin/ui/playback/PlaybackOverlay.kt | 30 +++++++++ .../wholphin/ui/playback/PlaybackPage.kt | 62 +++++++++---------- .../wholphin/ui/playback/SkipIndicator.kt | 1 + 4 files changed, 63 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index 79bd2f73..8fa6f60f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -17,6 +17,7 @@ fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { text = LocalClock.current.timeString, fontSize = 18.sp, color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, modifier = modifier .align(Alignment.TopEnd) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 2200b954..74821924 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -34,6 +35,8 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type @@ -127,10 +130,37 @@ fun PlaybackOverlay( var controllerHeight by remember { mutableStateOf(0.dp) } var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } + // Background scrim for OSD readability + val scrimBrush = + remember { + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.80f), + ), + ) + } + Box( modifier = modifier, contentAlignment = Alignment.BottomCenter, ) { + AnimatedVisibility( + visible = controllerViewState.controlsVisible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.matchParentSize(), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(scrimBrush), + ) + } + AnimatedVisibility( state == OverlayViewState.CONTROLLER, enter = slideInVertically() + fadeIn(), 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 c905e3f0..e27ea11f 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 @@ -332,37 +332,6 @@ fun PlaybackPage( } } - // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { - val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - AndroidView( - factory = { context -> - SubtitleView(context).apply { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) - } - } - }, - update = { - it.setCues(cues) - Media3SubtitleOverride( - preferences.appPreferences.interfacePreferences.subtitlesPreferences - .calculateEdgeSize(density), - ).apply(it) - }, - onReset = { - it.setCues(null) - }, - modifier = - Modifier - .fillMaxSize(maxSize) - .align(Alignment.TopCenter) - .background(Color.Transparent), - ) - } - // The playback controls AnimatedVisibility( controllerViewState.controlsVisible, @@ -402,6 +371,37 @@ fun PlaybackPage( showClock = preferences.appPreferences.interfacePreferences.showClock, ) } + + // Subtitles + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { + val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + AndroidView( + factory = { context -> + SubtitleView(context).apply { + preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { + setStyle(it.toSubtitleStyle()) + setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) + setBottomPaddingFraction(it.margin.toFloat() / 100f) + } + } + }, + update = { + it.setCues(cues) + Media3SubtitleOverride( + preferences.appPreferences.interfacePreferences.subtitlesPreferences + .calculateEdgeSize(density), + ).apply(it) + }, + onReset = { + it.setCues(null) + }, + modifier = + Modifier + .fillMaxSize(maxSize) + .align(Alignment.TopCenter) + .background(Color.Transparent), + ) + } } // Ask to skip intros, etc button diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt index 0a3e343d..3e417a91 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt @@ -61,6 +61,7 @@ fun SkipIndicator( modifier = Modifier.align(Alignment.Center), color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, + style = MaterialTheme.typography.bodySmall, text = abs(durationMs / 1000).toString(), ) } From 693398536e2bf7a86d797313264cfcea6adea050 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:46:57 -0500 Subject: [PATCH 120/124] Better images for genre grid (#501) ## Description Overhauls the genre cards for Movies & TV Show libraries. Now the cards use a semi-transparent, consistent, generated color using the same logic as in #478. And a random item from the library with that genre is show in the background. This is heavily inspired from Plex. If there are not applicable items for the genre, then just the colored card is shown. ### Related issues Fixes #490 --- .../wholphin/data/model/BaseItem.kt | 12 +- .../wholphin/services/ImageUrlService.kt | 2 +- .../wholphin/ui/cards/GenreCard.kt | 127 ++++++++++++++++++ .../wholphin/ui/components/GenreCardGrid.kt | 102 ++++++++++++-- .../wholphin/ui/detail/CardGrid.kt | 25 ++-- .../wholphin/ui/setup/ServerList.kt | 8 +- 6 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 7abdc890..e5cd53bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.data.model +import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears @@ -18,8 +20,14 @@ import kotlin.time.Duration data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, -) { - val id get() = data.id +) : CardGridItem { + override val id get() = data.id + + override val playable: Boolean + get() = type.playable + + override val sortName: String + get() = data.sortName ?: data.name ?: "" val type get() = data.type diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 821794e3..164035e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -31,7 +31,7 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, - ): String? = + ): String = when (imageType) { ImageType.BACKDROP, ImageType.LOGO, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt new file mode 100644 index 00000000..43d160e3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -0,0 +1,127 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.setup.rememberIdColor +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import timber.log.Timber +import java.util.UUID + +@Composable +fun GenreCard( + genre: Genre?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val background = rememberIdColor(genre?.id).copy(alpha = .6f) + Card( + modifier = + modifier, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + ) { + Timber.v("genre image=${genre?.imageUrl}") + if (genre?.imageUrl.isNotNullOrBlank()) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(genre.imageUrl) + .crossfade(true) + .build(), + contentScale = ContentScale.FillBounds, + contentDescription = null, + modifier = + Modifier + .alpha(.6f) + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize(), + ) + } + Box( + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .background(background), + ) { + Text( + text = genre?.name ?: "", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(16.dp) + .align(Alignment.Center), + ) + } + } + } +} + +@PreviewTvSpec +@Composable +private fun GenreCardPreview() { + WholphinTheme { + val genre = + Genre( + UUID.randomUUID(), + "Adventure", + null, + Color.Black, + ) + GenreCard( + genre = genre, + onClick = {}, + onLongClick = {}, + modifier = Modifier.width(180.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 33900e2f..f4fea70c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -10,30 +10,43 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @HiltViewModel @@ -41,11 +54,12 @@ class GenreViewModel @Inject constructor( private val api: ApiClient, + private val imageUrlService: ImageUrlService, val navigationManager: NavigationManager, ) : ViewModel() { private lateinit var itemId: UUID val loading = MutableLiveData(LoadingState.Pending) - val genres = MutableLiveData>(listOf()) + val genres = MutableLiveData>(listOf()) fun init(itemId: UUID) { loading.value = LoadingState.Loading @@ -56,11 +70,69 @@ class GenreViewModel parentId = itemId, fields = SlimItemFields, ) - val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init() + val genres = + GetGenresRequestHandler + .execute(api, request) + .content.items + .map { + Genre(it.id, it.name ?: "", null, Color.Black) + } +// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init() withContext(Dispatchers.Main) { - genres.value = pager + this@GenreViewModel.genres.value = genres loading.value = LoadingState.Success } +// val excludeItemIds = mutableSetOf() + val genreToUrl = ConcurrentHashMap() + val semaphore = Semaphore(4) + genres + .map { genre -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + val item = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( +// excludeItemIds = excludeItemIds, + parentId = itemId, + recursive = true, + limit = 1, + sortBy = listOf(ItemSortBy.RANDOM), + fields = listOf(ItemFields.GENRES), + imageTypes = listOf(ImageType.THUMB), + imageTypeLimit = 1, + includeItemTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + ), + genreIds = listOf(genre.id), + enableTotalRecordCount = false, + ), + ).content.items + .firstOrNull() + if (item != null) { +// excludeItemIds.add(item.id) + genreToUrl[genre.id] = + imageUrlService.getItemImageUrl( + item.id, + item.type, + null, + false, + ImageType.THUMB, + ) + } + } + } + }.awaitAll() + val genresWithImages = + genres.map { + it.copy( + imageUrl = genreToUrl[it.id], + ) + } + this@GenreViewModel.genres.setValueOnMain(genresWithImages) } } @@ -78,6 +150,16 @@ class GenreViewModel } } +data class Genre( + override val id: UUID, + val name: String, + val imageUrl: String?, + val color: Color, +) : CardGridItem { + override val playable: Boolean = false + override val sortName: String get() = name +} + @Composable fun GenreCardGrid( itemId: UUID, @@ -126,13 +208,13 @@ fun GenreCardGrid( initialPosition = 0, positionCallback = { columns, position -> }, - cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> - GridCard( - item = item, + columns = 4, + cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + GenreCard( + genre = item, onClick = onClick, onLongClick = onLongClick, modifier = mod, - imageAspectRatio = 1f, ) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index eeb16031..ba74641a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -67,23 +67,29 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.playback.isBackwardButton import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp -import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import java.util.UUID private const val DEBUG = false +interface CardGridItem { + val id: UUID + val playable: Boolean + val sortName: String +} + @OptIn(ExperimentalFoundationApi::class) @Composable -fun CardGrid( - pager: List, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onClickPlay: (Int, BaseItem) -> Unit, +fun CardGrid( + pager: List, + onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, + onClickPlay: (Int, T) -> Unit, letterPosition: suspend (Char) -> Int, gridFocusRequester: FocusRequester, showJumpButtons: Boolean, @@ -92,13 +98,13 @@ fun CardGrid( initialPosition: Int = 0, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, cardContent: @Composable ( - item: BaseItem?, + item: T?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier, ) -> Unit = { item, onClick, onLongClick, mod -> GridCard( - item = item, + item = item as BaseItem?, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, @@ -220,7 +226,7 @@ fun CardGrid( return@onKeyEvent true } else if (isPlayKeyUp(it)) { val item = pager.getOrNull(focusedIndex) - if (item?.type?.playable == true) { + if (item?.playable == true) { Timber.v("Clicked play on ${item.id}") onClickPlay.invoke(focusedIndex, item) } @@ -351,7 +357,6 @@ fun CardGrid( remember(focusedIndex) { pager .getOrNull(focusedIndex) - ?.data ?.sortName ?.first() ?.uppercaseChar() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 4ab990de..0d7d3c6e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -179,8 +179,14 @@ fun ServerList( * Generate a consistent color for a UUID */ @Composable -fun rememberIdColor(id: UUID): Color = +fun rememberIdColor( + id: UUID?, + nullColor: Color = MaterialTheme.colorScheme.surfaceVariant, +): Color = remember(id) { + if (id == null) { + return@remember nullColor + } // Generate a color based on the server ID hash, fallback to URL hash val hash = id.hashCode() val hue = (hash % 360).toFloat() From 5c5db4c1ef66ac1aad1c05ef60071b7c58aaa63b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:54:41 -0500 Subject: [PATCH 121/124] TV Guide title clean up & align start to half hours (#517) ## Description - Cleans up program titles & subtitles by removing newline characters - Align the start of the guide to half hour, rounded down, ie 9:52->9:30 or 11:05->11:00 - Add some visual indication that a program has started before the guide start time - This is done by adding a `<` prefix to the title and using sharp 90 degree corners at the start instead of rounded ### Related issues Closes #434 Closes #435 Closes #436 --- .../wholphin/ui/detail/livetv/Components.kt | 32 +++++++++++++-- .../ui/detail/livetv/LiveTvViewModel.kt | 40 ++++++++++++++----- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 33 ++++++++------- 3 files changed, 79 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index 41b7b114..18d193ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -20,9 +21,11 @@ import androidx.tv.material3.Text import androidx.tv.material3.contentColorFor import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage +import java.time.LocalDateTime @Composable fun Program( + guideStart: LocalDateTime, program: TvProgram, focused: Boolean, colorCode: Boolean, @@ -38,14 +41,37 @@ fun Program( MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) } val textColor = MaterialTheme.colorScheme.contentColorFor(background) + val startedBeforeGuide = program.start.isBefore(guideStart) + val shape = + remember(startedBeforeGuide) { + val cornerSize = 4.dp + if (startedBeforeGuide) { + RoundedCornerShape( + topEnd = cornerSize, + bottomEnd = cornerSize, + topStart = 0.dp, + bottomStart = 0.dp, + ) + } else { + RoundedCornerShape(cornerSize) + } + } + val title = + remember(startedBeforeGuide) { + if (startedBeforeGuide) { + "< " + } else { + "" + } + (program.name ?: program.id.toString()) + } Box( modifier = modifier .padding(2.dp) .fillMaxSize() .background( - background, - shape = RoundedCornerShape(4.dp), + color = background, + shape = shape, ), ) { Column( @@ -56,7 +82,7 @@ fun Program( .padding(4.dp), ) { Text( - text = program.name ?: program.id.toString(), + text = title, color = textColor, fontSize = 16.sp, maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index b9c74ce6..621dd7da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -74,11 +74,10 @@ class LiveTvViewModel ) : ViewModel() { val loading = MutableLiveData(LoadingState.Pending) - lateinit var guideStart: LocalDateTime - private set private lateinit var channelsIdToIndex: Map private val mutex = Mutex() + val guideTimes = MutableLiveData>(buildGuideTimes()) val channels = MutableLiveData>() val channelProgramCount = mutableMapOf() val programs = MutableLiveData() @@ -106,7 +105,7 @@ class LiveTvViewModel } fun init() { - guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + val guideStart = guideTimes.value!!.first() viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( @@ -153,7 +152,7 @@ class LiveTvViewModel // Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more // This makes the guide appear faster, and load more usable data in the background val initial = 10 - fetchPrograms(channels, 0.. initial) { - fetchPrograms(channels, 0.., range: IntRange, ) { loading.setValueOnMain(LoadingState.Loading) - fetchPrograms(channels, range) + val guideStart = guideTimes.value!!.first() + fetchPrograms(guideStart, channels, range) loading.setValueOnMain(LoadingState.Success) } private suspend fun fetchPrograms( + guideStart: LocalDateTime, channels: List, range: IntRange, ) = mutex.withLock { @@ -216,7 +229,11 @@ class LiveTvViewModel } else { null } - + val name = (dto.seriesName ?: dto.name)?.replace(Regex("[\n\r]"), "") + val subtitle = + dto.episodeTitle + .takeIf { dto.isSeries ?: false } + ?.replace(Regex("[\n\r]"), "") val p = TvProgram( id = dto.id, @@ -230,8 +247,8 @@ class LiveTvViewModel ).coerceAtLeast(0f), endHours = hoursBetween(guideStart, dto.endDate!!), duration = dto.runTimeTicks!!.ticks, - name = dto.seriesName ?: dto.name, - subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, + name = name, + subtitle = subtitle, overview = dto.overview, officialRating = dto.officialRating, seasonEpisode = @@ -573,3 +590,8 @@ data class FetchedPrograms( val programs: List, val programsByChannel: Map>, ) + +fun LocalDateTime.roundDownToHalfHour(): LocalDateTime { + val min = minute % 30L + return minusMinutes(min).truncatedTo(ChronoUnit.MINUTES) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 8fee8995..68c62063 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -105,6 +105,7 @@ fun TvGuideGrid( LoadingState.Success, -> { val context = LocalContext.current + val guideTimes by viewModel.guideTimes.observeAsState(listOf()) val fetchedItem by viewModel.fetchedItem.observeAsState(null) val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) var showItemDialog by remember { mutableStateOf(null) } @@ -156,7 +157,7 @@ fun TvGuideGrid( channels = channels, programs = programs, channelProgramCount = viewModel.channelProgramCount, - start = viewModel.guideStart, + guideTimes = guideTimes, onClickChannel = { index, channel -> viewModel.navigationManager.navigateTo( Destination.Playback( @@ -273,7 +274,7 @@ fun TvGuideGridContent( channels: List, programs: FetchedPrograms, channelProgramCount: Map, - start: LocalDateTime, + guideTimes: List, onClickChannel: (Int, TvChannel) -> Unit, onClickProgram: (Int, TvProgram) -> Unit, onFocus: (RowColumn) -> Unit, @@ -284,6 +285,7 @@ fun TvGuideGridContent( val focusManager = LocalFocusManager.current val state = rememberSaveableProgramGuideState() val scope = rememberCoroutineScope() + val guideStart = guideTimes.first() var focusedItem by rememberPosition(RowColumn(0, 0)) val focusedChannelIndex = focusedItem.row @@ -506,7 +508,7 @@ fun TvGuideGridContent( currentTime( layoutInfo = { ProgramGuideItem.CurrentTime( - hoursBetween(start, LocalDateTime.now()), + hoursBetween(guideStart, LocalDateTime.now()), ) }, ) { @@ -523,11 +525,18 @@ fun TvGuideGridContent( } } timeline( - count = MAX_HOURS.toInt(), + count = guideTimes.size, layoutInfo = { index -> + val start = guideTimes[index] + val end = + if (index < guideTimes.lastIndex) { + guideTimes[index + 1] + } else { + start.plusHours(1) + } ProgramGuideItem.Timeline( - startHour = index.toFloat(), - endHour = index + 1f, + startHour = hoursBetween(guideStart, start), + endHour = hoursBetween(guideStart, end), ) }, ) { index -> @@ -545,16 +554,12 @@ fun TvGuideGridContent( shape = RoundedCornerShape(4.dp), ), ) { - val differentDay = - start.toLocalDate() != - start - .plusHours(index.toLong()) - .toLocalDate() + val guideTime = guideTimes[index] + val differentDay = guideTime.toLocalDate() != guideStart.toLocalDate() val time = DateUtils.formatDateTime( context, - start - .plusHours(index.toLong()) + guideTime .toInstant(OffsetDateTime.now().offset) .epochSecond * 1000, DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, @@ -584,7 +589,7 @@ fun TvGuideGridContent( val program = programs.programs[programIndex] val focused = gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex - Program(program, focused, preferences.colorCodePrograms, Modifier) + Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier) } channels( From 13d70e76238b37c34052d7b737dffb1e2379b0d1 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:39:48 -0500 Subject: [PATCH 122/124] Rework internals of ApiRequestPager (#518) ## Description Changes how `init()` works for `ApiRequestPager` so that it 1) always forces updating the total count if needed and 2) propagates errors during the initial fetch up to the original caller This means if an error occurs during the initialization of the pager data, it won't be quietly logged and ignored. Instead, ViewModel code will catch it and display an error in the UI. ### Related issues Should fix #492 --- .../ui/components/CollectionFolderGrid.kt | 27 +++++++--- .../wholphin/util/ApiRequestPager.kt | 54 ++++++++++--------- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 7ff7fdcf..f52f3970 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -252,13 +252,26 @@ class CollectionFolderViewModel this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection this@CollectionFolderViewModel.filter.value = filter } - val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary) - newPager.init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - pager.value = newPager - loading.value = LoadingState.Success - backgroundLoading.value = LoadingState.Success + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + pager.value = newPager + loading.value = LoadingState.Success + backgroundLoading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + withContext(Dispatchers.Main) { + loading.value = LoadingState.Error(ex) + pager.value = listOf() + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 0ed74fe0..b03ce9f9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -66,7 +66,7 @@ class ApiRequestPager( suspend fun init(initialPosition: Int = 0): ApiRequestPager { if (totalCount < 0) { - fetchPage(initialPosition, true).join() + fetchPageBlocking(initialPosition, true) } return this } @@ -75,7 +75,7 @@ class ApiRequestPager( if (index in 0..( if (index in 0..( override val size: Int get() = totalCount - private fun fetchPage( + private fun fetchPage(position: Int): Job = + scope.launch(ExceptionHandler() + Dispatchers.IO) { + fetchPageBlocking(position, false) + } + + private suspend fun fetchPageBlocking( position: Int, setTotalCount: Boolean, - ): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { - mutex.withLock { - val pageNumber = position / pageSize - if (cachedPages.getIfPresent(pageNumber) == null) { - if (DEBUG) Timber.v("fetchPage: $pageNumber") - val newRequest = - requestHandler.prepare( - request, - pageNumber * pageSize, - pageSize, - setTotalCount, - ) - val result = requestHandler.execute(api, newRequest).content - if (setTotalCount) { - totalCount = result.totalRecordCount - } - val data = mutableListOf() - result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } - cachedPages.put(pageNumber, data) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) + ) { + mutex.withLock { + val pageNumber = position / pageSize + if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { + if (DEBUG) Timber.v("fetchPage: $pageNumber") + val newRequest = + requestHandler.prepare( + request, + pageNumber * pageSize, + pageSize, + setTotalCount, + ) + val result = requestHandler.execute(api, newRequest).content + if (setTotalCount) { + totalCount = result.totalRecordCount.coerceAtLeast(0) } + val data = mutableListOf() + result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } + cachedPages.put(pageNumber, data) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) } } + } suspend fun refreshItem( position: Int, From a65b93e0645640b4268fb7e643208b30b5f7d651 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:39:59 -0500 Subject: [PATCH 123/124] Enforce a minimum aspect ratio (#519) ## Description When using a server provided aspect, force a minimum value (.666) in case the server returns unusable data like zero. Note: I have not been able to reproduce the errors in #451 to verify that this PR will fix it. And it's not clear to me why #453 did not fix it either. However, this PR shouldn't impact anything if the server is sending valid aspect ratios and should at least prevent the crash on the UI side. ### Related issues Follow up to #453 Hopefully fixes #451 --- .../java/com/github/damontecres/wholphin/ui/UiConstants.kt | 2 ++ .../github/damontecres/wholphin/ui/cards/EpisodeCard.kt | 2 +- .../com/github/damontecres/wholphin/ui/cards/GridCard.kt | 2 +- .../com/github/damontecres/wholphin/ui/cards/SeasonCard.kt | 7 ++++--- .../damontecres/wholphin/ui/playback/NextUpEpisode.kt | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 9557de96..beafccb2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -76,6 +76,8 @@ object AspectRatios { const val FOUR_THREE = 4f / 3f const val TALL = 2f / 3f const val SQUARE = 1f + + const val MIN = TALL } enum class AspectRatio( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index d6c88633..8cc3ad12 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -64,7 +64,7 @@ fun EpisodeCard( } else { focusedAfterDelay = false } - val aspectRatio = item?.aspectRatio ?: AspectRatios.TALL + val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN val width = imageHeight * aspectRatio val height = imageWidth * (1f / aspectRatio) Column( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 218a64d2..1bc35a08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -96,7 +96,7 @@ fun GridCard( modifier = Modifier .fillMaxWidth() - .aspectRatio(imageAspectRatio) + .aspectRatio(imageAspectRatio.coerceAtLeast(AspectRatios.MIN)) .background(MaterialTheme.colorScheme.surfaceVariant), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 54041fff..243bb6d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -139,8 +139,9 @@ fun SeasonCard( } else { focusedAfterDelay = false } - val width = imageHeight * aspectRatio - val height = imageWidth * (1f / aspectRatio) + val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) + val width = imageHeight * aspectRationToUse + val height = imageWidth * (1f / aspectRationToUse) Column( verticalArrangement = Arrangement.spacedBy(spaceBetween), modifier = modifier.size(width, height), @@ -149,7 +150,7 @@ fun SeasonCard( modifier = Modifier .size(imageWidth, imageHeight) - .aspectRatio(aspectRatio), + .aspectRatio(aspectRationToUse), onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt index 44048a40..f032c8b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt @@ -98,7 +98,7 @@ fun NextUpEpisode( modifier = Modifier .fillMaxHeight() - .aspectRatio(aspectRatio) + .aspectRatio(aspectRatio.coerceAtLeast(AspectRatios.MIN)) // .fillMaxSize() // .weight(1f) .focusRequester(focusRequester), From 11dda19b8acdaa64298c2c573dc145ac7970ce34 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:47:28 -0500 Subject: [PATCH 124/124] TV Guide UI followup to #517 (#521) ## Description - Instead of just replacing newline characters, collapse white-space into single space characters which is roughly how the web UI does it with CSS's `white-space: nowrap;` - Use an actual arrow indicator instead of a `<` character for when a program begins before the guide start ### Related issues #517 https://github.com/damontecres/Wholphin/issues/434 https://github.com/damontecres/Wholphin/issues/435 https://github.com/damontecres/Wholphin/issues/436 ### Screenshots image --- .../wholphin/ui/detail/livetv/Components.kt | 84 +++++++++++-------- .../ui/detail/livetv/LiveTvViewModel.kt | 5 +- app/src/main/res/values/fa_strings.xml | 2 + 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index 18d193ee..c27cde4e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -21,6 +22,8 @@ import androidx.tv.material3.Text import androidx.tv.material3.contentColorFor import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome import java.time.LocalDateTime @Composable @@ -56,14 +59,7 @@ fun Program( RoundedCornerShape(cornerSize) } } - val title = - remember(startedBeforeGuide) { - if (startedBeforeGuide) { - "< " - } else { - "" - } + (program.name ?: program.id.toString()) - } + val title = program.name ?: program.id.toString() Box( modifier = modifier @@ -74,35 +70,51 @@ fun Program( shape = shape, ), ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .fillMaxSize() - .padding(4.dp), + Row( + modifier = Modifier.fillMaxSize(), ) { - Text( - text = title, - color = textColor, - fontSize = 16.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) - listOfNotNull( - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, - program.subtitle, - ).joinToString(" - ") - .ifBlank { null } - ?.let { - Text( - text = it, - color = textColor, - fontSize = 14.sp, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) - } + if (startedBeforeGuide) { + Text( + text = stringResource(R.string.fa_caret_left), + fontFamily = FontAwesome, + color = textColor, + fontSize = 16.sp, + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(start = 2.dp), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .fillMaxSize() + .padding(start = 2.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + ) { + Text( + text = title, + color = textColor, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + listOfNotNull( + program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + program.subtitle, + ).joinToString(" - ") + .ifBlank { null } + ?.let { + Text( + text = it, + color = textColor, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } + } } RecordingMarker( isRecording = program.isRecording, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 621dd7da..9bce1dfe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -229,11 +229,12 @@ class LiveTvViewModel } else { null } - val name = (dto.seriesName ?: dto.name)?.replace(Regex("[\n\r]"), "") + // Clean up name/subtitles by collapsing whitespace (including newlines) into single spaces + val name = (dto.seriesName ?: dto.name)?.replace(Regex("\\s+"), " ") val subtitle = dto.episodeTitle .takeIf { dto.isSeries ?: false } - ?.replace(Regex("[\n\r]"), "") + ?.replace(Regex("\\s+"), " ") val p = TvProgram( id = dto.id, diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index e027f30c..59814e43 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -14,6 +14,8 @@ + +