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 { lint {
disable.add("MissingTranslation") disable.add("MissingTranslation")
} }
androidResources {
generateLocaleConfig = true
}
} }
androidComponents { 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" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</service> </service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="false" />
</service>
</application> </application>
</manifest> </manifest>

View file

@ -46,9 +46,8 @@ import java.util.UUID
ItemTrackModification::class, ItemTrackModification::class,
SeerrServer::class, SeerrServer::class,
SeerrUser::class, SeerrUser::class,
], ],
version = 32, version = 33,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -64,6 +63,7 @@ import java.util.UUID
AutoMigration(20, 30), AutoMigration(20, 30),
AutoMigration(30, 31), AutoMigration(30, 31),
AutoMigration(31, 32), AutoMigration(31, 32),
AutoMigration(32, 33),
], ],
) )
@TypeConverters(Converters::class) @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.AspectRatio
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ExtraType import org.jellyfin.sdk.model.api.ExtraType
@ -41,8 +42,7 @@ sealed interface ExtrasItem {
) : ExtrasItem { ) : ExtrasItem {
override val destination: Destination = override val destination: Destination =
Destination.ItemGrid( Destination.ItemGrid(
title = null, title = ResStringProvider(type.stringRes),
titleRes = type.stringRes,
request = request =
GetItemsRequest( GetItemsRequest(
ids = items.map { it.id }, ids = items.map { it.id },

View file

@ -64,6 +64,7 @@ data class JellyfinUser(
@ColumnInfo(defaultValue = "false") @ColumnInfo(defaultValue = "false")
val requireLogin: Boolean = false, val requireLogin: Boolean = false,
val lastUsed: ZonedDateTime? = null, val lastUsed: ZonedDateTime? = null,
val uiLanguage: String? = null,
) { ) {
val hasPin: Boolean get() = pin.isNotNullOrBlank() val hasPin: Boolean get() = pin.isNotNullOrBlank()

View file

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

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem 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.playback.getTypeFor
import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString 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.ApiRequestPager
import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -275,11 +281,11 @@ class HomeSettingsService
libraries libraries
.mapIndexed { index, it -> .mapIndexed { index, it ->
val parentId = it.itemId val parentId = it.itemId
val title = getRecentlyAddedTitle(context, it) val title = getRecentlyAddedTitle(it)
if (it.collectionType == CollectionType.LIVETV) { if (it.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = index, id = index,
title = context.getString(R.string.live_tv), title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(), config = HomeRowConfig.TvPrograms(),
) )
} else { } else {
@ -295,7 +301,7 @@ class HomeSettingsService
listOf( listOf(
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = includedIds.size + 1, id = includedIds.size + 1,
title = context.getString(R.string.combine_continue_next), title = ResStringProvider(R.string.combine_continue_next),
config = HomeRowConfig.ContinueWatchingCombined(), config = HomeRowConfig.ContinueWatchingCombined(),
), ),
) )
@ -303,12 +309,12 @@ class HomeSettingsService
listOf( listOf(
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = includedIds.size + 1, id = includedIds.size + 1,
title = context.getString(R.string.continue_watching), title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(), config = HomeRowConfig.ContinueWatching(),
), ),
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = includedIds.size + 2, id = includedIds.size + 2,
title = context.getString(R.string.next_up), title = ResStringProvider(R.string.next_up),
config = HomeRowConfig.NextUp(), config = HomeRowConfig.NextUp(),
), ),
) )
@ -355,7 +361,7 @@ class HomeSettingsService
HomeSectionType.ACTIVE_RECORDINGS -> { HomeSectionType.ACTIVE_RECORDINGS -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = context.getString(R.string.active_recordings), title = ResStringProvider(R.string.active_recordings),
config = HomeRowConfig.Recordings(), config = HomeRowConfig.Recordings(),
) )
} }
@ -363,7 +369,7 @@ class HomeSettingsService
HomeSectionType.RESUME -> { HomeSectionType.RESUME -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = context.getString(R.string.continue_watching), title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(), config = HomeRowConfig.ContinueWatching(),
) )
} }
@ -371,7 +377,7 @@ class HomeSettingsService
HomeSectionType.NEXT_UP -> { HomeSectionType.NEXT_UP -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = context.getString(R.string.next_up), title = ResStringProvider(R.string.next_up),
config = HomeRowConfig.NextUp(), config = HomeRowConfig.NextUp(),
) )
} }
@ -380,7 +386,7 @@ class HomeSettingsService
if (userDto.tvAccess) { if (userDto.tvAccess) {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = context.getString(R.string.live_tv), title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(), config = HomeRowConfig.TvPrograms(),
) )
} else { } else {
@ -413,7 +419,7 @@ class HomeSettingsService
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = title =
context.getString( ResArgStringProvider(
R.string.recently_added_in, R.string.recently_added_in,
it.name ?: "", it.name ?: "",
), ),
@ -441,7 +447,7 @@ class HomeSettingsService
): HomeRowConfigDisplay = ): HomeRowConfigDisplay =
when (config) { when (config) {
is HomeRowConfig.ByParent -> { is HomeRowConfig.ByParent -> {
val name = getItemName(config.parentId) ?: "" val name = getItemName(null, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
name, name,
@ -452,7 +458,7 @@ class HomeSettingsService
is HomeRowConfig.ContinueWatching -> { is HomeRowConfig.ContinueWatching -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.continue_watching), ResStringProvider(R.string.continue_watching),
config, config,
) )
} }
@ -460,64 +466,64 @@ class HomeSettingsService
is HomeRowConfig.ContinueWatchingCombined -> { is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.combine_continue_next), ResStringProvider(R.string.combine_continue_next),
config, config,
) )
} }
is HomeRowConfig.Genres -> { is HomeRowConfig.Genres -> {
val name = getItemName(config.parentId) ?: "" val title = getItemName(R.string.genres_in, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.genres_in, name), title,
config, config,
) )
} }
is HomeRowConfig.Studios -> { is HomeRowConfig.Studios -> {
val name = getItemName(config.parentId) ?: "" val title = getItemName(R.string.studios_in, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.studios_in, name), title,
config, config,
) )
} }
is HomeRowConfig.GetItems -> { is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(id, config.name, config) HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
} }
is HomeRowConfig.NextUp -> { is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.next_up), ResStringProvider(R.string.next_up),
config, config,
) )
} }
is HomeRowConfig.RecentlyAdded -> { is HomeRowConfig.RecentlyAdded -> {
val name = getItemName(config.parentId) ?: "" val title = getItemName(R.string.recently_added_in, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.recently_added_in, name), title,
config, config,
) )
} }
is HomeRowConfig.RecentlyReleased -> { is HomeRowConfig.RecentlyReleased -> {
val name = getItemName(config.parentId) ?: "" val title = getItemName(R.string.recently_released_in, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.recently_released_in, name), title,
config, config,
) )
} }
is HomeRowConfig.Favorite -> { is HomeRowConfig.Favorite -> {
val name = val name =
context.getString( ResProviderStringProvider(
R.string.favorite_items, R.string.favorite_items,
context.getString(favoriteOptions[config.kind]!!), ResStringProvider(favoriteOptions[config.kind]!!),
) )
HomeRowConfigDisplay(id, name, config) HomeRowConfigDisplay(id, name, config)
} }
@ -525,7 +531,7 @@ class HomeSettingsService
is HomeRowConfig.Recordings -> { is HomeRowConfig.Recordings -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.active_recordings), title = ResStringProvider(R.string.active_recordings),
config, config,
) )
} }
@ -533,7 +539,7 @@ class HomeSettingsService
is HomeRowConfig.TvPrograms -> { is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.live_tv), title = ResStringProvider(R.string.live_tv),
config, config,
) )
} }
@ -541,29 +547,40 @@ class HomeSettingsService
is HomeRowConfig.TvChannels -> { is HomeRowConfig.TvChannels -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.channels), title = ResStringProvider(R.string.channels),
config, config,
) )
} }
is HomeRowConfig.Suggestions -> { is HomeRowConfig.Suggestions -> {
val name = getItemName(config.parentId) ?: "" val title = getItemName(R.string.suggestions_for, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.suggestions_for, name), title = title,
config, config,
) )
} }
} }
private suspend fun getItemName(itemId: UUID): String? = private suspend fun getItemName(
@StringRes stringRes: Int?,
itemId: UUID,
default: StringProvider = StringStringProvider(""),
): StringProvider =
try { try {
api.userLibraryApi api.userLibraryApi
.getItem(itemId = itemId) .getItem(itemId = itemId)
.content.name .content.name
?.let {
if (stringRes == null) {
StringStringProvider(it)
} else {
ResArgStringProvider(stringRes, it)
}
} ?: default
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Could not get name for %s", itemId) 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( Success(
title = context.getString(R.string.continue_watching), title = ResStringProvider(R.string.continue_watching),
items = resume, items = resume,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row, rowType = row,
@ -609,7 +626,7 @@ class HomeSettingsService
) )
Success( Success(
title = context.getString(R.string.next_up), title = ResStringProvider(R.string.next_up),
items = nextUp, items = nextUp,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row, rowType = row,
@ -635,7 +652,7 @@ class HomeSettingsService
) )
Success( Success(
title = context.getString(R.string.continue_watching), title = ResStringProvider(R.string.continue_watching),
items = items =
latestNextUpService latestNextUpService
.buildCombined( .buildCombined(
@ -676,8 +693,8 @@ class HomeSettingsService
.firstOrNull { it.itemId == row.parentId } .firstOrNull { it.itemId == row.parentId }
val title = val title =
library?.name?.let { context.getString(R.string.genres_in, it) } library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
?: context.getString(R.string.genres) ?: ResStringProvider(R.string.genres)
val genres = val genres =
items.map { items.map {
BaseItem( BaseItem(
@ -723,8 +740,8 @@ class HomeSettingsService
libraries libraries
.firstOrNull { it.itemId == row.parentId } .firstOrNull { it.itemId == row.parentId }
val title = val title =
library?.name?.let { context.getString(R.string.studios_in, it) } library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
?: context.getString(R.string.studios) ?: ResStringProvider(R.string.studios)
val studios = val studios =
items.map { items.map {
val imageUrl = val imageUrl =
@ -762,7 +779,7 @@ class HomeSettingsService
val library = val library =
libraries libraries
.firstOrNull { it.itemId == row.parentId } .firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(context, library) val title = getRecentlyAddedTitle(library)
val request = val request =
GetLatestMediaRequest( GetLatestMediaRequest(
fields = SlimItemFields, fields = SlimItemFields,
@ -795,8 +812,8 @@ class HomeSettingsService
?.name ?.name
val title = val title =
name?.let { name?.let {
context.getString(R.string.recently_released_in, it) ResArgStringProvider(R.string.recently_released_in, it)
} ?: context.getString(R.string.recently_released) } ?: ResStringProvider(R.string.recently_released)
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = row.parentId, parentId = row.parentId,
@ -853,7 +870,8 @@ class HomeSettingsService
fields = DefaultItemFields, fields = DefaultItemFields,
) )
val name = getItemName(row.parentId) val title =
getItemName(null, row.parentId, ResStringProvider(R.string.collection))
if (usePaging) { if (usePaging) {
ApiRequestPager( ApiRequestPager(
api, api,
@ -869,7 +887,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) } .map { BaseItem(it, row.viewOptions.useSeries) }
}.let { }.let {
Success( Success(
name ?: context.getString(R.string.collection), title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
@ -906,7 +924,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) } .map { BaseItem(it, row.viewOptions.useSeries) }
}.let { }.let {
Success( Success(
row.name, StringStringProvider(row.name),
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
@ -916,9 +934,9 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> { is HomeRowConfig.Favorite -> {
val title = val title =
context.getString( ResProviderStringProvider(
R.string.favorite_items, R.string.favorite_items,
context.getString(favoriteOptions[row.kind]!!), ResStringProvider(favoriteOptions[row.kind]!!),
) )
if (row.kind == BaseItemKind.PERSON) { if (row.kind == BaseItemKind.PERSON) {
val request = val request =
@ -1001,7 +1019,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) } .map { BaseItem(it, row.viewOptions.useSeries) }
}.let { }.let {
Success( Success(
context.getString(R.string.active_recordings), ResStringProvider(R.string.active_recordings),
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
@ -1027,7 +1045,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) } .map { BaseItem(it, row.viewOptions.useSeries) }
.let { .let {
Success( Success(
context.getString(R.string.live_tv), ResStringProvider(R.string.live_tv),
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
@ -1057,7 +1075,7 @@ class HomeSettingsService
.toBaseItems(api, row.viewOptions.useSeries) .toBaseItems(api, row.viewOptions.useSeries)
}.let { }.let {
Success( Success(
context.getString(R.string.channels), ResStringProvider(R.string.channels),
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
@ -1070,7 +1088,7 @@ class HomeSettingsService
api.userLibraryApi api.userLibraryApi
.getItem(itemId = row.parentId) .getItem(itemId = row.parentId)
.content .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 itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
val suggestions = val suggestions =
itemKind?.let { itemKind?.let {
@ -1111,7 +1129,7 @@ class HomeSettingsService
*/ */
data class HomeRowConfigDisplay( data class HomeRowConfigDisplay(
val id: Int, val id: Int,
val title: String, val title: StringProvider,
val config: HomeRowConfig, val config: HomeRowConfig,
) )
@ -1154,13 +1172,10 @@ class UnsupportedHomeSettingsVersionException(
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
fun getRecentlyAddedTitle( fun getRecentlyAddedTitle(library: Library?): StringProvider =
context: Context,
library: Library?,
): String =
if (library?.isRecordingFolder == true) { if (library?.isRecordingFolder == true) {
context.getString(R.string.recently_recorded) ResStringProvider(R.string.recently_recorded)
} else { } else {
library?.name?.let { context.getString(R.string.recently_added_in, it) } library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added) ?: ResStringProvider(R.string.recently_added)
} }

View file

@ -1,10 +1,5 @@
package com.github.damontecres.wholphin.services 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.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest 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.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository 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.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient 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.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.util.LoadingState 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -140,7 +129,7 @@ class SeerrServerRepository
serverRepository.currentUser.value?.let { jellyfinUser -> serverRepository.currentUser.value?.let { jellyfinUser ->
// TODO Need to update server early so that cookies are saved // TODO Need to update server early so that cookies are saved
seerrApi.update(server.url, null) seerrApi.update(server.url, null)
val userConfig = login(seerrApi.api, authMethod, username, password) val userConfig = seerrLogin(seerrApi.api, authMethod, username, password)
val user = val user =
SeerrUser( SeerrUser(
@ -174,7 +163,7 @@ class SeerrServerRepository
.readTimeout(6.seconds) .readTimeout(6.seconds)
.build(), .build(),
) )
login(api, authMethod, username, passwordOrApiKey) seerrLogin(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success return LoadingState.Success
} }
@ -227,7 +216,7 @@ data class CurrentSeerr(
config.hasPermission(SeerrPermission.REQUEST_4K_TV) config.hasPermission(SeerrPermission.REQUEST_4K_TV)
} }
private suspend fun login( suspend fun seerrLogin(
client: SeerrApiClient, client: SeerrApiClient,
authMethod: SeerrAuthMethod, authMethod: SeerrAuthMethod,
username: String?, 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( fun CurrentSeerr?.imageUrlBuilder(
imageType: ImageType, imageType: ImageType,
path: String?, 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 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add 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.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -184,15 +184,15 @@ fun ContextMenu(
) { ) {
val item = contextMenu.item val item = contextMenu.item
val chosenStreams = contextMenu.chosenStreams val chosenStreams = contextMenu.chosenStreams
val context = LocalContext.current val resources = LocalResources.current
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showPlayWithDialog by remember { mutableStateOf(false) } var showPlayWithDialog by remember { mutableStateOf(false) }
val dialogItems = val dialogItems =
remember(context, item, chosenStreams) { remember(resources, item, chosenStreams) {
buildContextMenuItems( buildContextMenuItems(
context = context, resources = resources,
item = item, item = item,
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
@ -208,7 +208,7 @@ fun ContextMenu(
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
chooseVersionParams( chooseVersionParams(
context, resources,
item.data.mediaSources.orEmpty(), item.data.mediaSources.orEmpty(),
chosenStreams?.source?.id?.toUUIDOrNull(), chosenStreams?.source?.id?.toUUIDOrNull(),
) { idx -> ) { idx ->
@ -225,7 +225,7 @@ fun ContextMenu(
)?.let { source -> )?.let { source ->
chooseVersion = chooseVersion =
chooseStream( chooseStream(
context = context, resources = resources,
streams = source.mediaStreams.orEmpty(), streams = source.mediaStreams.orEmpty(),
type = type, type = type,
currentIndex = currentIndex =
@ -290,7 +290,7 @@ fun ContextMenu(
if (showPlayWithDialog) { if (showPlayWithDialog) {
val dialogItems = val dialogItems =
remember { remember {
buildPlayWith(context) { transcode, backend -> buildPlayWith(resources) { transcode, backend ->
onDismissRequest.invoke() onDismissRequest.invoke()
actions.navigateTo( actions.navigateTo(
Destination.Playback( Destination.Playback(
@ -315,7 +315,7 @@ fun ContextMenu(
} }
private fun buildContextMenuItems( private fun buildContextMenuItems(
context: Context, resources: Resources,
item: BaseItem, item: BaseItem,
seriesId: UUID?, seriesId: UUID?,
sourceId: UUID?, sourceId: UUID?,
@ -339,7 +339,7 @@ private fun buildContextMenuItems(
if (showGoTo) { if (showGoTo) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to), resources.getString(R.string.go_to),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -351,7 +351,7 @@ private fun buildContextMenuItems(
if (item.playbackPosition > Duration.ZERO) { if (item.playbackPosition > Duration.ZERO) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.resume), resources.getString(R.string.resume),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true, dismissOnClick = true,
@ -366,7 +366,7 @@ private fun buildContextMenuItems(
) )
add( add(
DialogItem( DialogItem(
context.getString(R.string.restart), resources.getString(R.string.restart),
Icons.Default.Refresh, Icons.Default.Refresh,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -381,7 +381,7 @@ private fun buildContextMenuItems(
} else { } else {
add( add(
DialogItem( DialogItem(
context.getString(R.string.play), resources.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true, dismissOnClick = true,
@ -407,9 +407,9 @@ private fun buildContextMenuItems(
if (audioCount > 1) { if (audioCount > 1) {
add( add(
DialogItem( DialogItem(
context.getString( resources.getString(
R.string.choose_stream, R.string.choose_stream,
context.getString(R.string.audio), resources.getString(R.string.audio),
), ),
R.string.fa_volume_low, R.string.fa_volume_low,
dismissOnClick = false, dismissOnClick = false,
@ -421,9 +421,9 @@ private fun buildContextMenuItems(
if (subtitleCount > 0) { if (subtitleCount > 0) {
add( add(
DialogItem( DialogItem(
context.getString( resources.getString(
R.string.choose_stream, R.string.choose_stream,
context.getString(R.string.subtitles), resources.getString(R.string.subtitles),
), ),
R.string.fa_closed_captioning, R.string.fa_closed_captioning,
dismissOnClick = false, dismissOnClick = false,
@ -436,9 +436,9 @@ private fun buildContextMenuItems(
if (sources.size > 1) { if (sources.size > 1) {
add( add(
DialogItem( DialogItem(
context.getString( resources.getString(
R.string.choose_stream, R.string.choose_stream,
context.getString(R.string.version), resources.getString(R.string.version),
), ),
R.string.fa_file_video, R.string.fa_file_video,
dismissOnClick = false, dismissOnClick = false,
@ -452,7 +452,7 @@ private fun buildContextMenuItems(
if (item.type == BaseItemKind.MUSIC_ALBUM) { if (item.type == BaseItemKind.MUSIC_ALBUM) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.add_to_queue), resources.getString(R.string.add_to_queue),
Icons.Default.Add, Icons.Default.Add,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -472,7 +472,7 @@ private fun buildContextMenuItems(
if (canDelete) { if (canDelete) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.delete), resources.getString(R.string.delete),
Icons.Default.Delete, Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f), iconColor = Color.Red.copy(alpha = .8f),
dismissOnClick = false, dismissOnClick = false,
@ -525,7 +525,7 @@ private fun buildContextMenuItems(
item.data.albumId?.let { albumId -> item.data.albumId?.let { albumId ->
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_album), resources.getString(R.string.go_to_album),
R.string.fa_compact_disc, R.string.fa_compact_disc,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -542,7 +542,7 @@ private fun buildContextMenuItems(
item.data.artistItems?.firstOrNull()?.id?.let { artistId -> item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_artist), resources.getString(R.string.go_to_artist),
R.string.fa_user, R.string.fa_user,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -559,7 +559,7 @@ private fun buildContextMenuItems(
seriesId?.let { seriesId?.let {
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_series), resources.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward, Icons.AutoMirrored.Filled.ArrowForward,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -576,7 +576,7 @@ private fun buildContextMenuItems(
if (item.data.mediaSources?.isNotEmpty() == true) { if (item.data.mediaSources?.isNotEmpty() == true) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.media_information), resources.getString(R.string.media_information),
Icons.Default.Info, Icons.Default.Info,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -587,7 +587,7 @@ private fun buildContextMenuItems(
if (showStreamChoices && canClearChosenStreams) { if (showStreamChoices && canClearChosenStreams) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.clear_track_choices), resources.getString(R.string.clear_track_choices),
Icons.Default.Delete, Icons.Default.Delete,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -598,7 +598,7 @@ private fun buildContextMenuItems(
if (item.type in supportedPlayableTypes) { if (item.type in supportedPlayableTypes) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.play_with), resources.getString(R.string.play_with),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
dismissOnClick = false, dismissOnClick = false,
onClick = onClickPlayWith, onClick = onClickPlayWith,
@ -619,13 +619,13 @@ private fun buildContextMenuItems(
} }
private fun buildPlayWith( private fun buildPlayWith(
context: Context, resources: Resources,
onClick: (Boolean, PlayerBackend?) -> Unit, onClick: (Boolean, PlayerBackend?) -> Unit,
) = buildList { ) = buildList {
val entries = val entries =
PlayerBackend.entries PlayerBackend.entries
.filterNot { it == PlayerBackend.UNRECOGNIZED } .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 } .filterNot { it.first == PlayerBackend.PREFER_MPV }
entries.forEach { (backend, title) -> entries.forEach { (backend, title) ->
add( add(
@ -639,7 +639,7 @@ private fun buildPlayWith(
} }
add( add(
DialogItem( DialogItem(
context.getString(R.string.transcoding), resources.getString(R.string.transcoding),
dismissOnClick = true, dismissOnClick = true,
) { ) {
onClick.invoke(true, null) onClick.invoke(true, null)
@ -691,11 +691,11 @@ fun ContextMenu(
item: ContextMenu.ForMusic, item: ContextMenu.ForMusic,
actions: MusicContextActions, actions: MusicContextActions,
) { ) {
val context = LocalContext.current val resources = LocalResources.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
val dialogItems = val dialogItems =
remember(context, item, actions) { remember(resources, item, actions) {
buildContextForMusic(context, item, actions, onClickDelete = { buildContextForMusic(resources, item, actions, onClickDelete = {
showDeleteDialog = true showDeleteDialog = true
}) })
} }
@ -720,7 +720,7 @@ fun ContextMenu(
} }
fun buildContextForMusic( fun buildContextForMusic(
context: Context, resources: Resources,
music: ContextMenu.ForMusic, music: ContextMenu.ForMusic,
actions: MusicContextActions, actions: MusicContextActions,
onClickDelete: () -> Unit, onClickDelete: () -> Unit,
@ -730,7 +730,7 @@ fun buildContextForMusic(
val index = music.index val index = music.index
add( add(
DialogItem( DialogItem(
context.getString(R.string.play), resources.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true, dismissOnClick = true,
@ -740,7 +740,7 @@ fun buildContextForMusic(
) )
add( add(
DialogItem( DialogItem(
context.getString(R.string.play_next), resources.getString(R.string.play_next),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true, dismissOnClick = true,
@ -751,7 +751,7 @@ fun buildContextForMusic(
if (music.canRemoveFromQueue) { if (music.canRemoveFromQueue) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.remove_from_queue), resources.getString(R.string.remove_from_queue),
Icons.Default.Delete, Icons.Default.Delete,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -761,7 +761,7 @@ fun buildContextForMusic(
} else { } else {
add( add(
DialogItem( DialogItem(
context.getString(R.string.add_to_queue), resources.getString(R.string.add_to_queue),
Icons.Default.Add, Icons.Default.Add,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -781,7 +781,7 @@ fun buildContextForMusic(
if (music.canDelete) { if (music.canDelete) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.delete), resources.getString(R.string.delete),
Icons.Default.Delete, Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f), iconColor = Color.Red.copy(alpha = .8f),
dismissOnClick = false, dismissOnClick = false,
@ -803,7 +803,7 @@ fun buildContextForMusic(
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) { if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_album), resources.getString(R.string.go_to_album),
R.string.fa_compact_disc, R.string.fa_compact_disc,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -814,7 +814,7 @@ fun buildContextForMusic(
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) { if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_artist), resources.getString(R.string.go_to_artist),
R.string.fa_user, R.string.fa_user,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -834,11 +834,11 @@ fun ContextMenu(
item: ContextMenu.ForQueue, item: ContextMenu.ForQueue,
actions: QueueContextActions, actions: QueueContextActions,
) { ) {
val context = LocalContext.current val resources = LocalResources.current
val dialogItems = val dialogItems =
remember(context, item, actions) { remember(resources, item, actions) {
buildContextForMusicQueue( buildContextForMusicQueue(
context, resources,
item.item, item.item,
item.index, item.index,
actions, actions,
@ -855,7 +855,7 @@ fun ContextMenu(
} }
fun buildContextForMusicQueue( fun buildContextForMusicQueue(
context: Context, resources: Resources,
item: AudioItem, item: AudioItem,
index: Int, index: Int,
actions: QueueContextActions, actions: QueueContextActions,
@ -863,7 +863,7 @@ fun buildContextForMusicQueue(
buildList { buildList {
add( add(
DialogItem( DialogItem(
context.getString(R.string.play), resources.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true, dismissOnClick = true,
@ -873,7 +873,7 @@ fun buildContextForMusicQueue(
) )
add( add(
DialogItem( DialogItem(
context.getString(R.string.play_next), resources.getString(R.string.play_next),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true, dismissOnClick = true,
@ -883,7 +883,7 @@ fun buildContextForMusicQueue(
) )
add( add(
DialogItem( DialogItem(
context.getString(R.string.remove_from_queue), resources.getString(R.string.remove_from_queue),
Icons.Default.Delete, Icons.Default.Delete,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -893,7 +893,7 @@ fun buildContextForMusicQueue(
if (item.albumId != null) { if (item.albumId != null) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_album), resources.getString(R.string.go_to_album),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
dismissOnClick = true, dismissOnClick = true,
) { ) {
@ -904,7 +904,7 @@ fun buildContextForMusicQueue(
if (item.artistId != null) { if (item.artistId != null) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_artist), resources.getString(R.string.go_to_artist),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
dismissOnClick = true, dismissOnClick = true,
) { ) {

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.res.Resources
import android.view.KeyEvent import android.view.KeyEvent
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -608,14 +608,14 @@ fun ConfirmDeleteDialog(
} }
fun chooseVersionParams( fun chooseVersionParams(
context: Context, resources: Resources,
sources: List<MediaSourceInfo>, sources: List<MediaSourceInfo>,
chosenSourceId: UUID?, chosenSourceId: UUID?,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
fromLongClick = false, 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 = items =
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
val uuid = source.id?.toUUIDOrNull() val uuid = source.id?.toUUIDOrNull()
@ -649,7 +649,7 @@ fun resourceFor(type: MediaStreamType): Int =
} }
fun chooseStream( fun chooseStream(
context: Context, resources: Resources,
streams: List<MediaStream>, streams: List<MediaStream>,
currentIndex: Int?, currentIndex: Int?,
type: MediaStreamType, type: MediaStreamType,
@ -658,7 +658,7 @@ fun chooseStream(
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
fromLongClick = false, 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 = items =
buildList { buildList {
if (type == MediaStreamType.SUBTITLE) { if (type == MediaStreamType.SUBTITLE) {
@ -700,7 +700,7 @@ fun chooseStream(
it it
} }
}.mapIndexed { index, stream -> }.mapIndexed { index, stream ->
val simpleStream = SimpleMediaStream.from(context, stream, true) val simpleStream = SimpleMediaStream.from(resources, stream, true)
DialogItem( DialogItem(
selected = currentIndex == stream.index, selected = currentIndex == stream.index,
leadingContent = { leadingContent = {

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -112,7 +111,7 @@ fun ItemGrid(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
text = destination.title ?: destination.titleRes?.let { stringResource(it) } ?: "", text = destination.title.getString(),
style = MaterialTheme.typography.displayMedium, style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
@ -132,14 +133,14 @@ fun TimeRemaining(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall, textStyle: TextStyle = MaterialTheme.typography.titleSmall,
) { ) {
val context = LocalContext.current val resources = LocalResources.current
val now by LocalClock.current.now val now by LocalClock.current.now
val remainingStr = val remainingStr =
remember(remaining, now) { remember(remaining, now, resources) {
val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
buildAnnotatedString { buildAnnotatedString {
dot() dot()
append(context.getString(R.string.ends_at, endTimeStr)) append(resources.getString(R.string.ends_at, endTimeStr))
} }
} }
Text( 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.toBaseItems 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.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
@ -111,13 +112,13 @@ class RecommendedViewModel
it.copy( it.copy(
loading = LoadingState.Loading, loading = LoadingState.Loading,
rows = rows =
recommendedRows.map { HomeRowLoadingState.Loading(context.getString(it.title)) } + recommendedRows.map { HomeRowLoadingState.Loading(ResStringProvider(it.title)) } +
listOf(HomeRowLoadingState.Loading(context.getString(R.string.suggestions))), listOf(HomeRowLoadingState.Loading(ResStringProvider(R.string.suggestions))),
) )
} }
val jobs = val jobs =
recommendedRows.mapIndexed { index, row -> recommendedRows.mapIndexed { index, row ->
val title = context.getString(row.title) val title = ResStringProvider(row.title)
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
try { try {
@ -173,7 +174,7 @@ class RecommendedViewModel
private fun fetchSuggestions() { private fun fetchSuggestions() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val title = context.getString(R.string.suggestions) val title = ResStringProvider(R.string.suggestions)
try { try {
suggestionService suggestionService
.getSuggestionsFlow(parentId, suggestionsType) .getSuggestionsFlow(parentId, suggestionsType)
@ -298,7 +299,6 @@ class RecommendedViewModel
navigationManager.navigateTo( navigationManager.navigateTo(
Destination.ItemGrid( Destination.ItemGrid(
title = row.title, title = row.title,
titleRes = recommendedRow.title,
request = recommendedRow.request, request = recommendedRow.request,
requestHandler = recommendedRow.handler, requestHandler = recommendedRow.handler,
initialPosition = row.items.size, initialPosition = row.items.size,
@ -313,7 +313,6 @@ class RecommendedViewModel
navigationManager.navigateTo( navigationManager.navigateTo(
Destination.ItemGrid( Destination.ItemGrid(
title = row.title, title = row.title,
titleRes = R.string.suggestions,
request = request =
GetItemsRequest( GetItemsRequest(
ids = row.items.mapNotNull { it?.id }, 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -69,7 +69,7 @@ fun VideoStreamDetails(
numberOfVersions: Int = 0, numberOfVersions: Int = 0,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val resources = LocalResources.current
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
@ -90,7 +90,13 @@ fun VideoStreamDetails(
} else { } else {
null null
} }
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) val range =
formatVideoRange(
resources,
it.videoRange,
it.videoRangeType,
it.videoDoViTitle,
)
resName.concatWithSpace(range) resName.concatWithSpace(range)
} }
} }
@ -108,11 +114,11 @@ fun VideoStreamDetails(
val audio = val audio =
remember(audioCount, audioStream) { remember(audioCount, audioStream) {
if (audioCount == 0 || audioStream == null) { if (audioCount == 0 || audioStream == null) {
context.getString(R.string.none) resources.getString(R.string.none)
} else { } else {
listOfNotNull( listOfNotNull(
languageName(audioStream.language), languageName(audioStream.language),
formatAudioCodec(context, audioStream.codec, audioStream.profile), formatAudioCodec(resources, audioStream.codec, audioStream.profile),
audioStream.channelLayout, audioStream.channelLayout,
).joinToString(" ") ).joinToString(" ")
} }
@ -133,7 +139,7 @@ fun VideoStreamDetails(
remember(subtitleCount, subtitleStream) { remember(subtitleCount, subtitleStream) {
if (subtitleCount > 0 && subtitleStream == null) { if (subtitleCount > 0 && subtitleStream == null) {
disabled = true disabled = true
context.getString(R.string.none) resources.getString(R.string.none)
} else if (subtitleCount == 0 || subtitleStream == null) { } else if (subtitleCount == 0 || subtitleStream == null) {
null null
} else { } else {

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.ui.data 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -13,7 +13,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -66,7 +66,7 @@ fun ItemDetailsDialog(
showFilePath: Boolean, showFilePath: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val context = LocalContext.current val resources = LocalResources.current
// Extract stringResource calls outside of ScrollableDialog's non-composable lambda // Extract stringResource calls outside of ScrollableDialog's non-composable lambda
val pathLabel = stringResource(R.string.path) val pathLabel = stringResource(R.string.path)
val fileSizeLabel = stringResource(R.string.file_size) val fileSizeLabel = stringResource(R.string.file_size)
@ -164,8 +164,8 @@ fun ItemDetailsDialog(
itemsIndexed(videoStreams) { index, stream -> itemsIndexed(videoStreams) { index, stream ->
MediaInfoSection( MediaInfoSection(
title = titleIndex(videoLabel, index, videoStreams.size), title = titleIndex(videoLabel, index, videoStreams.size),
items = remember { buildVideoStreamInfo(context, stream) }, items = remember { buildVideoStreamInfo(resources, stream) },
additional = remember { buildVideoStreamInfoAdditional(context, stream) }, additional = remember { buildVideoStreamInfoAdditional(resources, stream) },
) )
} }
@ -184,7 +184,7 @@ fun ItemDetailsDialog(
groupIndex * 3 + index, groupIndex * 3 + index,
audioStreams.size, audioStreams.size,
), ),
items = buildAudioStreamInfo(context, stream), items = buildAudioStreamInfo(resources, stream),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@ -210,7 +210,7 @@ fun ItemDetailsDialog(
groupIndex * 3 + index, groupIndex * 3 + index,
subtitleStreams.size, subtitleStreams.size,
), ),
items = buildSubtitleStreamInfo(context, stream, showFilePath), items = buildSubtitleStreamInfo(resources, stream, showFilePath),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@ -301,25 +301,25 @@ fun titleIndex(
} }
private fun buildVideoStreamInfo( private fun buildVideoStreamInfo(
context: Context, resources: Resources,
stream: MediaStream, stream: MediaStream,
): List<Pair<String, String>> = ): List<Pair<String, String>> =
buildList { buildList {
val yesStr = context.getString(R.string.yes) val yesStr = resources.getString(R.string.yes)
val noStr = context.getString(R.string.no) val noStr = resources.getString(R.string.no)
val titleLabel = context.getString(R.string.title) val titleLabel = resources.getString(R.string.title)
val codecLabel = context.getString(R.string.codec) val codecLabel = resources.getString(R.string.codec)
val resolutionLabel = context.getString(R.string.resolution) val resolutionLabel = resources.getString(R.string.resolution)
val aspectRatioLabel = context.getString(R.string.aspect_ratio) val aspectRatioLabel = resources.getString(R.string.aspect_ratio)
val framerateLabel = context.getString(R.string.framerate) val framerateLabel = resources.getString(R.string.framerate)
val bitrateLabel = context.getString(R.string.bitrate) val bitrateLabel = resources.getString(R.string.bitrate)
val profileLabel = context.getString(R.string.profile) val profileLabel = resources.getString(R.string.profile)
val levelLabel = context.getString(R.string.level) val levelLabel = resources.getString(R.string.level)
val interlacedLabel = context.getString(R.string.interlaced) val interlacedLabel = resources.getString(R.string.interlaced)
val videoRangeLabel = context.getString(R.string.video_range) val videoRangeLabel = resources.getString(R.string.video_range)
val sdrStr = context.getString(R.string.sdr) val sdrStr = resources.getString(R.string.sdr)
val hdrStr = context.getString(R.string.hdr) val hdrStr = resources.getString(R.string.hdr)
stream.title?.let { add(titleLabel to it) } stream.title?.let { add(titleLabel to it) }
stream.codec?.let { add(codecLabel to it.uppercase()) } stream.codec?.let { add(codecLabel to it.uppercase()) }
@ -350,31 +350,31 @@ private fun buildVideoStreamInfo(
} }
private fun buildVideoStreamInfoAdditional( private fun buildVideoStreamInfoAdditional(
context: Context, resources: Resources,
stream: MediaStream, stream: MediaStream,
): List<Pair<String, String>> = ): List<Pair<String, String>> =
buildList { buildList {
val yesStr = context.getString(R.string.yes) val yesStr = resources.getString(R.string.yes)
val noStr = context.getString(R.string.no) val noStr = resources.getString(R.string.no)
val avcLabel = context.getString(R.string.avc) val avcLabel = resources.getString(R.string.avc)
val anamorphicLabel = context.getString(R.string.anamorphic) val anamorphicLabel = resources.getString(R.string.anamorphic)
val bitDepthLabel = context.getString(R.string.bit_depth) val bitDepthLabel = resources.getString(R.string.bit_depth)
val videoRangeTypeLabel = context.getString(R.string.video_range_type) val videoRangeTypeLabel = resources.getString(R.string.video_range_type)
val colorSpaceLabel = context.getString(R.string.color_space) val colorSpaceLabel = resources.getString(R.string.color_space)
val colorTransferLabel = context.getString(R.string.color_transfer) val colorTransferLabel = resources.getString(R.string.color_transfer)
val colorPrimariesLabel = context.getString(R.string.color_primaries) val colorPrimariesLabel = resources.getString(R.string.color_primaries)
val pixelFormatLabel = context.getString(R.string.pixel_format) val pixelFormatLabel = resources.getString(R.string.pixel_format)
val refFramesLabel = context.getString(R.string.ref_frames) val refFramesLabel = resources.getString(R.string.ref_frames)
val nalLabel = context.getString(R.string.nal) val nalLabel = resources.getString(R.string.nal)
val dolbyVisionLabel = context.getString(R.string.dolby_vision) val dolbyVisionLabel = resources.getString(R.string.dolby_vision)
val sdrStr = context.getString(R.string.sdr) val sdrStr = resources.getString(R.string.sdr)
val hdr10Str = context.getString(R.string.hdr10) val hdr10Str = resources.getString(R.string.hdr10)
val hdr10PlusStr = context.getString(R.string.hdr10_plus) val hdr10PlusStr = resources.getString(R.string.hdr10_plus)
val hlgStr = context.getString(R.string.hlg) val hlgStr = resources.getString(R.string.hlg)
val bitUnit = context.getString(R.string.bit_unit) val bitUnit = resources.getString(R.string.bit_unit)
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.isAnamorphic?.let { add(anamorphicLabel 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_HDR10,
VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_HLG,
VideoRangeType.DOVI_WITH_SDR, VideoRangeType.DOVI_WITH_SDR,
-> context.getString(R.string.dolby_vision) -> resources.getString(R.string.dolby_vision)
VideoRangeType.UNKNOWN -> null VideoRangeType.UNKNOWN -> null
@ -412,27 +412,27 @@ private fun buildVideoStreamInfoAdditional(
} }
private fun buildAudioStreamInfo( private fun buildAudioStreamInfo(
context: Context, resources: android.content.res.Resources,
stream: MediaStream, stream: MediaStream,
): List<Pair<String, String>> = ): List<Pair<String, String>> =
buildList { buildList {
val titleLabel = context.getString(R.string.title) val titleLabel = resources.getString(R.string.title)
val languageLabel = context.getString(R.string.language) val languageLabel = resources.getString(R.string.language)
val codecLabel = context.getString(R.string.codec) val codecLabel = resources.getString(R.string.codec)
val layoutLabel = context.getString(R.string.layout) val layoutLabel = resources.getString(R.string.layout)
val channelsLabel = context.getString(R.string.channels) val channelsLabel = resources.getString(R.string.channels)
val bitrateLabel = context.getString(R.string.bitrate) val bitrateLabel = resources.getString(R.string.bitrate)
val sampleRateLabel = context.getString(R.string.sample_rate) val sampleRateLabel = resources.getString(R.string.sample_rate)
val defaultLabel = context.getString(R.string.default_track) val defaultLabel = resources.getString(R.string.default_track)
val profileLabel = context.getString(R.string.profile) val profileLabel = resources.getString(R.string.profile)
val yesStr = context.getString(R.string.yes) val yesStr = resources.getString(R.string.yes)
val noStr = context.getString(R.string.no) val noStr = resources.getString(R.string.no)
val sampleRateUnit = context.getString(R.string.sample_rate_unit) val sampleRateUnit = resources.getString(R.string.sample_rate_unit)
stream.title?.let { add(titleLabel to it) } stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) } stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let { stream.codec?.let {
val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)" val formattedCodec = formatAudioCodec(resources, it, stream.profile) + " ($it)"
add(codecLabel to formattedCodec) add(codecLabel to formattedCodec)
} }
stream.channelLayout?.let { add(layoutLabel to it) } stream.channelLayout?.let { add(layoutLabel to it) }
@ -444,20 +444,20 @@ private fun buildAudioStreamInfo(
} }
private fun buildSubtitleStreamInfo( private fun buildSubtitleStreamInfo(
context: Context, resources: Resources,
stream: MediaStream, stream: MediaStream,
showPath: Boolean, showPath: Boolean,
): List<Pair<String, String>> = ): List<Pair<String, String>> =
buildList { buildList {
val titleLabel = context.getString(R.string.title) val titleLabel = resources.getString(R.string.title)
val languageLabel = context.getString(R.string.language) val languageLabel = resources.getString(R.string.language)
val codecLabel = context.getString(R.string.codec) val codecLabel = resources.getString(R.string.codec)
val defaultLabel = context.getString(R.string.default_track) val defaultLabel = resources.getString(R.string.default_track)
val forcedLabel = context.getString(R.string.forced_track) val forcedLabel = resources.getString(R.string.forced_track)
val externalLabel = context.getString(R.string.external_track) val externalLabel = resources.getString(R.string.external_track)
val yesStr = context.getString(R.string.yes) val yesStr = resources.getString(R.string.yes)
val noStr = context.getString(R.string.no) val noStr = resources.getString(R.string.no)
val pathLabel = context.getString(R.string.path) val pathLabel = resources.getString(R.string.path)
stream.title?.let { add(titleLabel to it) } stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) } stream.language?.let { add(languageLabel to languageName(it)) }

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.ui.detail 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
@ -32,7 +32,7 @@ data class MoreDialogActions(
) )
fun buildMoreDialogItemsForHome( fun buildMoreDialogItemsForHome(
context: Context, resources: Resources,
item: BaseItem, item: BaseItem,
seriesId: UUID?, seriesId: UUID?,
playbackPosition: Duration, playbackPosition: Duration,
@ -47,7 +47,7 @@ fun buildMoreDialogItemsForHome(
val itemId = item.id val itemId = item.id
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to), resources.getString(R.string.go_to),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
) { ) {
actions.onClickGoTo(item) actions.onClickGoTo(item)
@ -57,7 +57,7 @@ fun buildMoreDialogItemsForHome(
if (playbackPosition >= 1.seconds) { if (playbackPosition >= 1.seconds) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.resume), resources.getString(R.string.resume),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -71,7 +71,7 @@ fun buildMoreDialogItemsForHome(
) )
add( add(
DialogItem( DialogItem(
context.getString(R.string.restart), resources.getString(R.string.restart),
Icons.Default.Refresh, Icons.Default.Refresh,
// iconColor = Color.Green.copy(alpha = .8f), // iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -86,7 +86,7 @@ fun buildMoreDialogItemsForHome(
} else { } else {
add( add(
DialogItem( DialogItem(
context.getString(R.string.play), resources.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -103,7 +103,7 @@ fun buildMoreDialogItemsForHome(
if (item.type == BaseItemKind.MUSIC_ALBUM) { if (item.type == BaseItemKind.MUSIC_ALBUM) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.add_to_queue), resources.getString(R.string.add_to_queue),
Icons.Default.Add, Icons.Default.Add,
) { ) {
actions.onClickAddToQueue(item) actions.onClickAddToQueue(item)
@ -121,7 +121,7 @@ fun buildMoreDialogItemsForHome(
if (canDelete) { if (canDelete) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.delete), resources.getString(R.string.delete),
Icons.Default.Delete, Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f), iconColor = Color.Red.copy(alpha = .8f),
) { ) {
@ -169,7 +169,7 @@ fun buildMoreDialogItemsForHome(
seriesId?.let { seriesId?.let {
add( add(
DialogItem( DialogItem(
context.getString(R.string.go_to_series), resources.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward, Icons.AutoMirrored.Filled.ArrowForward,
) { ) {
actions.navigateTo( 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.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
@ -93,13 +95,13 @@ class HomeRowGridViewModel
private val musicService: MusicService, private val musicService: MusicService,
val streamChoiceService: StreamChoiceService, val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
@Assisted private val title: String, @Assisted private val title: StringProvider,
@Assisted private val rowConfig: HomeRowConfig, @Assisted private val rowConfig: HomeRowConfig,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
title: String, title: StringProvider,
rowConfig: HomeRowConfig, rowConfig: HomeRowConfig,
): HomeRowGridViewModel ): HomeRowGridViewModel
} }
@ -155,7 +157,16 @@ class HomeRowGridViewModel
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error fetching: %s", rowConfig) 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( data class HomeRowGridState(
val loading: HomeRowLoadingState = HomeRowLoadingState.Pending(""), val loading: HomeRowLoadingState = HomeRowLoadingState.Pending(StringStringProvider("")),
) )
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -268,7 +279,7 @@ fun HomeRowGrid(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
) { ) {
GridTitle(destination.title) GridTitle(destination.title.getString())
when (val st = state.loading) { when (val st = state.loading) {
is HomeRowLoadingState.Error -> { 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.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
@ -385,7 +386,7 @@ fun PersonPageContent(
DiscoverRow( DiscoverRow(
row = row =
DiscoverRowData( DiscoverRowData(
stringResource(R.string.discover), ResStringProvider(R.string.discover),
DataLoadingState.Success(discovered), DataLoadingState.Success(discovered),
DiscoverRequestType.UNKNOWN, 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.nav.Destination
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.util.FilterUtils 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.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -212,7 +213,7 @@ class CollectionViewModel
val jobs = val jobs =
typesInCollection.map { type -> typesInCollection.map { type ->
async(Dispatchers.IO) { async(Dispatchers.IO) {
val title = context.getString(formatTypeName(type)) val title = ResStringProvider(formatTypeName(type))
val result = val result =
try { try {
val pager = fetchItems(sort, filter, listOf(type)) 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.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -79,6 +80,7 @@ fun DiscoverMovieDetails(
creationCallback = { it.create(destination.item) }, creationCallback = { it.create(destination.item) },
), ),
) { ) {
val resources = LocalResources.current
val context = LocalContext.current val context = LocalContext.current
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
viewModel.init() viewModel.init()
@ -160,7 +162,7 @@ fun DiscoverMovieDetails(
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = movie.title ?: context.getString(R.string.unknown), title = movie.title ?: resources.getString(R.string.unknown),
overview = movie.overview, overview = movie.overview,
genres = movie.genres?.mapNotNull { it.name }.orEmpty(), genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(), files = listOf(),
@ -244,7 +246,6 @@ fun DiscoverMovieDetailsContent(
onLongClickSimilar: (Int, DiscoverItem) -> Unit, onLongClickSimilar: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var position by rememberInt(0) var position by rememberInt(0)
val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.discover 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -83,6 +84,7 @@ fun DiscoverSeriesDetails(
), ),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.tvSeries.observeAsState() val item by viewModel.tvSeries.observeAsState()
@ -147,7 +149,7 @@ fun DiscoverSeriesDetails(
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = item.name ?: context.getString(R.string.unknown), title = item.name ?: resources.getString(R.string.unknown),
overview = item.overview, overview = item.overview,
genres = item.genres?.mapNotNull { it.name }.orEmpty(), genres = item.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(), files = listOf(),
@ -494,7 +496,7 @@ fun DiscoverSeriesDetailsHeader(
} }
fun buildDialogForSeason( fun buildDialogForSeason(
context: Context, resources: Resources,
s: BaseItem, s: BaseItem,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit, markPlayed: (Boolean) -> Unit,
@ -503,26 +505,26 @@ fun buildDialogForSeason(
val items = val items =
buildList { buildList {
add( add(
DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) {
onClickItem.invoke(s) onClickItem.invoke(s)
}, },
) )
if (s.data.userData?.played == true) { if (s.data.userData?.played == true) {
add( 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) markPlayed.invoke(false)
}, },
) )
} else { } else {
add( 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) markPlayed.invoke(true)
}, },
) )
} }
add( add(
DialogItem( DialogItem(
context.getString(R.string.play), resources.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -531,7 +533,7 @@ fun buildDialogForSeason(
) )
add( add(
DialogItem( DialogItem(
context.getString(R.string.shuffle), resources.getString(R.string.shuffle),
R.string.fa_shuffle, R.string.fa_shuffle,
) { ) {
onClickPlay.invoke(true) onClickPlay.invoke(true)
@ -539,7 +541,7 @@ fun buildDialogForSeason(
) )
} }
return DialogParams( return DialogParams(
title = s.name ?: context.getString(R.string.tv_season), title = s.name ?: resources.getString(R.string.tv_season),
fromLongClick = true, fromLongClick = true,
items = items, 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.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt 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.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -478,7 +479,7 @@ fun MovieDetailsContent(
DiscoverRow( DiscoverRow(
row = row =
DiscoverRowData( DiscoverRowData(
stringResource(R.string.discover), ResStringProvider(R.string.discover),
DataLoadingState.Success(discovered), DataLoadingState.Success(discovered),
type = DiscoverRequestType.UNKNOWN, type = DiscoverRequestType.UNKNOWN,
), ),

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.series 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.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt 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.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -644,7 +645,7 @@ fun SeriesDetailsContent(
DiscoverRow( DiscoverRow(
row = row =
DiscoverRowData( DiscoverRowData(
stringResource(R.string.discover), ResStringProvider(R.string.discover),
DataLoadingState.Success(discovered), DataLoadingState.Success(discovered),
type = DiscoverRequestType.UNKNOWN, type = DiscoverRequestType.UNKNOWN,
), ),
@ -726,7 +727,7 @@ fun SeriesDetailsHeader(
} }
fun buildDialogForSeason( fun buildDialogForSeason(
context: Context, resources: Resources,
s: BaseItem, s: BaseItem,
canDelete: Boolean, canDelete: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
@ -737,26 +738,26 @@ fun buildDialogForSeason(
val items = val items =
buildList { buildList {
add( add(
DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) {
onClickItem.invoke(s) onClickItem.invoke(s)
}, },
) )
if (s.data.userData?.played == true) { if (s.data.userData?.played == true) {
add( 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) markPlayed.invoke(false)
}, },
) )
} else { } else {
add( 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) markPlayed.invoke(true)
}, },
) )
} }
add( add(
DialogItem( DialogItem(
context.getString(R.string.play), resources.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -765,7 +766,7 @@ fun buildDialogForSeason(
) )
add( add(
DialogItem( DialogItem(
context.getString(R.string.shuffle), resources.getString(R.string.shuffle),
R.string.fa_shuffle, R.string.fa_shuffle,
) { ) {
onClickPlay.invoke(true) onClickPlay.invoke(true)
@ -774,7 +775,7 @@ fun buildDialogForSeason(
if (canDelete) { if (canDelete) {
add( add(
DialogItem( DialogItem(
context.getString(R.string.delete), resources.getString(R.string.delete),
Icons.Default.Delete, Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f), iconColor = Color.Red.copy(alpha = .8f),
) { ) {
@ -784,7 +785,7 @@ fun buildDialogForSeason(
} }
} }
return DialogParams( return DialogParams(
title = s.name ?: context.getString(R.string.tv_season), title = s.name ?: resources.getString(R.string.tv_season),
fromLongClick = true, fromLongClick = true,
items = items, items = items,
) )

View file

@ -59,7 +59,7 @@ fun DiscoverRow(
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = row.title, text = row.title.getString(),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
@ -73,7 +73,7 @@ fun DiscoverRow(
is DataLoadingState.Success<List<DiscoverItem>> -> { is DataLoadingState.Success<List<DiscoverItem>> -> {
DiscoverItemRow( DiscoverItemRow(
title = row.title, title = row.title.getString(),
items = state.data, items = state.data,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
@ -77,7 +80,7 @@ class SeerrDiscoverViewModel
this.copy( this.copy(
movies = movies =
DiscoverRowData( DiscoverRowData(
context.getString(R.string.movies), ResStringProvider(R.string.movies),
it, it,
DiscoverRequestType.DISCOVER_MOVIES, DiscoverRequestType.DISCOVER_MOVIES,
), ),
@ -87,7 +90,7 @@ class SeerrDiscoverViewModel
this.copy( this.copy(
tv = tv =
DiscoverRowData( DiscoverRowData(
context.getString(R.string.tv_shows), ResStringProvider(R.string.tv_shows),
it, it,
DiscoverRequestType.DISCOVER_TV, DiscoverRequestType.DISCOVER_TV,
), ),
@ -97,7 +100,7 @@ class SeerrDiscoverViewModel
this.copy( this.copy(
trending = trending =
DiscoverRowData( DiscoverRowData(
context.getString(R.string.trending), ResStringProvider(R.string.trending),
it, it,
DiscoverRequestType.TRENDING, DiscoverRequestType.TRENDING,
), ),
@ -107,7 +110,7 @@ class SeerrDiscoverViewModel
this.copy( this.copy(
upcomingMovies = upcomingMovies =
DiscoverRowData( DiscoverRowData(
context.getString(R.string.upcoming_movies), ResStringProvider(R.string.upcoming_movies),
it, it,
DiscoverRequestType.UPCOMING_MOVIES, DiscoverRequestType.UPCOMING_MOVIES,
), ),
@ -117,7 +120,7 @@ class SeerrDiscoverViewModel
this.copy( this.copy(
upcomingTv = upcomingTv =
DiscoverRowData( DiscoverRowData(
context.getString(R.string.upcoming_tv), ResStringProvider(R.string.upcoming_tv),
it, it,
DiscoverRequestType.UPCOMING_TV, DiscoverRequestType.UPCOMING_TV,
), ),
@ -214,12 +217,17 @@ class SeerrDiscoverViewModel
} }
data class DiscoverRowData( data class DiscoverRowData(
val title: String, val title: StringProvider,
val items: DataLoadingState<List<DiscoverItem>>, val items: DataLoadingState<List<DiscoverItem>>,
val type: DiscoverRequestType, val type: DiscoverRequestType,
) { ) {
companion object { 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, is HomeRowLoadingState.Pending,
-> { -> {
FocusableItemRow( FocusableItemRow(
title = r.title, title = r.title.getString(),
subtitle = stringResource(R.string.loading), subtitle = stringResource(R.string.loading),
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
) )
@ -389,7 +389,7 @@ fun HomePageContent(
is HomeRowLoadingState.Error -> { is HomeRowLoadingState.Error -> {
FocusableItemRow( FocusableItemRow(
title = r.title, title = r.title.getString(),
subtitle = r.localizedMessage, subtitle = r.localizedMessage,
isError = true, isError = true,
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
@ -400,7 +400,7 @@ fun HomePageContent(
if (row.items.isNotEmpty()) { if (row.items.isNotEmpty()) {
val viewOptions = row.viewOptions val viewOptions = row.viewOptions
ItemRow( ItemRow(
title = row.title, title = row.title.getString(),
items = row.items, items = row.items,
onClickItem = onClickItem =
remember(rowIndex, onClickItem) { remember(rowIndex, onClickItem) {
@ -502,7 +502,7 @@ fun HomePageContent(
) )
} else if (showEmptyRows) { } else if (showEmptyRows) {
FocusableItemRow( FocusableItemRow(
title = r.title, title = r.title.getString(),
subtitle = stringResource(R.string.no_results), subtitle = stringResource(R.string.no_results),
modifier = Modifier.animateItem(), 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.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast 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.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -103,7 +104,9 @@ class HomeViewModel
if (refresh) { if (refresh) {
it.homeRows it.homeRows
} else { } else {
List(settings.rows.size) { HomeRowLoadingState.Pending("") } List(settings.rows.size) {
HomeRowLoadingState.Pending(EmptyStringProvider)
}
}, },
) )
} }

View file

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

View file

@ -209,7 +209,7 @@ fun HomeRowConfigContent(
) { ) {
HomeSettingsListItem( HomeSettingsListItem(
selected = false, selected = false,
headlineText = config.title, headlineText = config.title.getString(),
onClick = onClick, onClick = onClick,
modifier = Modifier.weight(1f), 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.AspectRatio
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast 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.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -222,7 +226,7 @@ class HomeSettingsViewModel
MetaRowType.CONTINUE_WATCHING -> { MetaRowType.CONTINUE_WATCHING -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.continue_watching), title = ResStringProvider(R.string.continue_watching),
config = ContinueWatching(), config = ContinueWatching(),
) )
} }
@ -230,7 +234,7 @@ class HomeSettingsViewModel
MetaRowType.NEXT_UP -> { MetaRowType.NEXT_UP -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.next_up), title = ResStringProvider(R.string.next_up),
config = NextUp(), config = NextUp(),
) )
} }
@ -238,7 +242,7 @@ class HomeSettingsViewModel
MetaRowType.COMBINED_CONTINUE_WATCHING -> { MetaRowType.COMBINED_CONTINUE_WATCHING -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.combine_continue_next), title = ResStringProvider(R.string.combine_continue_next),
config = ContinueWatchingCombined(), config = ContinueWatchingCombined(),
) )
} }
@ -291,7 +295,12 @@ class HomeSettingsViewModel
when (rowType) { when (rowType) {
LibraryRowType.RECENTLY_ADDED -> { LibraryRowType.RECENTLY_ADDED -> {
val title = val title =
library.name.let { context.getString(R.string.recently_added_in, it) } library.name.let {
ResArgStringProvider(
R.string.recently_added_in,
it,
)
}
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
@ -302,7 +311,7 @@ class HomeSettingsViewModel
LibraryRowType.RECENTLY_RELEASED -> { LibraryRowType.RECENTLY_RELEASED -> {
val title = val title =
library.name.let { library.name.let {
context.getString( ResArgStringProvider(
R.string.recently_released_in, R.string.recently_released_in,
it, it,
) )
@ -315,7 +324,8 @@ class HomeSettingsViewModel
} }
LibraryRowType.GENRES -> { 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( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
@ -325,7 +335,7 @@ class HomeSettingsViewModel
LibraryRowType.STUDIOS -> { LibraryRowType.STUDIOS -> {
val title = val title =
library.name.let { context.getString(R.string.studios_in, it) } library.name.let { ResArgStringProvider(R.string.studios_in, it) }
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
@ -335,7 +345,12 @@ class HomeSettingsViewModel
LibraryRowType.SUGGESTIONS -> { LibraryRowType.SUGGESTIONS -> {
val title = val title =
library.name.let { context.getString(R.string.suggestions_for, it) } library.name.let {
ResArgStringProvider(
R.string.suggestions_for,
it,
)
}
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
@ -344,7 +359,7 @@ class HomeSettingsViewModel
} }
LibraryRowType.TV_CHANNELS -> { LibraryRowType.TV_CHANNELS -> {
val title = context.getString(R.string.channels) val title = ResStringProvider(R.string.channels)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
@ -356,7 +371,7 @@ class HomeSettingsViewModel
} }
LibraryRowType.TV_PROGRAMS -> { LibraryRowType.TV_PROGRAMS -> {
val title = context.getString(R.string.watch_live) val title = ResStringProvider(R.string.watch_live)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
@ -365,7 +380,7 @@ class HomeSettingsViewModel
} }
LibraryRowType.RECENTLY_RECORDED -> { LibraryRowType.RECENTLY_RECORDED -> {
val title = context.getString(R.string.recently_recorded) val title = ResStringProvider(R.string.recently_recorded)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
@ -396,9 +411,9 @@ class HomeSettingsViewModel
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title =
context.getString( ResProviderStringProvider(
R.string.favorite_items, R.string.favorite_items,
context.getString(favoriteOptions[type]!!), ResStringProvider(favoriteOptions[type]!!),
), ),
config = HomeRowConfig.Favorite(type), config = HomeRowConfig.Favorite(type),
) )
@ -420,7 +435,7 @@ class HomeSettingsViewModel
val newRow = val newRow =
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = parent.name ?: "", title = StringStringProvider(parent.name ?: ""),
config = config =
HomeRowConfig.ByParent( HomeRowConfig.ByParent(
parentId = parent.id, parentId = parent.id,

View file

@ -2,7 +2,6 @@
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.annotation.StringRes
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter 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.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption 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.DiscoverRequestType
import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.RequestHandler
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
@ -110,8 +110,7 @@ sealed class Destination(
@Serializable @Serializable
data class ItemGrid<T>( data class ItemGrid<T>(
val title: String?, val title: StringProvider,
@param:StringRes val titleRes: Int?,
@Contextual val request: T, @Contextual val request: T,
val requestHandler: RequestHandler<T>, val requestHandler: RequestHandler<T>,
val initialPosition: Int = 0, val initialPosition: Int = 0,
@ -120,7 +119,7 @@ sealed class Destination(
@Serializable @Serializable
data class MoreHomeRow( data class MoreHomeRow(
val title: String, val title: StringProvider,
val config: HomeRowConfig, val config: HomeRowConfig,
val initialPosition: Int, val initialPosition: Int,
) : Destination(false) ) : Destination(false)

View file

@ -509,14 +509,15 @@ class PlaybackViewModel
it it
} }
}?.map { }?.map {
SimpleMediaStream.from(context, it, true) // TODO should use a string provider instead
SimpleMediaStream.from(context.resources, it, true)
}.orEmpty() }.orEmpty()
val audioStreams = val audioStreams =
mediaSource.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO } ?.filter { it.type == MediaStreamType.AUDIO }
?.map { ?.map {
SimpleMediaStream.from(context, it, true) SimpleMediaStream.from(context.resources, it, true)
} }
// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) // ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty() .orEmpty()

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.ui.playback 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
@ -17,14 +17,14 @@ data class SimpleMediaStream(
) { ) {
companion object { companion object {
fun from( fun from(
context: Context, resources: Resources,
mediaStream: MediaStream, mediaStream: MediaStream,
includeFlags: Boolean = true, includeFlags: Boolean = true,
): SimpleMediaStream = ): SimpleMediaStream =
SimpleMediaStream( SimpleMediaStream(
index = mediaStream.index, index = mediaStream.index,
streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, 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( it.copy(
subtitleStreams = subtitleStreams =
subtitlesStreams.map { 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 com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.util.Locale
@Composable @Composable
fun PreferencesContent( fun PreferencesContent(
@ -109,6 +110,7 @@ fun PreferencesContent(
val seerrConnection by viewModel.seerrConnection.collectAsState() val seerrConnection by viewModel.seerrConnection.collectAsState()
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) } var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
var showQuickConnectDialog by remember { mutableStateOf(false) } var showQuickConnectDialog by remember { mutableStateOf(false) }
var showLocaleChoiceDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect { viewModel.preferenceDataStore.data.collect {
@ -255,6 +257,12 @@ fun PreferencesContent(
pref as AppPreference<AppPreferences, Any> pref as AppPreference<AppPreferences, Any>
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focusModifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
)
val focused = interactionSource.collectIsFocusedAsState().value val focused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) { LaunchedEffect(focused) {
if (focused) { if (focused) {
@ -275,12 +283,7 @@ fun PreferencesContent(
}, },
summary = installedVersion.toString(), summary = installedVersion.toString(),
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = modifier = focusModifier,
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
) )
} }
@ -317,12 +320,7 @@ fun PreferencesContent(
null null
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = modifier = focusModifier,
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
) )
} }
@ -348,7 +346,7 @@ fun PreferencesContent(
updateCache = true updateCache = true
} }
}, },
modifier = Modifier, modifier = focusModifier,
summary = summary, summary = summary,
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, interactionSource = interactionSource,
@ -359,7 +357,7 @@ fun PreferencesContent(
NavDrawerPreference( NavDrawerPreference(
title = stringResource(pref.title), title = stringResource(pref.title),
summary = pref.summary(context, null), summary = pref.summary(context, null),
modifier = Modifier, modifier = focusModifier,
interactionSource = interactionSource, interactionSource = interactionSource,
) )
} }
@ -370,7 +368,7 @@ fun PreferencesContent(
onClick = { onClick = {
viewModel.sendAppLogs() viewModel.sendAppLogs()
}, },
modifier = Modifier, modifier = focusModifier,
summary = pref.summary(context, null), summary = pref.summary(context, null),
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, interactionSource = interactionSource,
@ -383,7 +381,7 @@ fun PreferencesContent(
onClick = { onClick = {
viewModel.resetSubtitleSettings() viewModel.resetSubtitleSettings()
}, },
modifier = Modifier, modifier = focusModifier,
summary = pref.summary(context, null), summary = pref.summary(context, null),
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, interactionSource = interactionSource,
@ -400,6 +398,7 @@ fun PreferencesContent(
ChoicePreference( ChoicePreference(
title = stringResource(pref.title), title = stringResource(pref.title),
summary = stringResource(summary), summary = stringResource(summary),
interactionSource = interactionSource,
possibleValues = possibleValues =
listOf( listOf(
stringResource(R.string.none), stringResource(R.string.none),
@ -429,6 +428,7 @@ fun PreferencesContent(
} }
} }
}, },
modifier = focusModifier,
) )
} }
@ -456,7 +456,7 @@ fun PreferencesContent(
} }
} }
}, },
modifier = Modifier, modifier = focusModifier,
summary = summary =
when (seerrConnection) { when (seerrConnection) {
is SeerrConnectionStatus.Error -> { is SeerrConnectionStatus.Error -> {
@ -487,7 +487,7 @@ fun PreferencesContent(
showQuickConnectDialog = true showQuickConnectDialog = true
} }
}, },
modifier = Modifier, modifier = focusModifier,
summary = pref.summary(context, null), summary = pref.summary(context, null),
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, interactionSource = interactionSource,
@ -500,7 +500,7 @@ fun PreferencesContent(
onClick = { onClick = {
viewModel.screensaverService.start() viewModel.screensaverService.start()
}, },
modifier = Modifier, modifier = focusModifier,
summary = pref.summary(context, null), summary = pref.summary(context, null),
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, interactionSource = interactionSource,
@ -516,6 +516,7 @@ fun PreferencesContent(
ChoicePreference( ChoicePreference(
title = stringResource(pref.title), title = stringResource(pref.title),
summary = players[selectedIndex].name, summary = players[selectedIndex].name,
interactionSource = interactionSource,
possibleValues = players, possibleValues = players,
selectedIndex = selectedIndex, selectedIndex = selectedIndex,
onValueChange = { index -> onValueChange = { index ->
@ -543,6 +544,25 @@ fun PreferencesContent(
Text(item.name) 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, interactionSource = interactionSource,
modifier = modifier = focusModifier,
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
) )
} }
} }
@ -721,6 +736,11 @@ fun PreferencesContent(
} }
} }
} }
if (showLocaleChoiceDialog) {
LocaleChoiceDialog(
onDismissRequest = { showLocaleChoiceDialog = false },
)
}
} }
@Composable @Composable

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.ui.util 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.R
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.languageName import com.github.damontecres.wholphin.util.languageName
@ -44,7 +44,7 @@ object StreamFormatting {
} }
fun formatVideoRange( fun formatVideoRange(
context: Context, resources: Resources,
videoRange: VideoRange?, videoRange: VideoRange?,
type: VideoRangeType?, type: VideoRangeType?,
doviTitle: String?, doviTitle: String?,
@ -58,7 +58,7 @@ object StreamFormatting {
VideoRange.HDR -> { VideoRange.HDR -> {
if (doviTitle.isNotNullOrBlank()) { if (doviTitle.isNotNullOrBlank()) {
context.getString(R.string.dolby_vision) resources.getString(R.string.dolby_vision)
} else { } else {
when (type) { when (type) {
VideoRangeType.UNKNOWN, VideoRangeType.UNKNOWN,
@ -76,40 +76,40 @@ object StreamFormatting {
VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HDR10,
VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_HLG,
VideoRangeType.DOVI_WITH_SDR, VideoRangeType.DOVI_WITH_SDR,
-> context.getString(R.string.dolby_vision) -> resources.getString(R.string.dolby_vision)
} }
} }
} }
} }
fun formatAudioCodec( fun formatAudioCodec(
context: Context, resources: Resources,
codec: String?, codec: String?,
profile: String?, profile: String?,
): String? = ): String? =
when { when {
profile?.contains("Dolby Atmos", true) == true -> { profile?.contains("Dolby Atmos", true) == true -> {
context.getString(R.string.dolby_atmos) resources.getString(R.string.dolby_atmos)
} }
profile?.contains("DTS", true) == true -> { profile?.contains("DTS", true) == true -> {
when { when {
profile.contains("X", true) -> context.getString(R.string.dts_x) profile.contains("X", true) -> resources.getString(R.string.dts_x)
profile.contains("MA", true) -> context.getString(R.string.dts_hd_ma) profile.contains("MA", true) -> resources.getString(R.string.dts_hd_ma)
profile.contains("HD", true) -> context.getString(R.string.dts_hd) profile.contains("HD", true) -> resources.getString(R.string.dts_hd)
else -> context.getString(R.string.dts) else -> resources.getString(R.string.dts)
} }
} }
else -> { else -> {
when (codec?.lowercase()) { 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.OGG,
Codec.Audio.OPUS, Codec.Audio.OPUS,
@ -141,7 +141,7 @@ object StreamFormatting {
} }
fun mediaStreamDisplayTitle( fun mediaStreamDisplayTitle(
context: Context, resources: Resources,
stream: MediaStream, stream: MediaStream,
includeFlags: Boolean, includeFlags: Boolean,
): String { ): String {
@ -149,7 +149,7 @@ object StreamFormatting {
buildList { buildList {
add(languageName(stream.language)) add(languageName(stream.language))
if (stream.type == MediaStreamType.AUDIO) { if (stream.type == MediaStreamType.AUDIO) {
add(formatAudioCodec(context, stream.codec, stream.profile)) add(formatAudioCodec(resources, stream.codec, stream.profile))
add(stream.channelLayout) add(stream.channelLayout)
} else if (stream.type == MediaStreamType.SUBTITLE) { } else if (stream.type == MediaStreamType.SUBTITLE) {
"SDH".takeIf { stream.isHearingImpaired }?.let(::add) "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.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.ui.util.StringProvider
/** /**
* Generic state for loading something from the API * Generic state for loading something from the API
@ -48,28 +49,28 @@ sealed interface RowLoadingState {
} }
sealed interface HomeRowLoadingState { sealed interface HomeRowLoadingState {
val title: String val title: StringProvider
val completed: Boolean val completed: Boolean
get() = this is Success || this is Error get() = this is Success || this is Error
data class Pending( data class Pending(
override val title: String, override val title: StringProvider,
) : HomeRowLoadingState ) : HomeRowLoadingState
data class Loading( data class Loading(
override val title: String, override val title: StringProvider,
) : HomeRowLoadingState ) : HomeRowLoadingState
data class Success( data class Success(
override val title: String, override val title: StringProvider,
val items: List<BaseItem?>, val items: List<BaseItem?>,
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
val rowType: HomeRowConfig? = null, val rowType: HomeRowConfig? = null,
) : HomeRowLoadingState ) : HomeRowLoadingState
data class Error( data class Error(
override val title: String, override val title: StringProvider,
val message: String? = null, val message: String? = null,
val exception: Throwable? = null, val exception: Throwable? = null,
) : HomeRowLoadingState { ) : 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="skip_behavior_summary">For intros, credits, etc</string>
<string name="play_with">Play with</string> <string name="play_with">Play with</string>
<string name="transcoding">Transcoding</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_title">Ratings display</string>
<string name="display_toggles_summary">Customize which to display</string> <string name="display_toggles_summary">Customize which to display</string>
<string name="profile_protection">Profile protection</string> <string name="profile_protection">Profile protection</string>