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
-
-
+
+
+
+
+
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

---
.../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
+
+
+
+
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
### 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
---
.../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 @@
+
+