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
<img width="1280" height="720" alt="language Large"
src="https://github.com/user-attachments/assets/bf1c0962-8a36-4012-aed9-eab564fc1114"
/>

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-24 09:55:08 -04:00 committed by GitHub
parent 625cdcb4a3
commit 903c45dfb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1501 additions and 418 deletions

View file

@ -184,6 +184,9 @@ configure<ApplicationExtension> {
lint {
disable.add("MissingTranslation")
}
androidResources {
generateLocaleConfig = true
}
}
androidComponents {

View file

@ -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')"
]
}
}

View file

@ -84,6 +84,15 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="false" />
</service>
</application>
</manifest>

View file

@ -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)

View file

@ -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 },

View file

@ -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()

View file

@ -704,6 +704,13 @@ sealed interface AppPreference<Pref, T> {
summary = R.string.customize_home_summary,
)
val UserInterfaceLanguage =
AppClickablePreference<AppPreferences>(
title = R.string.user_interface_language,
getter = { },
setter = { prefs, _ -> prefs },
)
val SendCrashReports =
AppSwitchPreference<AppPreferences>(
title = R.string.send_crash_reports,
@ -1110,6 +1117,7 @@ val basicPreferences =
AppPreference.ProtectProfilePreference,
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
AppPreference.UserInterfaceLanguage,
),
),
PreferenceGroup(

View file

@ -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)
}

View file

@ -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?,

View file

@ -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)
}
}
}
}
}
}
}

View file

@ -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<DialogParams?>(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,
) {

View file

@ -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<MediaSourceInfo>,
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<MediaStream>,
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 = {

View file

@ -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,

View file

@ -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(

View file

@ -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 },

View file

@ -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 {

View file

@ -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<Pair<String, String>> =
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<Pair<String, String>> =
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<Pair<String, String>> =
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<Pair<String, String>> =
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)) }

View file

@ -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(

View file

@ -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 -> {

View file

@ -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,
),

View file

@ -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))

View file

@ -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() } }

View file

@ -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,
)

View file

@ -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,
),

View file

@ -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,
)

View file

@ -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<List<DiscoverItem>> -> {
DiscoverItemRow(
title = row.title,
title = row.title.getString(),
items = state.data,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,

View file

@ -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<List<DiscoverItem>>,
val type: DiscoverRequestType,
) {
companion object {
val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN)
val EMPTY =
DiscoverRowData(
EmptyStringProvider,
DataLoadingState.Pending,
DiscoverRequestType.UNKNOWN,
)
}
}

View file

@ -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(),
)

View file

@ -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)
}
},
)
}

View file

@ -221,7 +221,7 @@ fun HomeSettingsPage(
}
}
HomeRowSettings(
title = row.title,
title = row.title.getString(),
preferenceOptions = preferenceOptions,
viewOptions = row.config.viewOptions,
defaultViewOptions = defaultViewOptions,

View file

@ -209,7 +209,7 @@ fun HomeRowConfigContent(
) {
HomeSettingsListItem(
selected = false,
headlineText = config.title,
headlineText = config.title.getString(),
onClick = onClick,
modifier = Modifier.weight(1f),
)

View file

@ -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,

View file

@ -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<T>(
val title: String?,
@param:StringRes val titleRes: Int?,
val title: StringProvider,
@Contextual val request: T,
val requestHandler: RequestHandler<T>,
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)

View file

@ -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<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty()

View file

@ -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),
)
}
}

View file

@ -155,7 +155,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
it.copy(
subtitleStreams =
subtitlesStreams.map {
SimpleMediaStream.from(context, it, true)
SimpleMediaStream.from(context.resources, it, true)
},
)
}

View file

@ -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<LocaleChoiceState> = _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<Locale> {
val id =
context.resources.getIdentifier(
"_generated_res_locale_config",
"xml",
context.packageName,
)
return if (id != 0) {
try {
val locales = mutableListOf<Locale>()
// 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<Locale> = 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),
)
}
}
}
}

View file

@ -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>(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<AppPreferences, Any>
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

View file

@ -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)

View file

@ -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<String>,
) : 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())
}

View file

@ -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<BaseItem?>,
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 {

View file

@ -0,0 +1 @@
unqualifiedResLocale=en-US

View file

@ -793,6 +793,7 @@
<string name="skip_behavior_summary">For intros, credits, etc</string>
<string name="play_with">Play with</string>
<string name="transcoding">Transcoding</string>
<string name="user_interface_language">User interface language</string>
<string name="display_toggles_title">Ratings display</string>
<string name="display_toggles_summary">Customize which to display</string>
<string name="profile_protection">Profile protection</string>