From 903c45dfb8a370d20ba36b339081fdd5da557c6f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 24 May 2026 09:55:08 -0400 Subject: [PATCH] Support configuring interface language per user (#1406) ## Description Adds a setting to configure the app's language/locale. It is configured per user. This settings has no impact on audio or subtitles language choices. Only languages Wholphin has translations for can be used. If you would like to contribute new translations, check out https://translate.codeberg.org/projects/wholphin/! Dev notes: This doesn't exactly follow Android's recommendations because the [guidelines are for per-app](https://developer.android.com/guide/topics/resources/app-languages) and not users within the app. ### Related issues Closes #513 Closes #669 ### Testing - Emulator API 34 - Nvidia shield API 30 - Fire TV OS 7 API 28 ## Screenshots language Large ## AI or LLM usage None --- app/build.gradle.kts | 3 + .../33.json | 664 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 9 + .../damontecres/wholphin/data/AppDatabase.kt | 4 +- .../damontecres/wholphin/data/ExtrasItem.kt | 4 +- .../wholphin/data/model/JellyfinServer.kt | 1 + .../wholphin/preferences/AppPreference.kt | 8 + .../wholphin/services/HomeSettingsService.kt | 133 ++-- .../services/SeerrServerRepository.kt | 95 +-- .../wholphin/services/UserSwitchListener.kt | 110 +++ .../wholphin/ui/components/ContextMenu.kt | 100 +-- .../wholphin/ui/components/Dialogs.kt | 12 +- .../wholphin/ui/components/ItemGrid.kt | 3 +- .../wholphin/ui/components/QuickDetails.kt | 7 +- .../ui/components/RecommendedContent.kt | 11 +- .../ui/components/VideoStreamDetails.kt | 18 +- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 132 ++-- .../wholphin/ui/detail/DetailUtils.kt | 18 +- .../wholphin/ui/detail/HomeRowGrid.kt | 21 +- .../wholphin/ui/detail/PersonPage.kt | 3 +- .../detail/collection/CollectionViewModel.kt | 3 +- .../detail/discover/DiscoverMovieDetails.kt | 5 +- .../detail/discover/DiscoverSeriesDetails.kt | 20 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 3 +- .../ui/detail/series/SeriesDetails.kt | 21 +- .../wholphin/ui/discover/DiscoverRow.kt | 4 +- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 22 +- .../damontecres/wholphin/ui/main/HomePage.kt | 8 +- .../wholphin/ui/main/HomeViewModel.kt | 5 +- .../ui/main/settings/HomeSettingsPage.kt | 2 +- .../ui/main/settings/HomeSettingsRowList.kt | 2 +- .../ui/main/settings/HomeSettingsViewModel.kt | 43 +- .../wholphin/ui/nav/Destination.kt | 7 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 5 +- .../wholphin/ui/playback/SimpleMediaStream.kt | 6 +- .../ui/playback/SubtitleSearchUtils.kt | 2 +- .../ui/preferences/LocaleChoiceDialog.kt | 195 +++++ .../ui/preferences/PreferencesContent.kt | 70 +- .../wholphin/ui/util/StreamFormatting.kt | 32 +- .../wholphin/ui/util/StringProvider.kt | 95 +++ .../damontecres/wholphin/util/LoadingState.kt | 11 +- app/src/main/res/resources.properties | 1 + app/src/main/res/values/strings.xml | 1 + 43 files changed, 1501 insertions(+), 418 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt create mode 100644 app/src/main/res/resources.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7df2fb5..42603452 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -184,6 +184,9 @@ configure { lint { disable.add("MissingTranslation") } + androidResources { + generateLocaleConfig = true + } } androidComponents { diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json new file mode 100644 index 00000000..c8d73321 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json @@ -0,0 +1,664 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "a65eec85c027fac13948a0dd6f99d3cb", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` 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" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "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, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` 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" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "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, `order` INTEGER NOT NULL DEFAULT -1, 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 + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "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": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "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" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "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, 'a65eec85c027fac13948a0dd6f99d3cb')" + ] + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 51377cb8..64e7493c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -84,6 +84,15 @@ + + + + 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 cfd8c41e..69f2e745 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 @@ -46,9 +46,8 @@ import java.util.UUID ItemTrackModification::class, SeerrServer::class, SeerrUser::class, - ], - version = 32, + version = 33, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -64,6 +63,7 @@ import java.util.UUID AutoMigration(20, 30), AutoMigration(30, 31), AutoMigration(31, 32), + AutoMigration(32, 33), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index c81afacc..ff568a37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.GetItemsRequestHandler import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.ExtraType @@ -41,8 +42,7 @@ sealed interface ExtrasItem { ) : ExtrasItem { override val destination: Destination = Destination.ItemGrid( - title = null, - titleRes = type.stringRes, + title = ResStringProvider(type.stringRes), request = GetItemsRequest( ids = items.map { it.id }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index be966fc4..a357de4a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -64,6 +64,7 @@ data class JellyfinUser( @ColumnInfo(defaultValue = "false") val requireLogin: Boolean = false, val lastUsed: ZonedDateTime? = null, + val uiLanguage: String? = null, ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() 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 228b31c2..6c92358c 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 @@ -704,6 +704,13 @@ sealed interface AppPreference { summary = R.string.customize_home_summary, ) + val UserInterfaceLanguage = + AppClickablePreference( + title = R.string.user_interface_language, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + val SendCrashReports = AppSwitchPreference( title = R.string.send_crash_reports, @@ -1110,6 +1117,7 @@ val basicPreferences = AppPreference.ProtectProfilePreference, AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, + AppPreference.UserInterfaceLanguage, ), ), PreferenceGroup( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index d460b9e4..c40a1057 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem @@ -19,6 +20,11 @@ import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions import com.github.damontecres.wholphin.ui.playback.getTypeFor import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -275,11 +281,11 @@ class HomeSettingsService libraries .mapIndexed { index, it -> val parentId = it.itemId - val title = getRecentlyAddedTitle(context, it) + val title = getRecentlyAddedTitle(it) if (it.collectionType == CollectionType.LIVETV) { HomeRowConfigDisplay( id = index, - title = context.getString(R.string.live_tv), + title = ResStringProvider(R.string.live_tv), config = HomeRowConfig.TvPrograms(), ) } else { @@ -295,7 +301,7 @@ class HomeSettingsService listOf( HomeRowConfigDisplay( id = includedIds.size + 1, - title = context.getString(R.string.combine_continue_next), + title = ResStringProvider(R.string.combine_continue_next), config = HomeRowConfig.ContinueWatchingCombined(), ), ) @@ -303,12 +309,12 @@ class HomeSettingsService listOf( HomeRowConfigDisplay( id = includedIds.size + 1, - title = context.getString(R.string.continue_watching), + title = ResStringProvider(R.string.continue_watching), config = HomeRowConfig.ContinueWatching(), ), HomeRowConfigDisplay( id = includedIds.size + 2, - title = context.getString(R.string.next_up), + title = ResStringProvider(R.string.next_up), config = HomeRowConfig.NextUp(), ), ) @@ -355,7 +361,7 @@ class HomeSettingsService HomeSectionType.ACTIVE_RECORDINGS -> { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.active_recordings), + title = ResStringProvider(R.string.active_recordings), config = HomeRowConfig.Recordings(), ) } @@ -363,7 +369,7 @@ class HomeSettingsService HomeSectionType.RESUME -> { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.continue_watching), + title = ResStringProvider(R.string.continue_watching), config = HomeRowConfig.ContinueWatching(), ) } @@ -371,7 +377,7 @@ class HomeSettingsService HomeSectionType.NEXT_UP -> { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.next_up), + title = ResStringProvider(R.string.next_up), config = HomeRowConfig.NextUp(), ) } @@ -380,7 +386,7 @@ class HomeSettingsService if (userDto.tvAccess) { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.live_tv), + title = ResStringProvider(R.string.live_tv), config = HomeRowConfig.TvPrograms(), ) } else { @@ -413,7 +419,7 @@ class HomeSettingsService HomeRowConfigDisplay( id = id++, title = - context.getString( + ResArgStringProvider( R.string.recently_added_in, it.name ?: "", ), @@ -441,7 +447,7 @@ class HomeSettingsService ): HomeRowConfigDisplay = when (config) { is HomeRowConfig.ByParent -> { - val name = getItemName(config.parentId) ?: "" + val name = getItemName(null, config.parentId) HomeRowConfigDisplay( id, name, @@ -452,7 +458,7 @@ class HomeSettingsService is HomeRowConfig.ContinueWatching -> { HomeRowConfigDisplay( id, - context.getString(R.string.continue_watching), + ResStringProvider(R.string.continue_watching), config, ) } @@ -460,64 +466,64 @@ class HomeSettingsService is HomeRowConfig.ContinueWatchingCombined -> { HomeRowConfigDisplay( id, - context.getString(R.string.combine_continue_next), + ResStringProvider(R.string.combine_continue_next), config, ) } is HomeRowConfig.Genres -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.genres_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.genres_in, name), + title, config, ) } is HomeRowConfig.Studios -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.studios_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.studios_in, name), + title, config, ) } is HomeRowConfig.GetItems -> { - HomeRowConfigDisplay(id, config.name, config) + HomeRowConfigDisplay(id, StringStringProvider(config.name), config) } is HomeRowConfig.NextUp -> { HomeRowConfigDisplay( id, - context.getString(R.string.next_up), + ResStringProvider(R.string.next_up), config, ) } is HomeRowConfig.RecentlyAdded -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.recently_added_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.recently_added_in, name), + title, config, ) } is HomeRowConfig.RecentlyReleased -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.recently_released_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.recently_released_in, name), + title, config, ) } is HomeRowConfig.Favorite -> { val name = - context.getString( + ResProviderStringProvider( R.string.favorite_items, - context.getString(favoriteOptions[config.kind]!!), + ResStringProvider(favoriteOptions[config.kind]!!), ) HomeRowConfigDisplay(id, name, config) } @@ -525,7 +531,7 @@ class HomeSettingsService is HomeRowConfig.Recordings -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.active_recordings), + title = ResStringProvider(R.string.active_recordings), config, ) } @@ -533,7 +539,7 @@ class HomeSettingsService is HomeRowConfig.TvPrograms -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.live_tv), + title = ResStringProvider(R.string.live_tv), config, ) } @@ -541,29 +547,40 @@ class HomeSettingsService is HomeRowConfig.TvChannels -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.channels), + title = ResStringProvider(R.string.channels), config, ) } is HomeRowConfig.Suggestions -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.suggestions_for, config.parentId) HomeRowConfigDisplay( id = id, - title = context.getString(R.string.suggestions_for, name), + title = title, config, ) } } - private suspend fun getItemName(itemId: UUID): String? = + private suspend fun getItemName( + @StringRes stringRes: Int?, + itemId: UUID, + default: StringProvider = StringStringProvider(""), + ): StringProvider = try { api.userLibraryApi .getItem(itemId = itemId) .content.name + ?.let { + if (stringRes == null) { + StringStringProvider(it) + } else { + ResArgStringProvider(stringRes, it) + } + } ?: default } catch (ex: Exception) { Timber.e(ex, "Could not get name for %s", itemId) - context.getString(R.string.unknown) + ResStringProvider(R.string.unknown) } /** @@ -590,7 +607,7 @@ class HomeSettingsService ) Success( - title = context.getString(R.string.continue_watching), + title = ResStringProvider(R.string.continue_watching), items = resume, viewOptions = row.viewOptions, rowType = row, @@ -609,7 +626,7 @@ class HomeSettingsService ) Success( - title = context.getString(R.string.next_up), + title = ResStringProvider(R.string.next_up), items = nextUp, viewOptions = row.viewOptions, rowType = row, @@ -635,7 +652,7 @@ class HomeSettingsService ) Success( - title = context.getString(R.string.continue_watching), + title = ResStringProvider(R.string.continue_watching), items = latestNextUpService .buildCombined( @@ -676,8 +693,8 @@ class HomeSettingsService .firstOrNull { it.itemId == row.parentId } val title = - library?.name?.let { context.getString(R.string.genres_in, it) } - ?: context.getString(R.string.genres) + library?.name?.let { ResArgStringProvider(R.string.genres_in, it) } + ?: ResStringProvider(R.string.genres) val genres = items.map { BaseItem( @@ -723,8 +740,8 @@ class HomeSettingsService libraries .firstOrNull { it.itemId == row.parentId } val title = - library?.name?.let { context.getString(R.string.studios_in, it) } - ?: context.getString(R.string.studios) + library?.name?.let { ResArgStringProvider(R.string.studios_in, it) } + ?: ResStringProvider(R.string.studios) val studios = items.map { val imageUrl = @@ -762,7 +779,7 @@ class HomeSettingsService val library = libraries .firstOrNull { it.itemId == row.parentId } - val title = getRecentlyAddedTitle(context, library) + val title = getRecentlyAddedTitle(library) val request = GetLatestMediaRequest( fields = SlimItemFields, @@ -795,8 +812,8 @@ class HomeSettingsService ?.name val title = name?.let { - context.getString(R.string.recently_released_in, it) - } ?: context.getString(R.string.recently_released) + ResArgStringProvider(R.string.recently_released_in, it) + } ?: ResStringProvider(R.string.recently_released) val request = GetItemsRequest( parentId = row.parentId, @@ -853,7 +870,8 @@ class HomeSettingsService fields = DefaultItemFields, ) - val name = getItemName(row.parentId) + val title = + getItemName(null, row.parentId, ResStringProvider(R.string.collection)) if (usePaging) { ApiRequestPager( api, @@ -869,7 +887,7 @@ class HomeSettingsService .map { BaseItem(it, row.viewOptions.useSeries) } }.let { Success( - name ?: context.getString(R.string.collection), + title, it, row.viewOptions, rowType = row, @@ -906,7 +924,7 @@ class HomeSettingsService .map { BaseItem(it, row.viewOptions.useSeries) } }.let { Success( - row.name, + StringStringProvider(row.name), it, row.viewOptions, rowType = row, @@ -916,9 +934,9 @@ class HomeSettingsService is HomeRowConfig.Favorite -> { val title = - context.getString( + ResProviderStringProvider( R.string.favorite_items, - context.getString(favoriteOptions[row.kind]!!), + ResStringProvider(favoriteOptions[row.kind]!!), ) if (row.kind == BaseItemKind.PERSON) { val request = @@ -1001,7 +1019,7 @@ class HomeSettingsService .map { BaseItem(it, row.viewOptions.useSeries) } }.let { Success( - context.getString(R.string.active_recordings), + ResStringProvider(R.string.active_recordings), it, row.viewOptions, rowType = row, @@ -1027,7 +1045,7 @@ class HomeSettingsService .map { BaseItem(it, row.viewOptions.useSeries) } .let { Success( - context.getString(R.string.live_tv), + ResStringProvider(R.string.live_tv), it, row.viewOptions, rowType = row, @@ -1057,7 +1075,7 @@ class HomeSettingsService .toBaseItems(api, row.viewOptions.useSeries) }.let { Success( - context.getString(R.string.channels), + ResStringProvider(R.string.channels), it, row.viewOptions, rowType = row, @@ -1070,7 +1088,7 @@ class HomeSettingsService api.userLibraryApi .getItem(itemId = row.parentId) .content - val title = context.getString(R.string.suggestions_for, library.name ?: "") + val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) val suggestions = itemKind?.let { @@ -1111,7 +1129,7 @@ class HomeSettingsService */ data class HomeRowConfigDisplay( val id: Int, - val title: String, + val title: StringProvider, val config: HomeRowConfig, ) @@ -1154,13 +1172,10 @@ class UnsupportedHomeSettingsVersionException( val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") -fun getRecentlyAddedTitle( - context: Context, - library: Library?, -): String = +fun getRecentlyAddedTitle(library: Library?): StringProvider = if (library?.isRecordingFolder == true) { - context.getString(R.string.recently_recorded) + ResStringProvider(R.string.recently_recorded) } else { - library?.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) + library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) } + ?: ResStringProvider(R.string.recently_added) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 7096b833..e2086c29 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -1,10 +1,5 @@ package com.github.damontecres.wholphin.services -import android.content.Context -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.asFlow -import androidx.lifecycle.lifecycleScope -import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest @@ -12,28 +7,22 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient -import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.util.LoadingState -import dagger.hilt.android.qualifiers.ActivityContext -import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.supervisorScope import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.ImageType -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -140,7 +129,7 @@ class SeerrServerRepository serverRepository.currentUser.value?.let { jellyfinUser -> // TODO Need to update server early so that cookies are saved seerrApi.update(server.url, null) - val userConfig = login(seerrApi.api, authMethod, username, password) + val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) val user = SeerrUser( @@ -174,7 +163,7 @@ class SeerrServerRepository .readTimeout(6.seconds) .build(), ) - login(api, authMethod, username, passwordOrApiKey) + seerrLogin(api, authMethod, username, passwordOrApiKey) return LoadingState.Success } @@ -227,7 +216,7 @@ data class CurrentSeerr( config.hasPermission(SeerrPermission.REQUEST_4K_TV) } -private suspend fun login( +suspend fun seerrLogin( client: SeerrApiClient, authMethod: SeerrAuthMethod, username: String?, @@ -259,84 +248,6 @@ private suspend fun login( } } -/** - * Listens for JF user switching in the app to also switch the Seerr user/server - */ -@ActivityScoped -class UserSwitchListener - @Inject - constructor( - @param:ActivityContext private val context: Context, - private val serverRepository: ServerRepository, - private val seerrServerRepository: SeerrServerRepository, - private val seerrServerDao: SeerrServerDao, - private val seerrApi: SeerrApi, - private val homeSettingsService: HomeSettingsService, - ) { - init { - context as AppCompatActivity - context.lifecycleScope.launchIO { - serverRepository.currentUser.asFlow().collect { user -> - Timber.d("New user") - seerrServerRepository.clear() - homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } - if (user != null) { - switchUser(user) - } - } - } - } - - private suspend fun switchUser(user: JellyfinUser) = - supervisorScope { - // Check for home settings - launchIO { - homeSettingsService.loadCurrentSettings(user.id) - } - if (BuildConfig.DISCOVER_ENABLED) { - // Check for seerr server - launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) - } - } - } - } - } - } - } - fun CurrentSeerr?.imageUrlBuilder( imageType: ImageType, path: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt new file mode 100644 index 00000000..352b8439 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -0,0 +1,110 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.asFlow +import androidx.lifecycle.lifecycleScope +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +/** + * Listens for JF user switching in the app to also switch other settings like Seerr user/server + */ +@ActivityScoped +class UserSwitchListener + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val seerrServerRepository: SeerrServerRepository, + private val seerrServerDao: SeerrServerDao, + private val seerrApi: SeerrApi, + private val homeSettingsService: HomeSettingsService, + ) { + init { + context as AppCompatActivity + context.lifecycleScope.launchDefault { + serverRepository.currentUser.asFlow().collect { user -> + Timber.d("New user") + seerrServerRepository.clear() + homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } + if (user != null) { + switchUser(user) + } + } + } + } + + private suspend fun switchUser(user: JellyfinUser) = + supervisorScope { + // Switch the locale to either the user's choice or the system default (empty) + val localeList = + user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) } + ?: LocaleListCompat.getEmptyLocaleList() + Timber.i("Switching locale to %s", localeList) + withContext(Dispatchers.Main) { + AppCompatDelegate.setApplicationLocales(localeList) + } + + // Check for home settings + launchIO { + homeSettingsService.loadCurrentSettings(user.id) + } + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + seerrLogin( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index e2111637..c41c27f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context +import android.content.res.Resources import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Add @@ -15,7 +15,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R @@ -184,15 +184,15 @@ fun ContextMenu( ) { val item = contextMenu.item val chosenStreams = contextMenu.chosenStreams - val context = LocalContext.current + val resources = LocalResources.current var chooseVersion by remember { mutableStateOf(null) } var showDeleteDialog by remember { mutableStateOf(false) } var showPlayWithDialog by remember { mutableStateOf(false) } val dialogItems = - remember(context, item, chosenStreams) { + remember(resources, item, chosenStreams) { buildContextMenuItems( - context = context, + resources = resources, item = item, watched = item.played, favorite = item.favorite, @@ -208,7 +208,7 @@ fun ContextMenu( onChooseVersion = { chooseVersion = chooseVersionParams( - context, + resources, item.data.mediaSources.orEmpty(), chosenStreams?.source?.id?.toUUIDOrNull(), ) { idx -> @@ -225,7 +225,7 @@ fun ContextMenu( )?.let { source -> chooseVersion = chooseStream( - context = context, + resources = resources, streams = source.mediaStreams.orEmpty(), type = type, currentIndex = @@ -290,7 +290,7 @@ fun ContextMenu( if (showPlayWithDialog) { val dialogItems = remember { - buildPlayWith(context) { transcode, backend -> + buildPlayWith(resources) { transcode, backend -> onDismissRequest.invoke() actions.navigateTo( Destination.Playback( @@ -315,7 +315,7 @@ fun ContextMenu( } private fun buildContextMenuItems( - context: Context, + resources: Resources, item: BaseItem, seriesId: UUID?, sourceId: UUID?, @@ -339,7 +339,7 @@ private fun buildContextMenuItems( if (showGoTo) { add( DialogItem( - context.getString(R.string.go_to), + resources.getString(R.string.go_to), Icons.Default.ArrowForward, dismissOnClick = true, ) { @@ -351,7 +351,7 @@ private fun buildContextMenuItems( if (item.playbackPosition > Duration.ZERO) { add( DialogItem( - context.getString(R.string.resume), + resources.getString(R.string.resume), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, @@ -366,7 +366,7 @@ private fun buildContextMenuItems( ) add( DialogItem( - context.getString(R.string.restart), + resources.getString(R.string.restart), Icons.Default.Refresh, dismissOnClick = true, ) { @@ -381,7 +381,7 @@ private fun buildContextMenuItems( } else { add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, @@ -407,9 +407,9 @@ private fun buildContextMenuItems( if (audioCount > 1) { add( DialogItem( - context.getString( + resources.getString( R.string.choose_stream, - context.getString(R.string.audio), + resources.getString(R.string.audio), ), R.string.fa_volume_low, dismissOnClick = false, @@ -421,9 +421,9 @@ private fun buildContextMenuItems( if (subtitleCount > 0) { add( DialogItem( - context.getString( + resources.getString( R.string.choose_stream, - context.getString(R.string.subtitles), + resources.getString(R.string.subtitles), ), R.string.fa_closed_captioning, dismissOnClick = false, @@ -436,9 +436,9 @@ private fun buildContextMenuItems( if (sources.size > 1) { add( DialogItem( - context.getString( + resources.getString( R.string.choose_stream, - context.getString(R.string.version), + resources.getString(R.string.version), ), R.string.fa_file_video, dismissOnClick = false, @@ -452,7 +452,7 @@ private fun buildContextMenuItems( if (item.type == BaseItemKind.MUSIC_ALBUM) { add( DialogItem( - context.getString(R.string.add_to_queue), + resources.getString(R.string.add_to_queue), Icons.Default.Add, dismissOnClick = true, ) { @@ -472,7 +472,7 @@ private fun buildContextMenuItems( if (canDelete) { add( DialogItem( - context.getString(R.string.delete), + resources.getString(R.string.delete), Icons.Default.Delete, iconColor = Color.Red.copy(alpha = .8f), dismissOnClick = false, @@ -525,7 +525,7 @@ private fun buildContextMenuItems( item.data.albumId?.let { albumId -> add( DialogItem( - context.getString(R.string.go_to_album), + resources.getString(R.string.go_to_album), R.string.fa_compact_disc, dismissOnClick = true, ) { @@ -542,7 +542,7 @@ private fun buildContextMenuItems( item.data.artistItems?.firstOrNull()?.id?.let { artistId -> add( DialogItem( - context.getString(R.string.go_to_artist), + resources.getString(R.string.go_to_artist), R.string.fa_user, dismissOnClick = true, ) { @@ -559,7 +559,7 @@ private fun buildContextMenuItems( seriesId?.let { add( DialogItem( - context.getString(R.string.go_to_series), + resources.getString(R.string.go_to_series), Icons.AutoMirrored.Filled.ArrowForward, dismissOnClick = true, ) { @@ -576,7 +576,7 @@ private fun buildContextMenuItems( if (item.data.mediaSources?.isNotEmpty() == true) { add( DialogItem( - context.getString(R.string.media_information), + resources.getString(R.string.media_information), Icons.Default.Info, dismissOnClick = true, ) { @@ -587,7 +587,7 @@ private fun buildContextMenuItems( if (showStreamChoices && canClearChosenStreams) { add( DialogItem( - context.getString(R.string.clear_track_choices), + resources.getString(R.string.clear_track_choices), Icons.Default.Delete, dismissOnClick = true, ) { @@ -598,7 +598,7 @@ private fun buildContextMenuItems( if (item.type in supportedPlayableTypes) { add( DialogItem( - context.getString(R.string.play_with), + resources.getString(R.string.play_with), Icons.Default.PlayArrow, dismissOnClick = false, onClick = onClickPlayWith, @@ -619,13 +619,13 @@ private fun buildContextMenuItems( } private fun buildPlayWith( - context: Context, + resources: Resources, onClick: (Boolean, PlayerBackend?) -> Unit, ) = buildList { val entries = PlayerBackend.entries .filterNot { it == PlayerBackend.UNRECOGNIZED } - .zip(context.resources.getStringArray(R.array.player_backend_options)) + .zip(resources.getStringArray(R.array.player_backend_options)) .filterNot { it.first == PlayerBackend.PREFER_MPV } entries.forEach { (backend, title) -> add( @@ -639,7 +639,7 @@ private fun buildPlayWith( } add( DialogItem( - context.getString(R.string.transcoding), + resources.getString(R.string.transcoding), dismissOnClick = true, ) { onClick.invoke(true, null) @@ -691,11 +691,11 @@ fun ContextMenu( item: ContextMenu.ForMusic, actions: MusicContextActions, ) { - val context = LocalContext.current + val resources = LocalResources.current var showDeleteDialog by remember { mutableStateOf(false) } val dialogItems = - remember(context, item, actions) { - buildContextForMusic(context, item, actions, onClickDelete = { + remember(resources, item, actions) { + buildContextForMusic(resources, item, actions, onClickDelete = { showDeleteDialog = true }) } @@ -720,7 +720,7 @@ fun ContextMenu( } fun buildContextForMusic( - context: Context, + resources: Resources, music: ContextMenu.ForMusic, actions: MusicContextActions, onClickDelete: () -> Unit, @@ -730,7 +730,7 @@ fun buildContextForMusic( val index = music.index add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, @@ -740,7 +740,7 @@ fun buildContextForMusic( ) add( DialogItem( - context.getString(R.string.play_next), + resources.getString(R.string.play_next), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, @@ -751,7 +751,7 @@ fun buildContextForMusic( if (music.canRemoveFromQueue) { add( DialogItem( - context.getString(R.string.remove_from_queue), + resources.getString(R.string.remove_from_queue), Icons.Default.Delete, dismissOnClick = true, ) { @@ -761,7 +761,7 @@ fun buildContextForMusic( } else { add( DialogItem( - context.getString(R.string.add_to_queue), + resources.getString(R.string.add_to_queue), Icons.Default.Add, dismissOnClick = true, ) { @@ -781,7 +781,7 @@ fun buildContextForMusic( if (music.canDelete) { add( DialogItem( - context.getString(R.string.delete), + resources.getString(R.string.delete), Icons.Default.Delete, iconColor = Color.Red.copy(alpha = .8f), dismissOnClick = false, @@ -803,7 +803,7 @@ fun buildContextForMusic( if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) { add( DialogItem( - context.getString(R.string.go_to_album), + resources.getString(R.string.go_to_album), R.string.fa_compact_disc, dismissOnClick = true, ) { @@ -814,7 +814,7 @@ fun buildContextForMusic( if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) { add( DialogItem( - context.getString(R.string.go_to_artist), + resources.getString(R.string.go_to_artist), R.string.fa_user, dismissOnClick = true, ) { @@ -834,11 +834,11 @@ fun ContextMenu( item: ContextMenu.ForQueue, actions: QueueContextActions, ) { - val context = LocalContext.current + val resources = LocalResources.current val dialogItems = - remember(context, item, actions) { + remember(resources, item, actions) { buildContextForMusicQueue( - context, + resources, item.item, item.index, actions, @@ -855,7 +855,7 @@ fun ContextMenu( } fun buildContextForMusicQueue( - context: Context, + resources: Resources, item: AudioItem, index: Int, actions: QueueContextActions, @@ -863,7 +863,7 @@ fun buildContextForMusicQueue( buildList { add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, @@ -873,7 +873,7 @@ fun buildContextForMusicQueue( ) add( DialogItem( - context.getString(R.string.play_next), + resources.getString(R.string.play_next), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, @@ -883,7 +883,7 @@ fun buildContextForMusicQueue( ) add( DialogItem( - context.getString(R.string.remove_from_queue), + resources.getString(R.string.remove_from_queue), Icons.Default.Delete, dismissOnClick = true, ) { @@ -893,7 +893,7 @@ fun buildContextForMusicQueue( if (item.albumId != null) { add( DialogItem( - context.getString(R.string.go_to_album), + resources.getString(R.string.go_to_album), Icons.Default.ArrowForward, dismissOnClick = true, ) { @@ -904,7 +904,7 @@ fun buildContextForMusicQueue( if (item.artistId != null) { add( DialogItem( - context.getString(R.string.go_to_artist), + resources.getString(R.string.go_to_artist), Icons.Default.ArrowForward, dismissOnClick = true, ) { 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 58bcde20..6bd3dfaa 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 @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context +import android.content.res.Resources import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.background @@ -608,14 +608,14 @@ fun ConfirmDeleteDialog( } fun chooseVersionParams( - context: Context, + resources: Resources, sources: List, chosenSourceId: UUID?, onClick: (Int) -> Unit, ): DialogParams = DialogParams( fromLongClick = false, - title = context.getString(R.string.choose_stream, context.getString(R.string.version)), + title = resources.getString(R.string.choose_stream, resources.getString(R.string.version)), items = sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> val uuid = source.id?.toUUIDOrNull() @@ -649,7 +649,7 @@ fun resourceFor(type: MediaStreamType): Int = } fun chooseStream( - context: Context, + resources: Resources, streams: List, currentIndex: Int?, type: MediaStreamType, @@ -658,7 +658,7 @@ fun chooseStream( ): DialogParams = DialogParams( fromLongClick = false, - title = context.getString(R.string.choose_stream, context.getString(resourceFor(type))), + title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), items = buildList { if (type == MediaStreamType.SUBTITLE) { @@ -700,7 +700,7 @@ fun chooseStream( it } }.mapIndexed { index, stream -> - val simpleStream = SimpleMediaStream.from(context, stream, true) + val simpleStream = SimpleMediaStream.from(resources, stream, true) DialogItem( selected = currentIndex == stream.index, leadingContent = { 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 1aba204f..12c7c56e 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 @@ -10,7 +10,6 @@ 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.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -112,7 +111,7 @@ fun ItemGrid( } Column(modifier = modifier) { Text( - text = destination.title ?: destination.titleRes?.let { stringResource(it) } ?: "", + text = destination.title.getString(), style = MaterialTheme.typography.displayMedium, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index 080e6bb7..2c30f337 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder @@ -132,14 +133,14 @@ fun TimeRemaining( modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, ) { - val context = LocalContext.current + val resources = LocalResources.current val now by LocalClock.current.now val remainingStr = - remember(remaining, now) { + remember(remaining, now, resources) { val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) buildAnnotatedString { dot() - append(context.getString(R.string.ends_at, endTimeStr)) + append(resources.getString(R.string.ends_at, endTimeStr)) } } Text( 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 72c2888f..85328920 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 @@ -44,6 +44,7 @@ import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -111,13 +112,13 @@ class RecommendedViewModel it.copy( loading = LoadingState.Loading, rows = - recommendedRows.map { HomeRowLoadingState.Loading(context.getString(it.title)) } + - listOf(HomeRowLoadingState.Loading(context.getString(R.string.suggestions))), + recommendedRows.map { HomeRowLoadingState.Loading(ResStringProvider(it.title)) } + + listOf(HomeRowLoadingState.Loading(ResStringProvider(R.string.suggestions))), ) } val jobs = recommendedRows.mapIndexed { index, row -> - val title = context.getString(row.title) + val title = ResStringProvider(row.title) viewModelScope.launchIO { val result = try { @@ -173,7 +174,7 @@ class RecommendedViewModel private fun fetchSuggestions() { viewModelScope.launch(Dispatchers.IO) { - val title = context.getString(R.string.suggestions) + val title = ResStringProvider(R.string.suggestions) try { suggestionService .getSuggestionsFlow(parentId, suggestionsType) @@ -298,7 +299,6 @@ class RecommendedViewModel navigationManager.navigateTo( Destination.ItemGrid( title = row.title, - titleRes = recommendedRow.title, request = recommendedRow.request, requestHandler = recommendedRow.handler, initialPosition = row.items.size, @@ -313,7 +313,6 @@ class RecommendedViewModel navigationManager.navigateTo( Destination.ItemGrid( title = row.title, - titleRes = R.string.suggestions, request = GetItemsRequest( ids = row.items.mapNotNull { it?.id }, 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 ddfb7f0f..083dded6 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 @@ -17,7 +17,7 @@ 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 +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -69,7 +69,7 @@ fun VideoStreamDetails( numberOfVersions: Int = 0, modifier: Modifier = Modifier, ) { - val context = LocalContext.current + val resources = LocalResources.current Row( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, @@ -90,7 +90,13 @@ fun VideoStreamDetails( } else { null } - val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) + val range = + formatVideoRange( + resources, + it.videoRange, + it.videoRangeType, + it.videoDoViTitle, + ) resName.concatWithSpace(range) } } @@ -108,11 +114,11 @@ fun VideoStreamDetails( val audio = remember(audioCount, audioStream) { if (audioCount == 0 || audioStream == null) { - context.getString(R.string.none) + resources.getString(R.string.none) } else { listOfNotNull( languageName(audioStream.language), - formatAudioCodec(context, audioStream.codec, audioStream.profile), + formatAudioCodec(resources, audioStream.codec, audioStream.profile), audioStream.channelLayout, ).joinToString(" ") } @@ -133,7 +139,7 @@ fun VideoStreamDetails( remember(subtitleCount, subtitleStream) { if (subtitleCount > 0 && subtitleStream == null) { disabled = true - context.getString(R.string.none) + resources.getString(R.string.none) } else if (subtitleCount == 0 || subtitleStream == null) { null } else { 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 67e4cc3e..f0591868 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,6 +1,6 @@ package com.github.damontecres.wholphin.ui.data -import android.content.Context +import android.content.res.Resources import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,7 +13,7 @@ import androidx.compose.material3.HorizontalDivider 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.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -66,7 +66,7 @@ fun ItemDetailsDialog( showFilePath: Boolean, onDismissRequest: () -> Unit, ) { - val context = LocalContext.current + val resources = LocalResources.current // Extract stringResource calls outside of ScrollableDialog's non-composable lambda val pathLabel = stringResource(R.string.path) val fileSizeLabel = stringResource(R.string.file_size) @@ -164,8 +164,8 @@ fun ItemDetailsDialog( itemsIndexed(videoStreams) { index, stream -> MediaInfoSection( title = titleIndex(videoLabel, index, videoStreams.size), - items = remember { buildVideoStreamInfo(context, stream) }, - additional = remember { buildVideoStreamInfoAdditional(context, stream) }, + items = remember { buildVideoStreamInfo(resources, stream) }, + additional = remember { buildVideoStreamInfoAdditional(resources, stream) }, ) } @@ -184,7 +184,7 @@ fun ItemDetailsDialog( groupIndex * 3 + index, audioStreams.size, ), - items = buildAudioStreamInfo(context, stream), + items = buildAudioStreamInfo(resources, stream), modifier = Modifier.weight(1f), ) } @@ -210,7 +210,7 @@ fun ItemDetailsDialog( groupIndex * 3 + index, subtitleStreams.size, ), - items = buildSubtitleStreamInfo(context, stream, showFilePath), + items = buildSubtitleStreamInfo(resources, stream, showFilePath), modifier = Modifier.weight(1f), ) } @@ -301,25 +301,25 @@ fun titleIndex( } private fun buildVideoStreamInfo( - context: Context, + resources: Resources, stream: MediaStream, ): List> = buildList { - val yesStr = context.getString(R.string.yes) - val noStr = context.getString(R.string.no) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) - val titleLabel = context.getString(R.string.title) - val codecLabel = context.getString(R.string.codec) - val resolutionLabel = context.getString(R.string.resolution) - val aspectRatioLabel = context.getString(R.string.aspect_ratio) - val framerateLabel = context.getString(R.string.framerate) - val bitrateLabel = context.getString(R.string.bitrate) - val profileLabel = context.getString(R.string.profile) - val levelLabel = context.getString(R.string.level) - val interlacedLabel = context.getString(R.string.interlaced) - val videoRangeLabel = context.getString(R.string.video_range) - val sdrStr = context.getString(R.string.sdr) - val hdrStr = context.getString(R.string.hdr) + val titleLabel = resources.getString(R.string.title) + val codecLabel = resources.getString(R.string.codec) + val resolutionLabel = resources.getString(R.string.resolution) + val aspectRatioLabel = resources.getString(R.string.aspect_ratio) + val framerateLabel = resources.getString(R.string.framerate) + val bitrateLabel = resources.getString(R.string.bitrate) + val profileLabel = resources.getString(R.string.profile) + val levelLabel = resources.getString(R.string.level) + val interlacedLabel = resources.getString(R.string.interlaced) + val videoRangeLabel = resources.getString(R.string.video_range) + val sdrStr = resources.getString(R.string.sdr) + val hdrStr = resources.getString(R.string.hdr) stream.title?.let { add(titleLabel to it) } stream.codec?.let { add(codecLabel to it.uppercase()) } @@ -350,31 +350,31 @@ private fun buildVideoStreamInfo( } private fun buildVideoStreamInfoAdditional( - context: Context, + resources: Resources, stream: MediaStream, ): List> = buildList { - val yesStr = context.getString(R.string.yes) - val noStr = context.getString(R.string.no) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) - val avcLabel = context.getString(R.string.avc) - val anamorphicLabel = context.getString(R.string.anamorphic) - val bitDepthLabel = context.getString(R.string.bit_depth) + val avcLabel = resources.getString(R.string.avc) + val anamorphicLabel = resources.getString(R.string.anamorphic) + val bitDepthLabel = resources.getString(R.string.bit_depth) - 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 dolbyVisionLabel = context.getString(R.string.dolby_vision) + val videoRangeTypeLabel = resources.getString(R.string.video_range_type) + val colorSpaceLabel = resources.getString(R.string.color_space) + val colorTransferLabel = resources.getString(R.string.color_transfer) + val colorPrimariesLabel = resources.getString(R.string.color_primaries) + val pixelFormatLabel = resources.getString(R.string.pixel_format) + val refFramesLabel = resources.getString(R.string.ref_frames) + val nalLabel = resources.getString(R.string.nal) + val dolbyVisionLabel = resources.getString(R.string.dolby_vision) - val sdrStr = context.getString(R.string.sdr) - 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) + val sdrStr = resources.getString(R.string.sdr) + val hdr10Str = resources.getString(R.string.hdr10) + val hdr10PlusStr = resources.getString(R.string.hdr10_plus) + val hlgStr = resources.getString(R.string.hlg) + val bitUnit = resources.getString(R.string.bit_unit) stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } @@ -394,7 +394,7 @@ private fun buildVideoStreamInfoAdditional( VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) + -> resources.getString(R.string.dolby_vision) VideoRangeType.UNKNOWN -> null @@ -412,27 +412,27 @@ private fun buildVideoStreamInfoAdditional( } private fun buildAudioStreamInfo( - context: Context, + resources: android.content.res.Resources, 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 profileLabel = context.getString(R.string.profile) - val yesStr = context.getString(R.string.yes) - val noStr = context.getString(R.string.no) - val sampleRateUnit = context.getString(R.string.sample_rate_unit) + val titleLabel = resources.getString(R.string.title) + val languageLabel = resources.getString(R.string.language) + val codecLabel = resources.getString(R.string.codec) + val layoutLabel = resources.getString(R.string.layout) + val channelsLabel = resources.getString(R.string.channels) + val bitrateLabel = resources.getString(R.string.bitrate) + val sampleRateLabel = resources.getString(R.string.sample_rate) + val defaultLabel = resources.getString(R.string.default_track) + val profileLabel = resources.getString(R.string.profile) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) + val sampleRateUnit = resources.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)" + val formattedCodec = formatAudioCodec(resources, it, stream.profile) + " ($it)" add(codecLabel to formattedCodec) } stream.channelLayout?.let { add(layoutLabel to it) } @@ -444,20 +444,20 @@ private fun buildAudioStreamInfo( } private fun buildSubtitleStreamInfo( - context: Context, + resources: Resources, stream: MediaStream, showPath: Boolean, ): List> = buildList { - val titleLabel = context.getString(R.string.title) - val languageLabel = context.getString(R.string.language) - val codecLabel = context.getString(R.string.codec) - 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) - val pathLabel = context.getString(R.string.path) + val titleLabel = resources.getString(R.string.title) + val languageLabel = resources.getString(R.string.language) + val codecLabel = resources.getString(R.string.codec) + val defaultLabel = resources.getString(R.string.default_track) + val forcedLabel = resources.getString(R.string.forced_track) + val externalLabel = resources.getString(R.string.external_track) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) + val pathLabel = resources.getString(R.string.path) stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } 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 20172832..73ad4a2e 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 @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.detail -import android.content.Context +import android.content.res.Resources import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Add @@ -32,7 +32,7 @@ data class MoreDialogActions( ) fun buildMoreDialogItemsForHome( - context: Context, + resources: Resources, item: BaseItem, seriesId: UUID?, playbackPosition: Duration, @@ -47,7 +47,7 @@ fun buildMoreDialogItemsForHome( val itemId = item.id add( DialogItem( - context.getString(R.string.go_to), + resources.getString(R.string.go_to), Icons.Default.ArrowForward, ) { actions.onClickGoTo(item) @@ -57,7 +57,7 @@ fun buildMoreDialogItemsForHome( if (playbackPosition >= 1.seconds) { add( DialogItem( - context.getString(R.string.resume), + resources.getString(R.string.resume), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { @@ -71,7 +71,7 @@ fun buildMoreDialogItemsForHome( ) add( DialogItem( - context.getString(R.string.restart), + resources.getString(R.string.restart), Icons.Default.Refresh, // iconColor = Color.Green.copy(alpha = .8f), ) { @@ -86,7 +86,7 @@ fun buildMoreDialogItemsForHome( } else { add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { @@ -103,7 +103,7 @@ fun buildMoreDialogItemsForHome( if (item.type == BaseItemKind.MUSIC_ALBUM) { add( DialogItem( - context.getString(R.string.add_to_queue), + resources.getString(R.string.add_to_queue), Icons.Default.Add, ) { actions.onClickAddToQueue(item) @@ -121,7 +121,7 @@ fun buildMoreDialogItemsForHome( if (canDelete) { add( DialogItem( - context.getString(R.string.delete), + resources.getString(R.string.delete), Icons.Default.Delete, iconColor = Color.Red.copy(alpha = .8f), ) { @@ -169,7 +169,7 @@ fun buildMoreDialogItemsForHome( seriesId?.let { add( DialogItem( - context.getString(R.string.go_to_series), + resources.getString(R.string.go_to_series), Icons.AutoMirrored.Filled.ArrowForward, ) { actions.navigateTo( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index a83858cc..fc088cf7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -59,6 +59,8 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -93,13 +95,13 @@ class HomeRowGridViewModel private val musicService: MusicService, val streamChoiceService: StreamChoiceService, val mediaReportService: MediaReportService, - @Assisted private val title: String, + @Assisted private val title: StringProvider, @Assisted private val rowConfig: HomeRowConfig, ) : ViewModel() { @AssistedFactory interface Factory { fun create( - title: String, + title: StringProvider, rowConfig: HomeRowConfig, ): HomeRowGridViewModel } @@ -155,7 +157,16 @@ class HomeRowGridViewModel } } catch (ex: Exception) { Timber.e(ex, "Error fetching: %s", rowConfig) - _state.update { it.copy(loading = HomeRowLoadingState.Error(title, null, ex)) } + _state.update { + it.copy( + loading = + HomeRowLoadingState.Error( + title, + null, + ex, + ), + ) + } } } } @@ -210,7 +221,7 @@ class HomeRowGridViewModel } data class HomeRowGridState( - val loading: HomeRowLoadingState = HomeRowLoadingState.Pending(""), + val loading: HomeRowLoadingState = HomeRowLoadingState.Pending(StringStringProvider("")), ) @OptIn(ExperimentalFoundationApi::class) @@ -268,7 +279,7 @@ fun HomeRowGrid( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - GridTitle(destination.title) + GridTitle(destination.title.getString()) when (val st = state.loading) { is HomeRowLoadingState.Error -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index a6a77e23..913ea3e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -71,6 +71,7 @@ import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType @@ -385,7 +386,7 @@ fun PersonPageContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), DiscoverRequestType.UNKNOWN, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index b2876a09..c79eada4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.util.FilterUtils +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -212,7 +213,7 @@ class CollectionViewModel val jobs = typesInCollection.map { type -> async(Dispatchers.IO) { - val title = context.getString(formatTypeName(type)) + val title = ResStringProvider(formatTypeName(type)) val result = try { val pager = fetchItems(sort, filter, listOf(type)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 85743e3d..5a520c48 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -79,6 +80,7 @@ fun DiscoverMovieDetails( creationCallback = { it.create(destination.item) }, ), ) { + val resources = LocalResources.current val context = LocalContext.current LifecycleResumeEffect(Unit) { viewModel.init() @@ -160,7 +162,7 @@ fun DiscoverMovieDetails( overviewOnClick = { overviewDialog = ItemDetailsDialogInfo( - title = movie.title ?: context.getString(R.string.unknown), + title = movie.title ?: resources.getString(R.string.unknown), overview = movie.overview, genres = movie.genres?.mapNotNull { it.name }.orEmpty(), files = listOf(), @@ -244,7 +246,6 @@ fun DiscoverMovieDetailsContent( onLongClickSimilar: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val scope = rememberCoroutineScope() var position by rememberInt(0) val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 4b6a6200..609bd93a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.discover -import android.content.Context +import android.content.res.Resources import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,6 +27,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -83,6 +84,7 @@ fun DiscoverSeriesDetails( ), ) { val context = LocalContext.current + val resources = LocalResources.current val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.tvSeries.observeAsState() @@ -147,7 +149,7 @@ fun DiscoverSeriesDetails( overviewOnClick = { overviewDialog = ItemDetailsDialogInfo( - title = item.name ?: context.getString(R.string.unknown), + title = item.name ?: resources.getString(R.string.unknown), overview = item.overview, genres = item.genres?.mapNotNull { it.name }.orEmpty(), files = listOf(), @@ -494,7 +496,7 @@ fun DiscoverSeriesDetailsHeader( } fun buildDialogForSeason( - context: Context, + resources: Resources, s: BaseItem, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, @@ -503,26 +505,26 @@ fun buildDialogForSeason( val items = buildList { add( - DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) { onClickItem.invoke(s) }, ) if (s.data.userData?.played == true) { add( - DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + DialogItem(resources.getString(R.string.mark_unwatched), R.string.fa_eye) { markPlayed.invoke(false) }, ) } else { add( - DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + DialogItem(resources.getString(R.string.mark_watched), R.string.fa_eye_slash) { markPlayed.invoke(true) }, ) } add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { @@ -531,7 +533,7 @@ fun buildDialogForSeason( ) add( DialogItem( - context.getString(R.string.shuffle), + resources.getString(R.string.shuffle), R.string.fa_shuffle, ) { onClickPlay.invoke(true) @@ -539,7 +541,7 @@ fun buildDialogForSeason( ) } return DialogParams( - title = s.name ?: context.getString(R.string.tv_season), + title = s.name ?: resources.getString(R.string.tv_season), fromLongClick = true, items = items, ) 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 e249a0d2..82b0f0ce 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 @@ -64,6 +64,7 @@ import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler @@ -478,7 +479,7 @@ fun MovieDetailsContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), type = DiscoverRequestType.UNKNOWN, ), 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 18a539c4..bc735720 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 @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.series -import android.content.Context +import android.content.res.Resources import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -85,6 +85,7 @@ import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler @@ -644,7 +645,7 @@ fun SeriesDetailsContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), type = DiscoverRequestType.UNKNOWN, ), @@ -726,7 +727,7 @@ fun SeriesDetailsHeader( } fun buildDialogForSeason( - context: Context, + resources: Resources, s: BaseItem, canDelete: Boolean, onClickItem: (BaseItem) -> Unit, @@ -737,26 +738,26 @@ fun buildDialogForSeason( val items = buildList { add( - DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) { onClickItem.invoke(s) }, ) if (s.data.userData?.played == true) { add( - DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + DialogItem(resources.getString(R.string.mark_unwatched), R.string.fa_eye) { markPlayed.invoke(false) }, ) } else { add( - DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + DialogItem(resources.getString(R.string.mark_watched), R.string.fa_eye_slash) { markPlayed.invoke(true) }, ) } add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { @@ -765,7 +766,7 @@ fun buildDialogForSeason( ) add( DialogItem( - context.getString(R.string.shuffle), + resources.getString(R.string.shuffle), R.string.fa_shuffle, ) { onClickPlay.invoke(true) @@ -774,7 +775,7 @@ fun buildDialogForSeason( if (canDelete) { add( DialogItem( - context.getString(R.string.delete), + resources.getString(R.string.delete), Icons.Default.Delete, iconColor = Color.Red.copy(alpha = .8f), ) { @@ -784,7 +785,7 @@ fun buildDialogForSeason( } } return DialogParams( - title = s.name ?: context.getString(R.string.tv_season), + title = s.name ?: resources.getString(R.string.tv_season), fromLongClick = true, items = items, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt index 039ef566..bbf1dc18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt @@ -59,7 +59,7 @@ fun DiscoverRow( modifier = modifier, ) { Text( - text = row.title, + text = row.title.getString(), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) @@ -73,7 +73,7 @@ fun DiscoverRow( is DataLoadingState.Success> -> { DiscoverItemRow( - title = row.title, + title = row.title.getString(), items = state.data, onClickItem = onClickItem, onLongClickItem = onLongClickItem, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 321cc76c..6751a154 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -46,7 +46,10 @@ import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec +import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.google.common.cache.CacheBuilder @@ -77,7 +80,7 @@ class SeerrDiscoverViewModel this.copy( movies = DiscoverRowData( - context.getString(R.string.movies), + ResStringProvider(R.string.movies), it, DiscoverRequestType.DISCOVER_MOVIES, ), @@ -87,7 +90,7 @@ class SeerrDiscoverViewModel this.copy( tv = DiscoverRowData( - context.getString(R.string.tv_shows), + ResStringProvider(R.string.tv_shows), it, DiscoverRequestType.DISCOVER_TV, ), @@ -97,7 +100,7 @@ class SeerrDiscoverViewModel this.copy( trending = DiscoverRowData( - context.getString(R.string.trending), + ResStringProvider(R.string.trending), it, DiscoverRequestType.TRENDING, ), @@ -107,7 +110,7 @@ class SeerrDiscoverViewModel this.copy( upcomingMovies = DiscoverRowData( - context.getString(R.string.upcoming_movies), + ResStringProvider(R.string.upcoming_movies), it, DiscoverRequestType.UPCOMING_MOVIES, ), @@ -117,7 +120,7 @@ class SeerrDiscoverViewModel this.copy( upcomingTv = DiscoverRowData( - context.getString(R.string.upcoming_tv), + ResStringProvider(R.string.upcoming_tv), it, DiscoverRequestType.UPCOMING_TV, ), @@ -214,12 +217,17 @@ class SeerrDiscoverViewModel } data class DiscoverRowData( - val title: String, + val title: StringProvider, val items: DataLoadingState>, val type: DiscoverRequestType, ) { companion object { - val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN) + val EMPTY = + DiscoverRowData( + EmptyStringProvider, + DataLoadingState.Pending, + DiscoverRequestType.UNKNOWN, + ) } } 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 816fcc25..cfd0de03 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 @@ -381,7 +381,7 @@ fun HomePageContent( is HomeRowLoadingState.Pending, -> { FocusableItemRow( - title = r.title, + title = r.title.getString(), subtitle = stringResource(R.string.loading), modifier = Modifier.animateItem(), ) @@ -389,7 +389,7 @@ fun HomePageContent( is HomeRowLoadingState.Error -> { FocusableItemRow( - title = r.title, + title = r.title.getString(), subtitle = r.localizedMessage, isError = true, modifier = Modifier.animateItem(), @@ -400,7 +400,7 @@ fun HomePageContent( if (row.items.isNotEmpty()) { val viewOptions = row.viewOptions ItemRow( - title = row.title, + title = row.title.getString(), items = row.items, onClickItem = remember(rowIndex, onClickItem) { @@ -502,7 +502,7 @@ fun HomePageContent( ) } else if (showEmptyRows) { FocusableItemRow( - title = r.title, + title = r.title.getString(), subtitle = stringResource(R.string.no_results), modifier = Modifier.animateItem(), ) 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 d3722331..f6866b12 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 @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -103,7 +104,9 @@ class HomeViewModel if (refresh) { it.homeRows } else { - List(settings.rows.size) { HomeRowLoadingState.Pending("") } + List(settings.rows.size) { + HomeRowLoadingState.Pending(EmptyStringProvider) + } }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 53f11aac..e46be8bd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -221,7 +221,7 @@ fun HomeSettingsPage( } } HomeRowSettings( - title = row.title, + title = row.title.getString(), preferenceOptions = preferenceOptions, viewOptions = row.config.viewOptions, defaultViewOptions = defaultViewOptions, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 9eadc615..438e671f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -209,7 +209,7 @@ fun HomeRowConfigContent( ) { HomeSettingsListItem( selected = false, - headlineText = config.title, + headlineText = config.title.getString(), onClick = onClick, modifier = Modifier.weight(1f), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index c4874aa0..6cf01c24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -36,6 +36,10 @@ import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel @@ -222,7 +226,7 @@ class HomeSettingsViewModel MetaRowType.CONTINUE_WATCHING -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.continue_watching), + title = ResStringProvider(R.string.continue_watching), config = ContinueWatching(), ) } @@ -230,7 +234,7 @@ class HomeSettingsViewModel MetaRowType.NEXT_UP -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.next_up), + title = ResStringProvider(R.string.next_up), config = NextUp(), ) } @@ -238,7 +242,7 @@ class HomeSettingsViewModel MetaRowType.COMBINED_CONTINUE_WATCHING -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.combine_continue_next), + title = ResStringProvider(R.string.combine_continue_next), config = ContinueWatchingCombined(), ) } @@ -291,7 +295,12 @@ class HomeSettingsViewModel when (rowType) { LibraryRowType.RECENTLY_ADDED -> { val title = - library.name.let { context.getString(R.string.recently_added_in, it) } + library.name.let { + ResArgStringProvider( + R.string.recently_added_in, + it, + ) + } HomeRowConfigDisplay( id = id, title = title, @@ -302,7 +311,7 @@ class HomeSettingsViewModel LibraryRowType.RECENTLY_RELEASED -> { val title = library.name.let { - context.getString( + ResArgStringProvider( R.string.recently_released_in, it, ) @@ -315,7 +324,8 @@ class HomeSettingsViewModel } LibraryRowType.GENRES -> { - val title = library.name.let { context.getString(R.string.genres_in, it) } + val title = + library.name.let { ResArgStringProvider(R.string.genres_in, it) } HomeRowConfigDisplay( id = id, title = title, @@ -325,7 +335,7 @@ class HomeSettingsViewModel LibraryRowType.STUDIOS -> { val title = - library.name.let { context.getString(R.string.studios_in, it) } + library.name.let { ResArgStringProvider(R.string.studios_in, it) } HomeRowConfigDisplay( id = id, title = title, @@ -335,7 +345,12 @@ class HomeSettingsViewModel LibraryRowType.SUGGESTIONS -> { val title = - library.name.let { context.getString(R.string.suggestions_for, it) } + library.name.let { + ResArgStringProvider( + R.string.suggestions_for, + it, + ) + } HomeRowConfigDisplay( id = id, title = title, @@ -344,7 +359,7 @@ class HomeSettingsViewModel } LibraryRowType.TV_CHANNELS -> { - val title = context.getString(R.string.channels) + val title = ResStringProvider(R.string.channels) HomeRowConfigDisplay( id = id, title = title, @@ -356,7 +371,7 @@ class HomeSettingsViewModel } LibraryRowType.TV_PROGRAMS -> { - val title = context.getString(R.string.watch_live) + val title = ResStringProvider(R.string.watch_live) HomeRowConfigDisplay( id = id, title = title, @@ -365,7 +380,7 @@ class HomeSettingsViewModel } LibraryRowType.RECENTLY_RECORDED -> { - val title = context.getString(R.string.recently_recorded) + val title = ResStringProvider(R.string.recently_recorded) HomeRowConfigDisplay( id = id, title = title, @@ -396,9 +411,9 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = - context.getString( + ResProviderStringProvider( R.string.favorite_items, - context.getString(favoriteOptions[type]!!), + ResStringProvider(favoriteOptions[type]!!), ), config = HomeRowConfig.Favorite(type), ) @@ -420,7 +435,7 @@ class HomeSettingsViewModel val newRow = HomeRowConfigDisplay( id = id, - title = parent.name ?: "", + title = StringStringProvider(parent.name ?: ""), config = HomeRowConfig.ByParent( parentId = parent.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 213527db..761978ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.nav -import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter @@ -14,6 +13,7 @@ import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE @@ -110,8 +110,7 @@ sealed class Destination( @Serializable data class ItemGrid( - val title: String?, - @param:StringRes val titleRes: Int?, + val title: StringProvider, @Contextual val request: T, val requestHandler: RequestHandler, val initialPosition: Int = 0, @@ -120,7 +119,7 @@ sealed class Destination( @Serializable data class MoreHomeRow( - val title: String, + val title: StringProvider, val config: HomeRowConfig, val initialPosition: Int, ) : Destination(false) 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 4a73afd9..f6007f55 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 @@ -509,14 +509,15 @@ class PlaybackViewModel it } }?.map { - SimpleMediaStream.from(context, it, true) + // TODO should use a string provider instead + SimpleMediaStream.from(context.resources, it, true) }.orEmpty() val audioStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } ?.map { - SimpleMediaStream.from(context, it, true) + SimpleMediaStream.from(context.resources, it, true) } // ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt index d430258a..04699005 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.playback -import android.content.Context +import android.content.res.Resources import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle import org.jellyfin.sdk.model.api.MediaStream @@ -17,14 +17,14 @@ data class SimpleMediaStream( ) { companion object { fun from( - context: Context, + resources: Resources, mediaStream: MediaStream, includeFlags: Boolean = true, ): SimpleMediaStream = SimpleMediaStream( index = mediaStream.index, streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, - displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags), + displayTitle = mediaStreamDisplayTitle(resources, mediaStream, includeFlags), ) } } 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 b2e82eae..571d5210 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 @@ -155,7 +155,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( it.copy( subtitleStreams = subtitlesStreams.map { - SimpleMediaStream.from(context, it, true) + SimpleMediaStream.from(context.resources, it, true) }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt new file mode 100644 index 00000000..0ac88d55 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt @@ -0,0 +1,195 @@ +package com.github.damontecres.wholphin.ui.preferences + +import android.content.Context +import android.content.res.XmlResourceParser +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogPopupContent +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class LocaleChoiceViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val serverRepository: ServerRepository, + ) : ViewModel() { + fun changeLocale(locale: Locale) { + viewModelScope.launchDefault { + serverRepository.current.value?.let { + Timber.i("Updating user's locale to %s", locale.toLanguageTag()) + val newUser = it.user.copy(uiLanguage = locale.toLanguageTag()) + serverRepository.changeUser(it.server, newUser) + } + } + } + + private val _state = MutableStateFlow(LocaleChoiceState()) + val state: StateFlow = _state + + init { + viewModelScope.launchDefault { + serverRepository.currentUser.value?.let { + val availableLocales = extractAvailableLocales() + Timber.v("availableLocales=%s", availableLocales) + _state.update { + it.copy( + loading = LoadingState.Success, + availableLocales = availableLocales, + ) + } + } + } + viewModelScope.launchDefault { + serverRepository.currentUser.asFlow().collectLatest { user -> + val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault() + _state.update { it.copy(userLocale = userLocale) } + } + } + } + + /** + * Get the available locales that can be used. + * + * Defaults to `context.assets.locales` (all locales) if can't find app specific ones + */ + private fun extractAvailableLocales(): List { + val id = + context.resources.getIdentifier( + "_generated_res_locale_config", + "xml", + context.packageName, + ) + return if (id != 0) { + try { + val locales = mutableListOf() + // This is kind of a hack, the generated locale config is not available, + // programmatically, so this code parses the raw XML + context.resources.getXml(id).use { parser -> + var eventType: Int = parser.eventType + while (eventType != XmlResourceParser.END_DOCUMENT) { + if (eventType == XmlResourceParser.START_TAG) { + val tagName: String? = parser.name + + // Check for a specific tag name + if ("locale" == tagName) { + val attr = + parser.getAttributeValue( + "http://schemas.android.com/apk/res/android", + "name", + ) + attr?.let { locales.add(Locale.forLanguageTag(it)) } + } + } + eventType = parser.next() + } + } + locales + } catch (ex: Exception) { + Timber.w(ex, "Exception while parsing generated available locales") + context.assets.locales.map { Locale.forLanguageTag(it) } + } + } else { + context.assets.locales.map { Locale.forLanguageTag(it) } + } + } + } + +data class LocaleChoiceState( + val loading: LoadingState = LoadingState.Pending, + val availableLocales: List = emptyList(), + val userLocale: Locale = Locale.getDefault(), +) + +@Composable +fun LocaleChoiceDialog( + onDismissRequest: () -> Unit, + viewModel: LocaleChoiceViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(), + ) { + when (val st = state.loading) { + is LoadingState.Error -> { + ErrorMessage(st) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val dialogItems = + remember(state.userLocale, state.availableLocales) { + val userLanguage = state.userLocale.toLanguageTag() + state.availableLocales.map { locale -> + val tag = locale.toLanguageTag() + DialogItem( + selected = tag == userLanguage, + leadingContent = { + Box { + SelectedLeadingContent(tag == userLanguage) + } + }, + headlineContent = { + Text(locale.getDisplayName(locale)) + }, + supportingContent = { + Text(locale.getDisplayName(Locale.ENGLISH)) + }, + dismissOnClick = true, + onClick = { + viewModel.changeLocale(locale) + }, + ) + } + } + DialogPopupContent( + title = stringResource(R.string.user_interface_language), + dialogItems = dialogItems, + waiting = false, + onDismissRequest = onDismissRequest, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 945523c9..67b5733e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -82,6 +82,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import timber.log.Timber +import java.util.Locale @Composable fun PreferencesContent( @@ -109,6 +110,7 @@ fun PreferencesContent( val seerrConnection by viewModel.seerrConnection.collectAsState() var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } var showQuickConnectDialog by remember { mutableStateOf(false) } + var showLocaleChoiceDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -255,6 +257,12 @@ fun PreferencesContent( pref as AppPreference item { val interactionSource = remember { MutableInteractionSource() } + val focusModifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ) val focused = interactionSource.collectIsFocusedAsState().value LaunchedEffect(focused) { if (focused) { @@ -275,12 +283,7 @@ fun PreferencesContent( }, summary = installedVersion.toString(), interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), + modifier = focusModifier, ) } @@ -317,12 +320,7 @@ fun PreferencesContent( null }, interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), + modifier = focusModifier, ) } @@ -348,7 +346,7 @@ fun PreferencesContent( updateCache = true } }, - modifier = Modifier, + modifier = focusModifier, summary = summary, onLongClick = {}, interactionSource = interactionSource, @@ -359,7 +357,7 @@ fun PreferencesContent( NavDrawerPreference( title = stringResource(pref.title), summary = pref.summary(context, null), - modifier = Modifier, + modifier = focusModifier, interactionSource = interactionSource, ) } @@ -370,7 +368,7 @@ fun PreferencesContent( onClick = { viewModel.sendAppLogs() }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, @@ -383,7 +381,7 @@ fun PreferencesContent( onClick = { viewModel.resetSubtitleSettings() }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, @@ -400,6 +398,7 @@ fun PreferencesContent( ChoicePreference( title = stringResource(pref.title), summary = stringResource(summary), + interactionSource = interactionSource, possibleValues = listOf( stringResource(R.string.none), @@ -429,6 +428,7 @@ fun PreferencesContent( } } }, + modifier = focusModifier, ) } @@ -456,7 +456,7 @@ fun PreferencesContent( } } }, - modifier = Modifier, + modifier = focusModifier, summary = when (seerrConnection) { is SeerrConnectionStatus.Error -> { @@ -487,7 +487,7 @@ fun PreferencesContent( showQuickConnectDialog = true } }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, @@ -500,7 +500,7 @@ fun PreferencesContent( onClick = { viewModel.screensaverService.start() }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, @@ -516,6 +516,7 @@ fun PreferencesContent( ChoicePreference( title = stringResource(pref.title), summary = players[selectedIndex].name, + interactionSource = interactionSource, possibleValues = players, selectedIndex = selectedIndex, onValueChange = { index -> @@ -543,6 +544,25 @@ fun PreferencesContent( Text(item.name) } }, + modifier = focusModifier, + ) + } + + AppPreference.UserInterfaceLanguage -> { + val locale = + remember(currentUser?.uiLanguage) { + currentUser?.uiLanguage?.let { Locale.forLanguageTag(it) } + ?: Locale.getDefault() + } + ClickPreference( + title = stringResource(pref.title), + onClick = { + showLocaleChoiceDialog = true + }, + modifier = focusModifier, + summary = locale.getDisplayName(locale), + onLongClick = null, + interactionSource = interactionSource, ) } @@ -576,12 +596,7 @@ fun PreferencesContent( } }, interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), + modifier = focusModifier, ) } } @@ -721,6 +736,11 @@ fun PreferencesContent( } } } + if (showLocaleChoiceDialog) { + LocaleChoiceDialog( + onDismissRequest = { showLocaleChoiceDialog = false }, + ) + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt index 15d35b3b..4f1a592d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.util -import android.content.Context +import android.content.res.Resources import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.util.languageName @@ -44,7 +44,7 @@ object StreamFormatting { } fun formatVideoRange( - context: Context, + resources: Resources, videoRange: VideoRange?, type: VideoRangeType?, doviTitle: String?, @@ -58,7 +58,7 @@ object StreamFormatting { VideoRange.HDR -> { if (doviTitle.isNotNullOrBlank()) { - context.getString(R.string.dolby_vision) + resources.getString(R.string.dolby_vision) } else { when (type) { VideoRangeType.UNKNOWN, @@ -76,40 +76,40 @@ object StreamFormatting { VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) + -> resources.getString(R.string.dolby_vision) } } } } fun formatAudioCodec( - context: Context, + resources: Resources, codec: String?, profile: String?, ): String? = when { profile?.contains("Dolby Atmos", true) == true -> { - context.getString(R.string.dolby_atmos) + resources.getString(R.string.dolby_atmos) } profile?.contains("DTS", true) == true -> { when { - profile.contains("X", true) -> context.getString(R.string.dts_x) - profile.contains("MA", true) -> context.getString(R.string.dts_hd_ma) - profile.contains("HD", true) -> context.getString(R.string.dts_hd) - else -> context.getString(R.string.dts) + profile.contains("X", true) -> resources.getString(R.string.dts_x) + profile.contains("MA", true) -> resources.getString(R.string.dts_hd_ma) + profile.contains("HD", true) -> resources.getString(R.string.dts_hd) + else -> resources.getString(R.string.dts) } } else -> { when (codec?.lowercase()) { - Codec.Audio.TRUEHD -> context.getString(R.string.truehd) + Codec.Audio.TRUEHD -> resources.getString(R.string.truehd) - Codec.Audio.AC3 -> context.getString(R.string.dolby_digital) + Codec.Audio.AC3 -> resources.getString(R.string.dolby_digital) - Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus) + Codec.Audio.EAC3 -> resources.getString(R.string.dolby_digital_plus) - Codec.Audio.DCA -> context.getString(R.string.dts) + Codec.Audio.DCA -> resources.getString(R.string.dts) Codec.Audio.OGG, Codec.Audio.OPUS, @@ -141,7 +141,7 @@ object StreamFormatting { } fun mediaStreamDisplayTitle( - context: Context, + resources: Resources, stream: MediaStream, includeFlags: Boolean, ): String { @@ -149,7 +149,7 @@ object StreamFormatting { buildList { add(languageName(stream.language)) if (stream.type == MediaStreamType.AUDIO) { - add(formatAudioCodec(context, stream.codec, stream.profile)) + add(formatAudioCodec(resources, stream.codec, stream.profile)) add(stream.channelLayout) } else if (stream.type == MediaStreamType.SUBTITLE) { "SDH".takeIf { stream.isHearingImpaired }?.let(::add) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt new file mode 100644 index 00000000..6c3e7bca --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.res.stringResource +import kotlinx.serialization.Serializable + +/** + * Flexible way to provide a string for the UI + */ +@Serializable +sealed interface StringProvider { + @Composable + @NonRestartableComposable + fun getString(): String +} + +/** + * Provides a string literal + */ +@Serializable +data class StringStringProvider( + val str: String, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = str +} + +/** + * Provide an empty string literal + */ +@Serializable +data object EmptyStringProvider : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = "" +} + +/** + * Provides a string resource using [stringResource] + */ +@Serializable +data class ResStringProvider( + @param:StringRes val stringResId: Int, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString() = stringResource(stringResId) +} + +/** + * Provides a string resource with format arguments using [stringResource] + */ +@Serializable +data class ResArgStringProvider( + @param:StringRes val stringResId: Int, + val args: Array, +) : StringProvider { + constructor(stringResId: Int, arg: String) : this(stringResId, arrayOf(arg)) + + @Composable + @NonRestartableComposable + override fun getString() = stringResource(stringResId, *args) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ResArgStringProvider) return false + + if (stringResId != other.stringResId) return false + if (!args.contentEquals(other.args)) return false + + return true + } + + override fun hashCode(): Int { + var result = stringResId + result = 31 * result + args.contentHashCode() + return result + } +} + +/** + * Provides a string resource with another [StringProvider] format argument using [stringResource] + */ +@Serializable +data class ResProviderStringProvider( + @param:StringRes val stringResId: Int, + val argProvider: StringProvider, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = stringResource(stringResId, argProvider.getString()) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 97308113..585fff4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.ui.util.StringProvider /** * Generic state for loading something from the API @@ -48,28 +49,28 @@ sealed interface RowLoadingState { } sealed interface HomeRowLoadingState { - val title: String + val title: StringProvider val completed: Boolean get() = this is Success || this is Error data class Pending( - override val title: String, + override val title: StringProvider, ) : HomeRowLoadingState data class Loading( - override val title: String, + override val title: StringProvider, ) : HomeRowLoadingState data class Success( - override val title: String, + override val title: StringProvider, val items: List, val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), val rowType: HomeRowConfig? = null, ) : HomeRowLoadingState data class Error( - override val title: String, + override val title: StringProvider, val message: String? = null, val exception: Throwable? = null, ) : HomeRowLoadingState { diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 00000000..467b3efe --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d5e4d22..6c18fa1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -793,6 +793,7 @@ For intros, credits, etc Play with Transcoding + User interface language Ratings display Customize which to display Profile protection