diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/6.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/6.json new file mode 100644 index 00000000..40c938fb --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/6.json @@ -0,0 +1,303 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "24390d64259f19e0e67ecfb827b974fb", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, 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 + } + ], + "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, 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" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, 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 + } + ], + "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" + ] + } + ] + } + ], + "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, '24390d64259f19e0e67ecfb827b974fb')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index ad9fb3a7..305a3146 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -10,17 +10,21 @@ import androidx.sqlite.db.SupportSQLiteDatabase import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @Database( - entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class], - version = 5, + entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], + version = 6, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), AutoMigration(4, 5), + AutoMigration(5, 6), ], ) @TypeConverters(Converters::class) @@ -30,6 +34,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun itemPlaybackDao(): ItemPlaybackDao abstract fun serverPreferencesDao(): ServerPreferencesDao + + abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao } class Converters { @@ -38,6 +44,18 @@ class Converters { @TypeConverter fun convertToUUID(str: String): UUID = str.toUUID() + + @TypeConverter + fun convertItemSortBy(sort: ItemSortBy): String = sort.serialName + + @TypeConverter + fun convertItemSortBy(sort: String): ItemSortBy? = ItemSortBy.fromNameOrNull(sort) + + @TypeConverter + fun convertSortOrder(sort: SortOrder): String = sort.serialName + + @TypeConverter + fun convertSortOrder(sort: String): SortOrder? = SortOrder.fromNameOrNull(sort) } object Migrations { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt new file mode 100644 index 00000000..8e1b1ef2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt @@ -0,0 +1,29 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import java.util.UUID + +@Dao +interface LibraryDisplayInfoDao { + fun getItem( + user: JellyfinUser, + itemId: UUID, + ): LibraryDisplayInfo? = getItem(user.rowId, itemId) + + @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId") + fun getItem( + userId: Int, + itemId: UUID, + ): LibraryDisplayInfo? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun saveItem(item: LibraryDisplayInfo): Long + + @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId") + fun getItems(userId: Int): List +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 8975579f..9b910c78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -84,13 +84,15 @@ class ServerRepository } Timber.v("Changing user to ${user.name} on ${server.url}") apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val userDto = - apiClient.userApi - .getCurrentUser() - .content - val sysInfo by apiClient.systemApi.getSystemInfo() - - val updatedServer = server.copy(name = sysInfo.serverName) + val userDto by apiClient.userApi.getCurrentUser() + val updatedServer = + try { + val sysInfo by apiClient.systemApi.getPublicSystemInfo() + server.copy(name = sysInfo.serverName) + } catch (ex: Exception) { + Timber.w(ex, "Exception fetching public system info") + server + } var updatedUser = user.copy( id = userDto.id, @@ -171,8 +173,14 @@ class ServerRepository } if (user != null) { changeUser(server, user) + } else { + throw IllegalArgumentException("Authentication result's user was null") } + } else { + throw IllegalArgumentException("Authentication result's serverId not valid: ${authenticationResult.serverId}") } + } else { + throw IllegalArgumentException("Authentication result's access token was null") } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt new file mode 100644 index 00000000..d428f521 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -0,0 +1,40 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["userId", "itemId"], + indices = [Index("userId", "itemId", unique = true)], +) +@Serializable +data class LibraryDisplayInfo( + val userId: Int, + val itemId: UUID, + val sort: ItemSortBy, + val direction: SortOrder, +) { + @Ignore @Transient + val sortAndDirection = SortAndDirection(sort, direction) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt index c1f9da80..34219c58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt @@ -5,6 +5,7 @@ import android.content.Context import android.provider.Settings import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences @@ -47,9 +48,11 @@ annotation class IoCoroutineScope object AppModule { @Provides @Singleton - fun clientInfo(): ClientInfo = + fun clientInfo( + @ApplicationContext context: Context, + ): ClientInfo = ClientInfo( - name = "Wholphin", + name = context.getString(R.string.app_name), version = BuildConfig.VERSION_NAME, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt index ab1fb0e5..441a9033 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt @@ -9,6 +9,7 @@ import androidx.room.Room import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences @@ -51,6 +52,10 @@ object DatabaseModule { @Singleton fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + @Provides + @Singleton + fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 2805cbbd..11554506 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -70,7 +70,11 @@ sealed interface AppPreference { }, summarizer = { value -> if (value != null) { - "$value seconds" + WholphinApplication.instance.resources.getQuantityString( + R.plurals.seconds, + value.toInt(), + value.toInt(), + ) } else { null } @@ -95,7 +99,11 @@ sealed interface AppPreference { }, summarizer = { value -> if (value != null) { - "$value seconds" + WholphinApplication.instance.resources.getQuantityString( + R.plurals.seconds, + value.toInt(), + value.toInt(), + ) } else { null } @@ -137,7 +145,14 @@ sealed interface AppPreference { setter = { prefs, value -> prefs.updatePlaybackPreferences { controllerTimeoutMs = value } }, - summarizer = { value -> value?.let { "${value / 1000.0} seconds" } }, + summarizer = { value -> + value?.let { + WholphinApplication.instance.getString( + R.string.decimal_seconds, + value / 1000.0, + ) + } + }, ) val SeekBarSteps = @@ -243,7 +258,7 @@ sealed interface AppPreference { }, summarizer = { value -> if (value == 0L) { - "Disabled" + WholphinApplication.instance.getString(R.string.disabled) } else { "${value}s" } @@ -262,10 +277,16 @@ sealed interface AppPreference { prefs.updatePlaybackPreferences { autoPlayNextDelaySeconds = value } }, summarizer = { value -> - if (value == 0L) { - "Immediate" + if (value == null) { + "" + } else if (value == 0L) { + WholphinApplication.instance.getString(R.string.immediate) } else { - "$value seconds" + WholphinApplication.instance.resources.getQuantityString( + R.plurals.seconds, + value.toInt(), + value.toInt(), + ) } }, ) @@ -284,10 +305,16 @@ sealed interface AppPreference { } }, summarizer = { value -> - if (value == 0L) { - "Disabled" + if (value == null) { + "" + } else if (value == 0L) { + WholphinApplication.instance.getString(R.string.disabled) } else { - "$value hours" + WholphinApplication.instance.resources.getQuantityString( + R.plurals.hours, + value.toInt(), + value.toInt(), + ) } }, ) @@ -485,7 +512,7 @@ sealed interface AppPreference { val SkipCommercials = AppChoicePreference( - title = R.string.skip_comercials_behavior, + title = R.string.skip_commercials_behavior, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, getter = { it.playbackPreferences.skipCommercials }, setter = { prefs, value -> @@ -602,6 +629,19 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.nav_drawer_switch_on_focus_summary_off, ) + + val ShowNextUpTiming = + AppChoicePreference( + title = R.string.show_next_up_when, + defaultValue = ShowNextUpWhen.END_OF_PLAYBACK, + getter = { it.playbackPreferences.showNextUpWhen }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { showNextUpWhen = value } + }, + displayValues = R.array.show_next_up_when_options, + indexToValue = { ShowNextUpWhen.forNumber(it) }, + valueToIndex = { it.number }, + ) } } @@ -613,7 +653,6 @@ val basicPreferences = listOf( AppPreference.HomePageItems, AppPreference.RewatchNextUp, - AppPreference.CombineContinueNext, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.ThemeColors, @@ -625,11 +664,17 @@ val basicPreferences = listOf( AppPreference.SkipForward, AppPreference.SkipBack, - AppPreference.ControllerTimeout, + AppPreference.SkipBackOnResume, + ), + ), + PreferenceGroup( + title = R.string.next_up, + preferences = + listOf( + AppPreference.ShowNextUpTiming, AppPreference.AutoPlayNextUp, AppPreference.AutoPlayNextDelay, AppPreference.PassOutProtection, - AppPreference.SkipBackOnResume, ), ), PreferenceGroup( @@ -664,7 +709,10 @@ val advancedPreferences = title = R.string.ui_interface, preferences = listOf( - AppPreference.NavDrawerSwitchOnFocus, + AppPreference.CombineContinueNext, + // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 +// AppPreference.NavDrawerSwitchOnFocus, + AppPreference.ControllerTimeout, ), ), PreferenceGroup( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 4fce1470..bb90e4a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -43,6 +43,7 @@ class AppPreferencesSerializer skipRecaps = AppPreference.SkipRecaps.defaultValue passOutProtectionMs = AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds + showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt index 2468f7b1..1c206990 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt @@ -11,9 +11,11 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Chapter @Composable @@ -28,7 +30,7 @@ fun ChapterRow( modifier = modifier, ) { Text( - text = "Chapters", + text = stringResource(R.string.chapters), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 90f0f2ef..63730ca7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -15,9 +15,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.ifElse @@ -34,7 +36,7 @@ fun PersonRow( modifier = modifier, ) { Text( - text = "People", + text = stringResource(R.string.people), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 0a7e1ff6..31bd0245 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -19,6 +20,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -26,8 +28,12 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields @@ -43,6 +49,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -62,6 +69,9 @@ class CollectionFolderViewModel @Inject constructor( api: ApiClient, + @param:ApplicationContext private val context: Context, + private val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Loading) val pager = MutableLiveData>(listOf()) @@ -71,23 +81,52 @@ class CollectionFolderViewModel fun init( itemId: UUID?, potential: BaseItem?, - sortAndDirection: SortAndDirection, + initialSortAndDirection: SortAndDirection?, recursive: Boolean, filter: GetItemsFilter, ): Job = viewModelScope.launch( LoadingExceptionHandler( loading, - "Error loading collection $itemId", + context.getString(R.string.error_loading_collection, itemId), ) + Dispatchers.IO, ) { if (itemId != null) { - fetchItem(itemId, potential) + fetchItem(itemId) + + val sortAndDirection = + if (initialSortAndDirection == null) { + serverRepository.currentUser?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection + } ?: SortAndDirection.DEFAULT + } else { + SortAndDirection.DEFAULT + } + loadResults(sortAndDirection, recursive, filter) } - loadResults(sortAndDirection, recursive, filter) } - fun loadResults( + fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) { + serverRepository.currentUser?.let { user -> + viewModelScope.launch(Dispatchers.IO) { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + loadResults(sortAndDirection, recursive, filter) + } + + private fun loadResults( sortAndDirection: SortAndDirection, recursive: Boolean, filter: GetItemsFilter, @@ -126,17 +165,25 @@ class CollectionFolderViewModel recursive = recursive, excludeItemIds = item?.let { listOf(item.id) }, sortBy = - listOf( - sortAndDirection.sort, - ItemSortBy.SORT_NAME, - ItemSortBy.PRODUCTION_YEAR, - ), + buildList { + add(sortAndDirection.sort) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(ItemSortBy.PRODUCTION_YEAR) + } + }, sortOrder = - listOf( - sortAndDirection.direction, - SortOrder.ASCENDING, - SortOrder.ASCENDING, - ), + buildList { + add(sortAndDirection.direction) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(SortOrder.ASCENDING) + } + }, fields = SlimItemFields, ), ) @@ -158,26 +205,28 @@ class CollectionFolderViewModel } suspend fun positionOfLetter(letter: Char): Int? = - item.value?.let { item -> - val includeItemTypes = - when (item.data.collectionType) { - CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) - CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) - CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) + withContext(Dispatchers.IO) { + item.value?.let { item -> + val includeItemTypes = + when (item.data.collectionType) { + CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) + CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) + CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) - else -> listOf() - } - val request = - GetItemsRequest( - parentId = item.id, - includeItemTypes = includeItemTypes, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - recursive = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount + else -> listOf() + } + val request = + GetItemsRequest( + parentId = item.id, + includeItemTypes = includeItemTypes, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + recursive = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount + } } } @@ -196,18 +245,14 @@ fun CollectionFolderGrid( onClickItem: (BaseItem) -> Unit, modifier: Modifier = Modifier, viewModel: CollectionFolderViewModel = hiltViewModel(), - initialSortAndDirection: SortAndDirection = - SortAndDirection( - ItemSortBy.SORT_NAME, - SortOrder.ASCENDING, - ), + initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { OneTimeLaunchedEffect { viewModel.init(itemId, item, initialSortAndDirection, recursive, initialFilter) } - val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection) + val sortAndDirection by viewModel.sortAndDirection.observeAsState() val filter by viewModel.filter.observeAsState(initialFilter) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() @@ -224,11 +269,11 @@ fun CollectionFolderGrid( preferences, item, pager, - sortAndDirection = sortAndDirection, + sortAndDirection = sortAndDirection!!, modifier = modifier, onClickItem = onClickItem, onSortChange = { - viewModel.loadResults(it, recursive, filter) + viewModel.onSortChange(it, recursive, filter) }, showTitle = showTitle, positionCallback = positionCallback, @@ -252,7 +297,7 @@ fun CollectionFolderGridContent( showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { - val title = item?.name ?: item?.data?.collectionType?.name ?: "Collection" + val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) val sortOptions = when (item?.data?.collectionType) { CollectionType.MOVIES -> MovieSortOptions diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 2c639bff..b2363059 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.background @@ -462,12 +463,13 @@ fun ConfirmDialogContent( } fun chooseVersionParams( + context: Context, sources: List, onClick: (Int) -> Unit, ): DialogParams = DialogParams( fromLongClick = false, - title = "Play Version", + title = context.getString(R.string.choose_stream, context.getString(R.string.version)), items = sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> val videoStream = @@ -485,21 +487,33 @@ fun chooseVersionParams( }, ) +@StringRes +fun resourceFor(type: MediaStreamType): Int = + when (type) { + MediaStreamType.AUDIO -> R.string.audio + MediaStreamType.VIDEO -> R.string.video + MediaStreamType.SUBTITLE -> R.string.subtitles + MediaStreamType.EMBEDDED_IMAGE -> 0 + MediaStreamType.DATA -> 0 + MediaStreamType.LYRIC -> 0 + } + fun chooseStream( + context: Context, streams: List, type: MediaStreamType, onClick: (Int) -> Unit, ): DialogParams = DialogParams( fromLongClick = false, - title = "Choose ${type.serialName}", // TODO + title = context.getString(R.string.choose_stream, context.getString(resourceFor(type))), items = buildList { if (type == MediaStreamType.SUBTITLE) { add( DialogItem( headlineContent = { - Text(text = "None") + Text(text = stringResource(R.string.none)) }, supportingContent = { }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt index 14e0fe31..f833e228 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt @@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel @@ -50,7 +52,7 @@ fun ErrorMessage( modifier = modifier.padding(16.dp), ) { Text( - text = "An error occurred! Press the button to send logs to your server.", + text = stringResource(R.string.compose_error_message_title), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleMedium, ) @@ -60,7 +62,7 @@ fun ErrorMessage( }, ) { Text( - text = "Send Logs", + text = stringResource(R.string.send_app_logs), ) } message?.let { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 94710301..e2ce1b79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -64,17 +64,18 @@ class GenreViewModel } } - suspend fun positionOfLetter(letter: Char): Int { - val request = - GetGenresRequest( - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ) - val result by GetGenresRequestHandler.execute(api, request) - return result.totalRecordCount - } + suspend fun positionOfLetter(letter: Char): Int = + withContext(Dispatchers.IO) { + val request = + GetGenresRequest( + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetGenresRequestHandler.execute(api, request) + return@withContext result.totalRecordCount + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt index da4fa30d..593f2db3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt @@ -8,10 +8,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +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.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.ItemRow @@ -70,7 +72,7 @@ fun LoadingRow( -> LoadingRowPlaceholder( title = title, - message = "Loading...", + message = stringResource(R.string.loading), modifier = modifier, ) @@ -88,7 +90,7 @@ fun LoadingRow( } else if (showIfEmpty) { LoadingRowPlaceholder( title = title, - message = "No results", + message = stringResource(R.string.no_results), modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index ec193b2f..3c7e438a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -8,6 +8,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences @@ -16,7 +17,6 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomeRow -import com.github.damontecres.wholphin.ui.main.HomeSection import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -113,11 +113,11 @@ class RecommendedMovieViewModel rows.forEach { it.init() } val homeRows = listOf( - HomeRow(HomeSection.RESUME, resumeItems, "Continue Watching"), - HomeRow(HomeSection.LATEST_MEDIA, recentlyReleasedItems, "Recently Released"), - HomeRow(HomeSection.LATEST_MEDIA, recentlyAddedItems, "Recently Added"), - HomeRow(HomeSection.NONE, suggestedItems, "Suggestions"), - HomeRow(HomeSection.NONE, unwatchedTopRatedItems, "Top Rated Unwatched"), + HomeRow(R.string.continue_watching, resumeItems), + HomeRow(R.string.recently_released, recentlyReleasedItems), + HomeRow(R.string.recently_added, recentlyAddedItems), + HomeRow(R.string.suggestions, suggestedItems), + HomeRow(R.string.top_unwatched, unwatchedTopRatedItems), ).filter { it.items.isNotEmpty() } withContext(Dispatchers.Main) { this@RecommendedMovieViewModel.rows.value = homeRows diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 589403f2..d6f733a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -8,6 +8,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences @@ -16,7 +17,6 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomeRow -import com.github.damontecres.wholphin.ui.main.HomeSection import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -124,12 +124,12 @@ class RecommendedTvShowViewModel rows.forEach { it.init() } val homeRows = listOf( - HomeRow(HomeSection.RESUME, resumeItems, "Continue Watching"), - HomeRow(HomeSection.NEXT_UP, nextUpItems, "Next Up"), - HomeRow(HomeSection.LATEST_MEDIA, recentlyReleasedItems, "Recently Released"), - HomeRow(HomeSection.LATEST_MEDIA, recentlyAddedItems, "Recently Added"), - HomeRow(HomeSection.NONE, suggestedItems, "Suggestions"), - HomeRow(HomeSection.NONE, unwatchedTopRatedItems, "Top Rated Unwatched"), + HomeRow(R.string.continue_watching, resumeItems), + HomeRow(R.string.next_up, nextUpItems), + HomeRow(R.string.recently_released, recentlyReleasedItems), + HomeRow(R.string.recently_added, recentlyAddedItems), + HomeRow(R.string.suggestions, suggestedItems), + HomeRow(R.string.top_unwatched, unwatchedTopRatedItems), ).filter { it.items.isNotEmpty() } withContext(Dispatchers.Main) { this@RecommendedTvShowViewModel.rows.value = homeRows diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 7be5878a..63b904b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -74,24 +76,30 @@ fun MediaSourceInfo( modifier = modifier, ) { Text( - text = "Name: ${source.name}", + text = stringResource(R.string.name) + ": ${source.name}", ) Text( text = "ID: ${source.id}", ) if (showFilePath) { Text( - text = "Path: ${source.path}", + text = stringResource(R.string.path) + ": ${source.path}", ) } source.size?.let { size -> Text( - text = "Size: ${formatBytes(size)}", + text = stringResource(R.string.file_size) + ": ${formatBytes(size)}", ) } source.bitrate?.let { bitrate -> Text( - text = "Bitrate: ${formatBytes(bitrate, byteRateSuffixes)}", + text = + stringResource(R.string.bitrate) + ": ${ + formatBytes( + bitrate, + byteRateSuffixes, + ) + }", ) } source.mediaStreams?.letNotEmpty { streams -> @@ -109,7 +117,7 @@ fun MediaSourceInfo( stream.profile?.let(::add) } Text( - text = "Video: " + data.joinToString(" - "), + text = stringResource(R.string.video) + ": " + data.joinToString(" - "), ) } @@ -121,10 +129,10 @@ fun MediaSourceInfo( stream.channelLayout?.let(::add) stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) } if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName) - if (stream.isDefault) add("Default") + if (stream.isDefault) add(stringResource(R.string.default_track)) } Text( - text = "Audio ${index + 1}: " + data.joinToString(" - "), + text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "), ) } @@ -133,12 +141,14 @@ fun MediaSourceInfo( buildList { stream.language?.let { add(languageName(it)) } stream.codec?.let(::add) - if (stream.isDefault) add("Default") - if (stream.isForced) add("Forced") - if (stream.isExternal) add("External") + if (stream.isDefault) add(stringResource(R.string.default_track)) + if (stream.isForced) add(stringResource(R.string.forced_track)) + if (stream.isExternal) add(stringResource(R.string.external_track)) } Text( - text = "Subtitle ${index + 1}: " + data.joinToString(" - "), + text = + stringResource(R.string.subtitle) + " ${index + 1}: " + + data.joinToString(" - "), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index 945ed053..ac0808a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -10,6 +10,14 @@ data class SortAndDirection( val direction: SortOrder, ) { fun flip() = copy(direction = direction.flip()) + + companion object { + val DEFAULT = + SortAndDirection( + ItemSortBy.SORT_NAME, + SortOrder.ASCENDING, + ) + } } fun SortOrder.flip() = if (this == SortOrder.ASCENDING) SortOrder.DESCENDING else SortOrder.ASCENDING @@ -20,6 +28,9 @@ val MovieSortOptions = ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, ) @@ -30,6 +41,7 @@ val SeriesSortOptions = ItemSortBy.DATE_CREATED, ItemSortBy.DATE_LAST_CONTENT_ADDED, ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, ItemSortBy.RANDOM, ) @@ -50,5 +62,8 @@ fun getStringRes(sort: ItemSortBy): Int = ItemSortBy.DATE_LAST_CONTENT_ADDED -> R.string.sort_by_date_episode_added ItemSortBy.DATE_PLAYED -> R.string.sort_by_date_played ItemSortBy.RANDOM -> R.string.sort_by_random + ItemSortBy.COMMUNITY_RATING -> R.string.community_rating + ItemSortBy.CRITIC_RATING -> R.string.critic_rating + ItemSortBy.PLAY_COUNT -> R.string.play_count else -> throw IllegalArgumentException("Unsupported sort option: $sort") } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 9a7d3d77..7fa770ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.Button @@ -59,7 +60,9 @@ import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber private const val DEBUG = false @@ -296,7 +299,7 @@ fun CardGrid( // focusedIndex = -1 Box(modifier = Modifier.fillMaxSize()) { Text( - text = "No results", + text = stringResource(R.string.no_results), color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.align(Alignment.Center), ) @@ -324,6 +327,8 @@ fun CardGrid( } } } + val context = LocalContext.current + val letters = context.getString(R.string.jump_letters) // Letters val currentLetter = remember(focusedIndex) { @@ -342,15 +347,19 @@ fun CardGrid( null } } - ?: LETTERS[0] + ?: letters[0] } if (showLetterButtons && pager.isNotEmpty()) { AlphabetButtons( + letters = letters, currentLetter = currentLetter, modifier = Modifier.align(Alignment.CenterVertically), letterClicked = { letter -> scope.launch(ExceptionHandler()) { - val jumpPosition = letterPosition.invoke(letter) + val jumpPosition = + withContext(Dispatchers.IO) { + letterPosition.invoke(letter) + } Timber.d("Alphabet jump to $jumpPosition") if (jumpPosition >= 0) { gridState.scrollToItem(jumpPosition) @@ -399,18 +408,18 @@ fun JumpButton( } } -private const val LETTERS = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ" - @Composable fun AlphabetButtons( + letters: String, currentLetter: Char, letterClicked: (Char) -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val index = LETTERS.indexOf(currentLetter) + val index = letters.indexOf(currentLetter) LaunchedEffect(currentLetter) { scope.launch(ExceptionHandler()) { val firstVisibleItemIndex = listState.firstVisibleItemIndex @@ -423,19 +432,19 @@ fun AlphabetButtons( } } } - val focusRequesters = remember { List(LETTERS.length) { FocusRequester() } } + val focusRequesters = remember { List(letters.length) { FocusRequester() } } LazyColumn( state = listState, modifier = modifier.focusProperties { onEnter = { - focusRequesters[index.coerceIn(0, LETTERS.length - 1)].tryRequestFocus() + focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus() } }, ) { items( - LETTERS.length, - key = { LETTERS[it] }, + letters.length, + key = { letters[it] }, ) { index -> val interactionSource = remember { MutableInteractionSource() } val focused by interactionSource.collectIsFocusedAsState() @@ -447,17 +456,17 @@ fun AlphabetButtons( contentPadding = PaddingValues(2.dp), interactionSource = interactionSource, onClick = { - letterClicked.invoke(LETTERS[index]) + letterClicked.invoke(letters[index]) }, ) { val color = - if (!focused && LETTERS[index] == currentLetter) { + if (!focused && letters[index] == currentLetter) { MaterialTheme.colorScheme.tertiary } else { LocalContentColor.current } Text( - text = LETTERS[index].toString(), + text = letters[index].toString(), color = color, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 368df5ee..1716a926 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData @@ -33,6 +34,7 @@ import androidx.tv.material3.TabDefaults import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -96,8 +98,8 @@ fun CollectionFolderLiveTv( val tabs = listOf( - TabId("Guide", UUID.randomUUID()), - TabId("DVR Schedule", UUID.randomUUID()), + TabId(stringResource(R.string.tv_guide), UUID.randomUUID()), + TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()), ) + folders var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 426d8b3b..c9cc3e59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme @@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -52,7 +54,13 @@ fun CollectionFolderMovie( val rememberedTabIndex = remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = listOf("Recommended", "Library", "Collections", "Genres") + val tabs = + listOf( + stringResource(R.string.recommended), + stringResource(R.string.library), + stringResource(R.string.collections), + stringResource(R.string.genres), + ) var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 0e5728ad..bb659e96 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme @@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -52,7 +54,12 @@ fun CollectionFolderTv( val rememberedTabIndex = remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = listOf("Recommended", "Library", "Genres") + val tabs = + listOf( + stringResource(R.string.recommended), + stringResource(R.string.library), + stringResource(R.string.genres), + ) var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 2f7c0ede..c55b2a4e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail +import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward @@ -36,6 +37,7 @@ import kotlin.time.Duration.Companion.seconds * @param onChooseTracks callback to pick a track for the given type of the item */ fun buildMoreDialogItems( + context: Context, item: BaseItem, series: BaseItem?, sourceId: UUID?, @@ -50,7 +52,7 @@ fun buildMoreDialogItems( buildList { add( DialogItem( - "Play", + context.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { @@ -83,7 +85,7 @@ fun buildMoreDialogItems( series?.let { add( DialogItem( - "Go to series", + context.getString(R.string.go_to_series), Icons.AutoMirrored.Filled.ArrowForward, ) { navigateTo( @@ -100,7 +102,10 @@ fun buildMoreDialogItems( if (sources.size > 1) { add( DialogItem( - "Choose Version", + context.getString( + R.string.choose_stream, + context.getString(R.string.version), + ), R.string.fa_file_video, ) { onChooseVersion.invoke() @@ -116,7 +121,10 @@ fun buildMoreDialogItems( if (audioCount > 1) { add( DialogItem( - "Choose audio", + context.getString( + R.string.choose_stream, + context.getString(R.string.audio), + ), R.string.fa_volume_low, ) { onChooseTracks.invoke(MediaStreamType.AUDIO) @@ -126,7 +134,10 @@ fun buildMoreDialogItems( if (subtitleCount > 0) { add( DialogItem( - "Choose subtitles", + context.getString( + R.string.choose_stream, + context.getString(R.string.subtitles), + ), R.string.fa_closed_captioning, ) { onChooseTracks.invoke(MediaStreamType.SUBTITLE) @@ -138,6 +149,7 @@ fun buildMoreDialogItems( } fun buildMoreDialogItemsForHome( + context: Context, item: BaseItem, seriesId: UUID?, playbackPosition: Duration, @@ -151,7 +163,7 @@ fun buildMoreDialogItemsForHome( val itemId = item.id add( DialogItem( - "Go To", + context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { navigateTo(item.destination()) @@ -161,7 +173,7 @@ fun buildMoreDialogItemsForHome( if (playbackPosition >= 1.seconds) { add( DialogItem( - "Resume", + context.getString(R.string.resume), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { @@ -175,7 +187,7 @@ fun buildMoreDialogItemsForHome( ) add( DialogItem( - "Restart", + context.getString(R.string.restart), Icons.Default.Refresh, // iconColor = Color.Green.copy(alpha = .8f), ) { @@ -190,7 +202,7 @@ fun buildMoreDialogItemsForHome( } else { add( DialogItem( - "Play", + context.getString(R.string.play), Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { @@ -224,7 +236,7 @@ fun buildMoreDialogItemsForHome( seriesId?.let { add( DialogItem( - "Go to series", + context.getString(R.string.go_to_series), Icons.AutoMirrored.Filled.ArrowForward, ) { navigateTo( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 92fa57c9..7365452e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme @@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -55,7 +57,12 @@ fun FavoritesPage( ) } - val tabs = listOf("Movies", "TV Shows", "Episodes") + val tabs = + listOf( + stringResource(R.string.movies), + stringResource(R.string.tv_shows), + stringResource(R.string.episodes), + ) val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt index 8a15d2f9..fec6162a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt @@ -25,21 +25,11 @@ abstract class ItemViewModel( val api: ApiClient, ) : ViewModel() { val item = MutableLiveData(null) + lateinit var itemId: UUID - suspend fun fetchItem( - itemId: UUID, - potential: BaseItem?, - ): BaseItem = + suspend fun fetchItem(itemId: UUID): BaseItem = withContext(Dispatchers.IO) { -// val fetchedItem = -// when { -// item.value == null && potential?.id == itemId -> potential -// item.value?.id == itemId -> item.value -// else -> { -// val it = api.userLibraryApi.getItem(itemId).content -// BaseItem.from(it, api) -// } -// } + this@ItemViewModel.itemId = itemId val it = api.userLibraryApi.getItem(itemId).content val fetchedItem = BaseItem.from(it, api) return@withContext fetchedItem.let { @@ -89,10 +79,8 @@ abstract class LoadingItemViewModel( open suspend fun fetchAndSetItem(itemId: UUID): BaseItem = withContext(Dispatchers.IO) { - val fetchedItem = api.userLibraryApi.getItem(itemId).content - val item = BaseItem.from(fetchedItem, api) + val item = fetchItem(itemId) withContext(Dispatchers.Main) { - this@LoadingItemViewModel.item.value = item loading.value = LoadingState.Success } return@withContext item diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 86dfbdbd..aaab1b9c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -33,6 +34,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -258,7 +260,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle("Movies", movies), + title = rowTitle(stringResource(R.string.movies), movies), state = movies, rowIndex = MOVIE_ROW, position = position, @@ -271,7 +273,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle("Series", series), + title = rowTitle(stringResource(R.string.tv_shows), series), state = series, rowIndex = SERIES_ROW, position = position, @@ -284,7 +286,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle("Episodes", episodes), + title = rowTitle(stringResource(R.string.episodes), episodes), state = episodes, rowIndex = EPISODE_ROW, position = position, @@ -365,9 +367,14 @@ fun PersonHeader( val age = if (deathdate == null) birthdate.until(LocalDate.now())?.years else null val text = if (age != null) { - "Born: ${formatDate(it)} ($age years old)" + stringResource(R.string.born) + ": ${formatDate(it)} (${ + stringResource( + R.string.years_old, + age, + ) + })" } else { - "Born: ${formatDate(it)}" + stringResource(R.string.born) + ": ${formatDate(it)}" } Text( text = text, @@ -379,7 +386,7 @@ fun PersonHeader( } birthPlace?.let { Text( - text = "Birthplace: $it", + text = stringResource(R.string.birthplace) + ": $it", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall, maxLines = 1, @@ -390,9 +397,14 @@ fun PersonHeader( val age = birthdate?.until(it)?.years val text = if (age != null) { - "Died: ${formatDate(it)} ($age years old)" + stringResource(R.string.died) + ": ${formatDate(it)} (${ + stringResource( + R.string.years_old, + age, + ) + })" } else { - "Died: ${formatDate(it)}" + stringResource(R.string.died) + ": ${formatDate(it)}" } Text( text = text, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 54551834..5ab05521 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -102,7 +104,7 @@ class PlaylistViewModel Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), ) { - val playlist = fetchItem(playlistId, null) + val playlist = fetchItem(playlistId) val request = GetPlaylistItemsRequest( playlistId = playlist.id, @@ -123,6 +125,7 @@ fun PlaylistDetails( modifier: Modifier = Modifier, viewModel: PlaylistViewModel = hiltViewModel(), ) { + val context = LocalContext.current LaunchedEffect(Unit) { viewModel.init(destination.itemId) } @@ -170,13 +173,13 @@ fun PlaylistDetails( items = listOf( DialogItem( - "Go to", + context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { viewModel.navigationManager.navigateTo(item.destination()) }, DialogItem( - "Play from here", + context.getString(R.string.play_from_here), Icons.Default.PlayArrow, ) { viewModel.navigationManager.navigateTo( @@ -282,7 +285,7 @@ fun PlaylistDetailsContent( .padding(horizontal = 16.dp), ) { Text( - text = playlist.name ?: "Playlist", + text = playlist.name ?: stringResource(R.string.playlist), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.displayMedium, textAlign = TextAlign.Center, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index ad43c16f..45578f4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -31,6 +32,7 @@ import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -202,7 +204,7 @@ fun DvrScheduleContent( if (activeRecordings.isEmpty() && scheduledRecordings.isEmpty()) { item { Text( - text = "No scheduled recordings", + text = stringResource(R.string.no_scheduled_recordings), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, @@ -213,7 +215,7 @@ fun DvrScheduleContent( if (activeRecordings.isNotEmpty()) { item { Text( - text = "Active", + text = stringResource(R.string.active_recordings), style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index f590adc7..1f2c9963 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -1,9 +1,11 @@ package com.github.damontecres.wholphin.ui.detail.livetv +import android.content.Context import androidx.compose.ui.graphics.Color import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn @@ -17,6 +19,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -46,6 +49,7 @@ const val MAX_HOURS = 48L class LiveTvViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, ) : ViewModel() { @@ -70,7 +74,13 @@ class LiveTvViewModel if (!firstLoad) { loading.value = LoadingState.Loading } - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) { + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Could not fetch channels", + ), + ) { val channelData by api.liveTvApi.getLiveTvChannels( GetLiveTvChannelsRequest( startIndex = 0, @@ -179,7 +189,7 @@ class LiveTvViewModel startHours = it.toFloat(), endHours = (it + 1).toFloat(), duration = 60.seconds, - name = "No data", + name = context.getString(R.string.no_data), subtitle = null, seasonEpisode = null, isRecording = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt index a5f6db0b..933b20ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -30,6 +31,7 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -165,7 +167,14 @@ fun ProgramDialog( ) } Text( - text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series", + text = + if (isSeriesRecording) { + stringResource( + R.string.cancel_series_recording, + ) + } else { + stringResource(R.string.record_series) + }, ) } } @@ -193,7 +202,16 @@ fun ProgramDialog( ) } Text( - text = if (isRecording) "Cancel Recording" else "Record Program", + text = + if (isRecording) { + stringResource( + R.string.cancel_recording, + ) + } else { + stringResource( + R.string.record_program, + ) + }, ) } } @@ -210,11 +228,11 @@ fun ProgramDialog( ) { Icon( imageVector = Icons.Default.PlayArrow, - contentDescription = "Delete", + contentDescription = stringResource(R.string.delete), tint = Color.Green.copy(alpha = .75f), ) Text( - text = "Watch live", + text = stringResource(R.string.watch_live), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 301363d1..5322a9e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage @@ -109,6 +110,15 @@ fun MovieDetails( -> LoadingPage() LoadingState.Success -> { item?.let { movie -> + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } MovieDetailsContent( preferences = preferences, movie = movie, @@ -118,10 +128,10 @@ fun MovieDetails( trailers = trailers, similar = similar, onClickItem = { - viewModel.navigationManager.navigateTo(it.destination()) + viewModel.navigateTo(it.destination()) }, onClickPerson = { - viewModel.navigationManager.navigateTo( + viewModel.navigateTo( Destination.MediaItem( it.id, BaseItemKind.PERSON, @@ -129,7 +139,7 @@ fun MovieDetails( ) }, playOnClick = { - viewModel.navigationManager.navigateTo( + viewModel.navigateTo( Destination.Playback( movie.id, it.inWholeMilliseconds, @@ -140,7 +150,7 @@ fun MovieDetails( overviewOnClick = { overviewDialog = ItemDetailsDialogInfo( - title = movie.name ?: "Unknown", + title = movie.name ?: context.getString(R.string.unknown), overview = movie.data.overview, files = movie.data.mediaSources.orEmpty(), ) @@ -152,17 +162,21 @@ fun MovieDetails( title = movie.name + " (${movie.data.productionYear ?: ""})", items = buildMoreDialogItems( + context = context, item = movie, watched = movie.data.userData?.played ?: false, favorite = movie.data.userData?.isFavorite ?: false, series = null, sourceId = chosenStreams?.sourceId, - navigateTo = viewModel.navigationManager::navigateTo, + navigateTo = viewModel::navigateTo, onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onChooseVersion = { chooseVersion = - chooseVersionParams(movie.data.mediaSources!!) { idx -> + chooseVersionParams( + context, + movie.data.mediaSources!!, + ) { idx -> val source = movie.data.mediaSources!![idx] viewModel.savePlayVersion( movie, @@ -178,6 +192,7 @@ fun MovieDetails( )?.let { source -> chooseVersion = chooseStream( + context = context, streams = source.mediaStreams.orEmpty(), type = type, onClick = { trackIndex -> @@ -203,7 +218,7 @@ fun MovieDetails( trailerOnClick = { trailer -> when (trailer) { is LocalTrailer -> - viewModel.navigationManager.navigateTo( + viewModel.navigateTo( Destination.Playback( itemId = trailer.baseItem.id, item = trailer.baseItem, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 74faaccf..534fe23e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -147,7 +147,7 @@ fun MovieDetailsHeader( ?.joinToString(", ") { it.name!! } ?.let { Text( - text = "Directed by $it", + text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -183,7 +183,7 @@ fun MovieDetailsHeader( ?.let { if (it.isNotNullOrBlank()) { TitleValueText( - "Subtitles", + stringResource(R.string.subtitles), it, modifier = Modifier.widthIn(max = 200.dp), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index d51d9523..7284ba51 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.ui.detail.movie +import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -12,16 +14,20 @@ import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.ThemeSongPlayer import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -40,21 +46,20 @@ class MovieViewModel @Inject constructor( api: ApiClient, - val navigationManager: NavigationManager, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, + private val themeSongPlayer: ThemeSongPlayer, ) : LoadingItemViewModel(api) { - private lateinit var itemId: UUID - val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) val chapters = MutableLiveData>(listOf()) val similar = MutableLiveData>() val chosenStreams = MutableLiveData(null) - fun init(itemId: UUID): Job? { - this.itemId = itemId - return viewModelScope.launch( + fun init(itemId: UUID): Job? = + viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( loading, @@ -78,7 +83,7 @@ class MovieViewModel // TODO would be nice to clean up the trailer name // ?.replace(item.name ?: "", "") // ?.removePrefix(" - ") - ?: "Trailer" + ?: context.getString(R.string.trailer) RemoteTrailer(name, url) } }.orEmpty() @@ -120,7 +125,6 @@ class MovieViewModel } } } - } fun setWatched(played: Boolean) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { @@ -181,4 +185,25 @@ class MovieViewModel } } } + + fun maybePlayThemeSong( + seriesId: UUID, + playThemeSongs: ThemeSongVolume, + ) { + viewModelScope.launchIO { + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + addCloseable { + themeSongPlayer.stop() + } + } + } + + fun release() { + themeSongPlayer.stop() + } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 927a08e8..ec4c4b6f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -82,7 +82,7 @@ fun FocusedEpisodeFooter( ?.let { if (it.isNotNullOrBlank()) { TitleValueText( - "Subtitles", + stringResource(R.string.subtitles), it, modifier = Modifier.widthIn(max = 160.dp), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 85ff5a9d..a71d9423 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.series +import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +34,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -83,15 +85,7 @@ fun SeriesDetails( modifier: Modifier = Modifier, viewModel: SeriesViewModel = hiltViewModel(), ) { - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { - viewModel.release() - } - } + val context = LocalContext.current LaunchedEffect(Unit) { viewModel.init(preferences, destination.itemId, destination.item, null) } @@ -113,6 +107,16 @@ fun SeriesDetails( -> LoadingPage() LoadingState.Success -> { item?.let { item -> + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } + val played = item.data.userData?.played ?: false SeriesDetailsContent( preferences = preferences, @@ -135,7 +139,8 @@ fun SeriesDetails( onLongClickItem = { season -> seasonDialog = buildDialogForSeason( - season, + context = context, + s = season, onClickItem = { viewModel.navigateTo(it.destination()) }, markPlayed = { played -> viewModel.setSeasonWatched(season.id, played) @@ -145,7 +150,7 @@ fun SeriesDetails( overviewOnClick = { overviewDialog = ItemDetailsDialogInfo( - title = item.name ?: "Unknown", + title = item.name ?: context.getString(R.string.unknown), overview = item.data.overview, files = listOf(), ) @@ -160,7 +165,8 @@ fun SeriesDetails( if (showWatchConfirmation) { ConfirmDialog( title = item.name ?: "", - body = if (played) "Mark entire series as unplayed?" else "Mark entire series as played?", + body = + stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), onCancel = { showWatchConfirmation = false }, @@ -342,7 +348,7 @@ fun SeriesDetailsContent( } item { ItemRow( - title = "Seasons", + title = stringResource(R.string.tv_seasons), items = seasons, onClickItem = { position = SEASONS_ROW @@ -443,7 +449,7 @@ fun SeriesDetailsHeader( modifier = modifier, ) { Text( - text = series.name ?: "Unknown", + text = series.name ?: stringResource(R.string.unknown), style = MaterialTheme.typography.displaySmall, modifier = Modifier.fillMaxWidth(), ) @@ -486,6 +492,7 @@ fun SeriesDetailsHeader( } fun buildDialogForSeason( + context: Context, s: BaseItem, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, @@ -493,26 +500,26 @@ fun buildDialogForSeason( val items = buildList { add( - DialogItem("Go to", Icons.Default.PlayArrow) { + DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { onClickItem.invoke(s) }, ) if (s.data.userData?.played == true) { add( - DialogItem("Mark as unplayed", R.string.fa_eye) { + DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { markPlayed.invoke(false) }, ) } else { add( - DialogItem("Mark as played", R.string.fa_eye_slash) { + DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { markPlayed.invoke(true) }, ) } } return DialogParams( - title = s.name ?: "Season", + title = s.name ?: context.getString(R.string.tv_season), fromLongClick = true, items = items, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 4502ef07..ef0a0eaf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -13,8 +13,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences @@ -72,15 +74,7 @@ fun SeriesOverview( viewModel: SeriesViewModel = hiltViewModel(), initialSeasonEpisode: SeasonEpisodeIds? = null, ) { - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { - viewModel.release() - } - } + val context = LocalContext.current val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } @@ -168,6 +162,15 @@ fun SeriesOverview( LoadingState.Success -> { series?.let { series -> LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } fun buildMoreForEpisode( ep: BaseItem, @@ -178,7 +181,8 @@ fun SeriesOverview( title = series.name + " - " + ep.data.seasonEpisode, items = buildMoreDialogItems( - ep, + context = context, + item = ep, watched = ep.data.userData?.played ?: false, favorite = ep.data.userData?.isFavorite ?: false, series = series, @@ -208,7 +212,10 @@ fun SeriesOverview( }, onChooseVersion = { chooseVersion = - chooseVersionParams(ep.data.mediaSources!!) { idx -> + chooseVersionParams( + context, + ep.data.mediaSources!!, + ) { idx -> val source = ep.data.mediaSources!![idx] viewModel.savePlayVersion( ep, @@ -224,6 +231,7 @@ fun SeriesOverview( )?.let { source -> chooseVersion = chooseStream( + context = context, streams = source.mediaStreams.orEmpty(), type = type, onClick = { trackIndex -> @@ -313,7 +321,7 @@ fun SeriesOverview( episodeList?.getOrNull(position.episodeRowIndex)?.let { overviewDialog = ItemDetailsDialogInfo( - title = it.name ?: "Unknown", + title = it.name ?: context.getString(R.string.unknown), overview = it.data.overview, files = it.data.mediaSources.orEmpty(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index db9402cf..0629ce6d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -42,6 +43,7 @@ import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.aspectRatioFloat @@ -178,7 +180,9 @@ fun SeriesOverviewContent( .focusRequester(focusRequesters[index]), ) { Text( - text = season.name ?: "Season ${season.data.indexNumber}", + text = + season.name + ?: (stringResource(R.string.tv_season) + " ${season.data.indexNumber}"), modifier = Modifier.padding(8.dp), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 04da9868..8acb295e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context import android.widget.Toast -import androidx.annotation.OptIn import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -30,7 +28,6 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.ThemeSongPlayer -import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -41,7 +38,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields @@ -88,7 +84,7 @@ class SeriesViewModel "Error loading series $seriesId", ) + Dispatchers.IO, ) { - val item = fetchItem(seriesId, potential) + val item = fetchItem(seriesId) val seasons = getSeasons(item) // If a particular season was requested, fetch those episodes, otherwise get the first season @@ -141,34 +137,14 @@ class SeriesViewModel /** * If the series has a theme song & app settings allow, play it */ - @OptIn(UnstableApi::class) fun maybePlayThemeSong( seriesId: UUID, playThemeSongs: ThemeSongVolume, ) { viewModelScope.launchIO { - val themeSongs = api.libraryApi.getThemeSongs(seriesId).content - themeSongs.items.firstOrNull()?.let { theme -> - theme.mediaSources?.firstOrNull()?.let { source -> - val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - theme.id, - container = - listOf( - Codec.Audio.OPUS, - Codec.Audio.MP3, - Codec.Audio.AAC, - Codec.Audio.FLAC, - ), - ) - Timber.v("Found theme song for series $seriesId") - withContext(Dispatchers.Main) { - themeSongPlayer.play(playThemeSongs, url) - addCloseable { - themeSongPlayer.stop() - } - } - } + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + addCloseable { + themeSongPlayer.stop() } } } @@ -287,7 +263,7 @@ class SeriesViewModel if (listIndex != null) { refreshEpisode(itemId, listIndex) } else { - fetchItem(seriesId, null) + fetchItem(seriesId) } } @@ -296,7 +272,7 @@ class SeriesViewModel played: Boolean, ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) - val series = fetchItem(seriesId, null) + val series = fetchItem(seriesId) val seasons = getSeasons(series) this@SeriesViewModel.seasons.setValueOnMain(seasons) } @@ -308,7 +284,7 @@ class SeriesViewModel } else { api.playStateApi.markUnplayedItem(seriesId) } - val series = fetchItem(seriesId, null) + val series = fetchItem(seriesId) val seasons = getSeasons(series) this@SeriesViewModel.seasons.setValueOnMain(seasons) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 2e116b5d..74f2849c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.main import android.widget.Toast +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -71,10 +72,20 @@ import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration data class HomeRow( - val section: HomeSection, + @param:StringRes val titleRes: Int?, + val title: String?, val items: List, - val title: String? = null, -) +) { + constructor( + @StringRes titleRes: Int, + items: List, + ) : this(titleRes, null, items) + + constructor( + title: String, + items: List, + ) : this(null, title, items) +} @Composable fun HomePage( @@ -121,6 +132,7 @@ fun HomePage( onLongClickItem = { val dialogItems = buildMoreDialogItemsForHome( + context = context, item = it, seriesId = it.data.seriesId, playbackPosition = @@ -242,7 +254,7 @@ fun HomePageContent( itemsIndexed(homeRows) { rowIndex, row -> if (row.items.isNotEmpty()) { ItemRow( - title = row.title ?: stringResource(row.section.nameRes), + title = row.title ?: row.titleRes?.let { stringResource(it) } ?: "", items = row.items, onClickItem = onClickItem, cardOnFocus = { isFocused, index -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeSection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeSection.kt deleted file mode 100644 index 60978114..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeSection.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.damontecres.wholphin.ui.main - -import androidx.annotation.StringRes -import com.github.damontecres.wholphin.R - -/** - * All possible homesections, "synced" with jellyfin-web. - * - * https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js - */ -enum class HomeSection( - val key: String, - @param:StringRes val nameRes: Int, -) { - LATEST_MEDIA("latestmedia", R.string.home_section_latest_media), - LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library), - LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small), - RESUME("resume", R.string.home_section_resume), - RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio), - RESUME_BOOK("resumebook", R.string.home_section_resume_book), - ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings), - NEXT_UP("nextup", R.string.home_section_next_up), - LIVE_TV("livetv", R.string.home_section_livetv), - NONE("none", R.string.home_section_none), - ; - - companion object { - fun fromKey(key: String): HomeSection = entries.firstOrNull { it.key == key } ?: NONE - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 3db3f630..806ab9f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.ui.main +import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem @@ -15,6 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -40,6 +43,7 @@ import javax.inject.Inject class HomeViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, val serverRepository: ServerRepository, @@ -86,7 +90,7 @@ class HomeViewModel if (resume.isNotEmpty()) { add( HomeRow( - section = HomeSection.RESUME, + titleRes = R.string.recently_added, items = resume, ), ) @@ -94,7 +98,7 @@ class HomeViewModel if (nextUp.isNotEmpty()) { add( HomeRow( - section = HomeSection.NEXT_UP, + titleRes = R.string.next_up, items = nextUp, ), ) @@ -191,10 +195,10 @@ class HomeViewModel .mapNotNull { view -> val title = if (view.collectionType == CollectionType.LIVETV) { - "Recently Recorded" + context.getString(R.string.recently_recorded) } else { - view.name?.let { "Recently Added in $it" } - } + view.name?.let { context.getString(R.string.recently_added_in, it) } + } ?: context.getString(R.string.recently_added) val viewId = if (view.collectionType == CollectionType.LIVETV) { api.liveTvApi @@ -222,9 +226,8 @@ class HomeViewModel .content .map { BaseItem.from(it, api, true) } HomeRow( - section = HomeSection.LATEST_MEDIA, - items = latest, title = title, + items = latest, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 341f8c86..6d464fa7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData @@ -28,6 +30,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.Cards @@ -153,6 +156,7 @@ fun SearchPage( modifier: Modifier = Modifier, viewModel: SearchViewModel = hiltViewModel(), ) { + val context = LocalContext.current val movies by viewModel.movies.observeAsState(SearchResult.NoQuery) val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery) @@ -198,7 +202,7 @@ fun SearchPage( } } searchResultRow( - title = "Movies", + title = context.getString(R.string.movies), result = movies, rowIndex = MOVIE_ROW, position = position, @@ -208,7 +212,7 @@ fun SearchPage( modifier = Modifier.fillMaxWidth(), ) searchResultRow( - title = "Collections", + title = context.getString(R.string.collections), result = collections, rowIndex = COLLECTION_ROW, position = position, @@ -218,7 +222,7 @@ fun SearchPage( modifier = Modifier.fillMaxWidth(), ) searchResultRow( - title = "Series", + title = context.getString(R.string.tv_shows), result = series, rowIndex = SERIES_ROW, position = position, @@ -228,7 +232,7 @@ fun SearchPage( modifier = Modifier.fillMaxWidth(), ) searchResultRow( - title = "Episodes", + title = context.getString(R.string.episodes), result = episodes, rowIndex = EPISODE_ROW, position = position, @@ -308,7 +312,7 @@ fun LazyListScope.searchResultRow( SearchResult.Searching -> SearchResultPlaceholder( title = title, - message = "Searching...", + message = stringResource(R.string.searching), modifier = modifier, ) @@ -316,7 +320,7 @@ fun LazyListScope.searchResultRow( if (r.items.isEmpty()) { SearchResultPlaceholder( title = title, - message = "No results", + message = stringResource(R.string.no_results), modifier = modifier, ) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index f8f37703..de463e99 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -253,7 +253,8 @@ fun NavDrawer( } } } - if (preferences.appPreferences.interfacePreferences.navDrawerSwitchOnFocus) { + // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 + if (false && preferences.appPreferences.interfacePreferences.navDrawerSwitchOnFocus) { LaunchedEffect(derivedFocusedIndex) { val index = derivedFocusedIndex delay(600) @@ -323,7 +324,7 @@ fun NavDrawer( val focused by interactionSource.collectIsFocusedAsState() LaunchedEffect(focused) { if (focused) focusedIndex = -2 } IconNavItem( - text = "Search", + text = stringResource(R.string.search), icon = Icons.Default.Search, selected = selectedIndex == -2, interactionSource = interactionSource, @@ -344,7 +345,7 @@ fun NavDrawer( val focused by interactionSource.collectIsFocusedAsState() LaunchedEffect(focused) { if (focused) focusedIndex = -1 } IconNavItem( - text = "Home", + text = stringResource(R.string.home), icon = Icons.Default.Home, selected = selectedIndex == -1, interactionSource = interactionSource, @@ -412,7 +413,7 @@ fun NavDrawer( val focused by interactionSource.collectIsFocusedAsState() LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } IconNavItem( - text = "Settings", + text = stringResource(R.string.settings), icon = Icons.Default.Settings, selected = false, interactionSource = interactionSource, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index ab19bbf6..4842f740 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon @@ -35,6 +37,7 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItemDivider @@ -58,7 +61,7 @@ fun DownloadSubtitlesContent( SubtitleSearch.Searching -> { Wrapper { Text( - text = "Searching...", + text = stringResource(R.string.searching), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -68,7 +71,7 @@ fun DownloadSubtitlesContent( SubtitleSearch.Downloading -> { Wrapper { Text( - text = "Downloading...", + text = stringResource(R.string.downloading), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -199,9 +202,15 @@ fun convertRemoteSubtitles( op.providerName?.let(::add) op.threeLetterIsoLanguageName?.let(::add) if (op.forced == true) { - add("Forced") + add(stringResource(R.string.forced_track)) } - add("${abbreviateNumber(op.downloadCount ?: 0)} downloads") + add( + pluralStringResource( + R.plurals.downloads, + op.downloadCount ?: 0, + abbreviateNumber(op.downloadCount ?: 0), + ), + ) } Text( text = strings.joinToString(" - "), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt index 71374ffc..2811ecc3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt @@ -1,5 +1,8 @@ package com.github.damontecres.wholphin.ui.playback +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication + data class SubtitleStream( val index: Int, val language: String?, @@ -17,7 +20,8 @@ data class SubtitleStream( language, title, codec, - ).joinToString(" - ").ifBlank { "Unknown" } + ).joinToString(" - ") + .ifBlank { WholphinApplication.instance.getString(R.string.unknown) } } data class AudioStream( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt index dc7036da..fc39255e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -34,6 +35,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -68,7 +70,7 @@ fun NextUpEpisode( .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Up Next...", + text = stringResource(R.string.next_up) + "...", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 60313567..5df69f4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -66,6 +67,7 @@ import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.stringRes import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -212,7 +214,7 @@ fun PlaybackControls( .padding(end = 32.dp), ) { Text( - text = "Skip ${segment.type.serialName}", + text = stringResource(R.string.skip) + " " + stringResource(segment.type.stringRes), ) } } @@ -332,7 +334,7 @@ fun LeftPlaybackButtons( val options = buildList { addAll(moreButtonOptions.options.keys) - add(if (showDebugInfo) "Hide debug info" else "Show debug info") + add(stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info)) } BottomDialog( choices = options, @@ -407,7 +409,12 @@ fun RightPlaybackButtons( val currentChoice = subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex } ?: subtitleStreams.size BottomDialog( - choices = options + listOf("None", "Search & Download"), + choices = + options + + listOf( + stringResource(R.string.none), + stringResource(R.string.search_and_download), + ), currentChoice = currentChoice, onDismissRequest = { onControllerInteraction.invoke() @@ -434,7 +441,12 @@ fun RightPlaybackButtons( ) } if (showOptionsDialog) { - val options = listOf("Audio Track", "Playback Speed", "Video Scale") + val options = + listOf( + stringResource(R.string.audio), + stringResource(R.string.playback_speed), + stringResource(R.string.video_scale), + ) BottomDialog( choices = options, currentChoice = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index d8d338da..912cb214 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -38,11 +38,13 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.Player import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.ItemPlayback @@ -199,7 +201,7 @@ fun PlaybackOverlay( }, ) { Text( - text = "Chapters", + text = stringResource(R.string.chapters), style = MaterialTheme.typography.titleLarge, ) LazyRow( @@ -240,7 +242,7 @@ fun PlaybackOverlay( } if (playlist.hasNext()) { Text( - text = "Queue", + text = stringResource(R.string.queue), style = MaterialTheme.typography.titleLarge, modifier = Modifier @@ -282,7 +284,7 @@ fun PlaybackOverlay( }, ) { Text( - text = "Queue", + text = stringResource(R.string.queue), style = MaterialTheme.typography.titleLarge, ) LazyRow( @@ -489,7 +491,7 @@ fun Controller( when (nextState) { OverlayViewState.CHAPTERS -> Text( - text = "Chapters", + text = stringResource(R.string.chapters), style = MaterialTheme.typography.titleLarge, modifier = Modifier @@ -501,7 +503,7 @@ fun Controller( OverlayViewState.QUEUE -> Text( - text = "Queue", + text = stringResource(R.string.queue), style = MaterialTheme.typography.titleLarge, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 50a1d81b..caf4f1ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -60,6 +61,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.preferences.UserPreferences @@ -69,9 +71,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.seasonEpisode +import com.github.damontecres.wholphin.util.stringRes import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID @@ -162,7 +167,6 @@ fun PlaybackPage( val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) val focusRequester = remember { FocusRequester() } - val controllerFocusRequester = remember { FocusRequester() } val playPauseState = rememberPlayPauseButtonState(player) val seekBarState = rememberSeekBarState(player, scope) @@ -215,9 +219,9 @@ fun PlaybackPage( Box( modifier - .background(Color.Black), + .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), ) { - val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f) + val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) Box( modifier = Modifier @@ -410,7 +414,7 @@ fun PlaybackPage( modifier = Modifier.focusRequester(focusRequester), ) { Text( - text = "Skip ${segment.type.serialName}", + text = stringResource(R.string.skip) + " " + stringResource(segment.type.stringRes), ) } } @@ -418,7 +422,13 @@ fun PlaybackPage( // Next up episode BackHandler(nextUp != null) { - viewModel.navigationManager.goBack() + if (player.isPlaying) { + scope.launch(ExceptionHandler()) { + viewModel.cancelUpNextEpisode() + } + } else { + viewModel.navigationManager.goBack() + } } AnimatedVisibility( nextUp != null, @@ -470,7 +480,7 @@ fun PlaybackPage( Modifier .padding(8.dp) // .height(128.dp) - .fillMaxHeight(.5f) + .fillMaxHeight(1 - playerSize) .fillMaxWidth(.66f) .align(Alignment.BottomCenter) .background( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 1510830d..5ed4e19b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -19,6 +19,7 @@ import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -33,6 +34,7 @@ import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.MediaExtensionStatus +import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.launchIO @@ -97,12 +99,6 @@ import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -enum class TranscodeType { - DIRECT_PLAY, - DIRECT_STREAM, - TRANSCODE, -} - data class StreamDecision( val itemId: UUID, val type: PlayMethod, @@ -159,6 +155,7 @@ class PlaybackViewModel val trickplay = MutableLiveData(null) val chapters = MutableLiveData>(listOf()) val currentSegment = EqualityMutableLiveData(null) + private val autoSkippedSegments = mutableSetOf() private lateinit var preferences: UserPreferences private lateinit var deviceProfile: DeviceProfile @@ -244,6 +241,7 @@ class PlaybackViewModel ): Boolean = withContext(Dispatchers.IO) { Timber.i("Playing ${base.id}") + autoSkippedSegments.clear() if (base.type !in supportItemKinds) { showToast( context, @@ -358,11 +356,9 @@ class PlaybackViewModel audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, ) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse - } withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.subtitleStreams.value = subtitleStreams @@ -664,39 +660,53 @@ class PlaybackViewModel .firstOrNull { it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } - if (currentSegment != null) { + if (currentSegment != null && autoSkippedSegments.add(currentSegment.id)) { Timber.d( "Found media segment for %s: %s, %s", currentSegment.itemId, currentSegment.id, currentSegment.type, ) - val behavior = - when (currentSegment.type) { - MediaSegmentType.COMMERCIAL -> prefs.skipCommercials - MediaSegmentType.PREVIEW -> prefs.skipPreviews - MediaSegmentType.RECAP -> prefs.skipRecaps - MediaSegmentType.OUTRO -> prefs.skipOutros - MediaSegmentType.INTRO -> prefs.skipIntros - MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE + val playlist = this@PlaybackViewModel.playlist.value + + if (currentSegment.type == MediaSegmentType.OUTRO && + prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && + playlist != null && playlist.hasNext() + ) { + val nextItem = playlist.peek() + Timber.v("Setting next up during outro to ${nextItem?.id}") + withContext(Dispatchers.Main) { + nextUp.value = nextItem } - withContext(Dispatchers.Main) { - when (behavior) { - SkipSegmentBehavior.AUTO_SKIP -> { - this@PlaybackViewModel.currentSegment.value = null - player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) + } else { + val behavior = + when (currentSegment.type) { + MediaSegmentType.COMMERCIAL -> prefs.skipCommercials + MediaSegmentType.PREVIEW -> prefs.skipPreviews + MediaSegmentType.RECAP -> prefs.skipRecaps + MediaSegmentType.OUTRO -> prefs.skipOutros + MediaSegmentType.INTRO -> prefs.skipIntros + MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE } + withContext(Dispatchers.Main) { + when (behavior) { + SkipSegmentBehavior.AUTO_SKIP -> { + this@PlaybackViewModel.currentSegment.value = null + player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) + } - SkipSegmentBehavior.ASK_TO_SKIP -> { - this@PlaybackViewModel.currentSegment.value = currentSegment - } + SkipSegmentBehavior.ASK_TO_SKIP -> { + this@PlaybackViewModel.currentSegment.value = + currentSegment + } - else -> { - this@PlaybackViewModel.currentSegment.value = null + else -> { + this@PlaybackViewModel.currentSegment.value = null + } } } } - } else { + } else if (currentSegment == null) { withContext(Dispatchers.Main) { this@PlaybackViewModel.currentSegment.value = null } @@ -954,7 +964,7 @@ class PlaybackViewModel if (maxAttempts == 0) { showToast( context, - "Download is taking a long time, you may need to restart playback", + context.getString(R.string.subtitle_download_too_long), ) } else { // Find the new subtitle stream diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 62b21a97..2844207b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -97,9 +97,9 @@ fun PreferencesContent( } val screenTitle = when (preferenceScreenOption) { - PreferenceScreenOption.BASIC -> "Preferences" - PreferenceScreenOption.ADVANCED -> "Advanced Preferences" - PreferenceScreenOption.USER_INTERFACE -> "User Interface Preferences" + PreferenceScreenOption.BASIC -> R.string.settings + PreferenceScreenOption.ADVANCED -> R.string.advanced_settings + PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface } var visible by remember { mutableStateOf(false) } @@ -126,7 +126,7 @@ fun PreferencesContent( ) { stickyHeader { Text( - text = screenTitle, + text = stringResource(screenTitle), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index c9ff33aa..edb461f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData @@ -42,6 +43,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -221,7 +223,7 @@ fun InstallUpdatePageContent( ).padding(16.dp), ) { Text( - text = "Update available", + text = stringResource(R.string.update_available), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -234,14 +236,14 @@ fun InstallUpdatePageContent( onClick = onInstallRelease, ) { Text( - text = "Download & Update", + text = stringResource(R.string.download_and_update), ) } Button( onClick = onCancel, ) { Text( - text = "Cancel", + text = stringResource(R.string.cancel), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 8dadb4fb..a83cfb6d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.Icon @@ -96,7 +97,7 @@ fun ServerList( ListItem( enabled = true, selected = false, - headlineContent = { Text(text = "Add Server") }, + headlineContent = { Text(text = stringResource(R.string.add_server)) }, leadingContent = { Icon( imageVector = Icons.Default.Add, @@ -116,11 +117,14 @@ fun ServerList( title = server.name ?: server.url, dialogItems = listOf( - DialogItem("Switch", R.string.fa_arrow_left_arrow_right) { + DialogItem( + stringResource(R.string.switch_servers), + R.string.fa_arrow_left_arrow_right, + ) { onSwitchServer.invoke(server) }, DialogItem( - "Delete", + stringResource(R.string.delete), Icons.Default.Delete, Color.Red.copy(alpha = .8f), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index e840a3e5..0dbce500 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -31,6 +32,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox @@ -78,7 +80,7 @@ fun SwitchServerContent( ), ) { Text( - text = "Select Server", + text = stringResource(R.string.select_server), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -120,13 +122,13 @@ fun SwitchServerContent( ), ) { Text( - text = "Discovered Servers", + text = stringResource(R.string.discovered_servers), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) if (discoveredServers.isEmpty()) { Text( - text = "Searching...", + text = stringResource(R.string.searching), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -184,7 +186,7 @@ fun SwitchServerContent( .fillMaxWidth(.4f), ) { Text( - text = "Enter Server URL", + text = stringResource(R.string.enter_server_url), ) EditTextBox( value = url, @@ -227,7 +229,7 @@ fun SwitchServerContent( if (state == LoadingState.Loading) { CircularProgress(Modifier.size(32.dp)) } else { - Text(text = "Submit") + Text(text = stringResource(R.string.submit)) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index b8d3c9d3..49e9be8a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -37,6 +38,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox @@ -94,7 +96,7 @@ fun SwitchUserContent( ), ) { Text( - text = "Select User", + text = stringResource(R.string.select_user), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index 37bc69c0..c4fb035e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -67,7 +67,7 @@ fun UserList( ListItem( enabled = true, selected = false, - headlineContent = { Text(text = "Add User") }, + headlineContent = { Text(text = stringResource(R.string.add_user)) }, leadingContent = { Icon( imageVector = Icons.Default.Add, @@ -84,7 +84,7 @@ fun UserList( ListItem( enabled = true, selected = false, - headlineContent = { Text(text = "Switch servers") }, + headlineContent = { Text(text = stringResource(R.string.switch_servers)) }, leadingContent = { Text( text = stringResource(R.string.fa_arrow_left_arrow_right), @@ -102,11 +102,14 @@ fun UserList( title = user.name ?: user.id.toString(), dialogItems = listOf( - DialogItem("Switch", R.string.fa_arrow_left_arrow_right) { + DialogItem( + stringResource(R.string.switch_user), + R.string.fa_arrow_left_arrow_right, + ) { onSwitchUser.invoke(user) }, DialogItem( - "Delete", + stringResource(R.string.delete), Icons.Default.Delete, Color.Red.copy(alpha = .8f), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index c6ec4bb5..e447758f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -1,16 +1,8 @@ package com.github.damontecres.wholphin.util -import com.github.damontecres.wholphin.ui.main.HomeSection import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -val supportedHomeSection = - setOf( - HomeSection.LATEST_MEDIA, - HomeSection.NEXT_UP, - HomeSection.RESUME, - ) - val supportItemKinds = setOf( BaseItemKind.MOVIE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index 3c411724..0afdca90 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -48,7 +48,7 @@ class CrashReportSender : ReportSender { val api = createJellyfin { this.context = context - clientInfo = AppModule.clientInfo() + clientInfo = AppModule.clientInfo(context) deviceInfo = AppModule.deviceInfo(context) apiClientFactory = okHttpFactory socketConnectionFactory = okHttpFactory diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt index 7076276a..94595b0a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.util import android.os.Build +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.github.damontecres.wholphin.R @@ -11,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import java.time.LocalDate @@ -164,3 +166,15 @@ fun formatBytes( } return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) } + +@get:StringRes +val MediaSegmentType.stringRes: Int + get() = + when (this) { + MediaSegmentType.UNKNOWN -> R.string.unknown + MediaSegmentType.COMMERCIAL -> R.string.commercial + MediaSegmentType.PREVIEW -> R.string.preview + MediaSegmentType.RECAP -> R.string.recap + MediaSegmentType.OUTRO -> R.string.outro + MediaSegmentType.INTRO -> R.string.intro + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt index 02160e65..1327b6f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt @@ -10,31 +10,77 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.preferences.ThemeSongVolume +import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.client.extensions.universalAudioApi +import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton /** * Simple service to play theme song music */ +@OptIn(UnstableApi::class) @Singleton class ThemeSongPlayer @Inject constructor( @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val api: ApiClient, ) { - private var player: Player? = null + private val player: Player by lazy { + ExoPlayer + .Builder(context) + .setMediaSourceFactory( + DefaultMediaSourceFactory( + OkHttpDataSource.Factory(authOkHttpClient), + ), + ).build() + } - @OptIn(UnstableApi::class) - fun play( + suspend fun playThemeFor( + itemId: UUID, volume: ThemeSongVolume, + ): Boolean = + withContext(Dispatchers.IO) { + if (volume == ThemeSongVolume.DISABLED || volume == ThemeSongVolume.UNRECOGNIZED) { + return@withContext false + } + val themeSongs by api.libraryApi.getThemeSongs(itemId) + return@withContext themeSongs.items.randomOrNull()?.let { theme -> + val url = + api.universalAudioApi.getUniversalAudioStreamUrl( + theme.id, + container = + listOf( + Codec.Audio.OPUS, + Codec.Audio.MP3, + Codec.Audio.AAC, + Codec.Audio.FLAC, + ), + ) + Timber.v("Found theme song for $itemId") + withContext(Dispatchers.Main) { + play(volume, url) + } + true + } ?: false + } + + private fun play( + volumeLevel: ThemeSongVolume, url: String, ) { stop() val volumeLevel = - when (volume) { + when (volumeLevel) { ThemeSongVolume.UNRECOGNIZED, ThemeSongVolume.DISABLED, -> return @@ -45,24 +91,16 @@ class ThemeSongPlayer ThemeSongVolume.HIGH -> .5f ThemeSongVolume.HIGHEST -> 75f } - val player = - ExoPlayer - .Builder(context) - .setMediaSourceFactory( - DefaultMediaSourceFactory( - OkHttpDataSource.Factory(authOkHttpClient), - ), - ).build() - .apply { - this.volume = volumeLevel - playWhenReady = true - } - player.setMediaItem(MediaItem.fromUri(url)) - player.prepare() - this.player = player + player.apply { + volume = volumeLevel + setMediaItem(MediaItem.fromUri(url)) + prepare() + play() + } } fun stop() { - player?.release() + Timber.v("Stopping theme song") + player.stop() } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f27f146a..f6023e49 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -3,6 +3,11 @@ syntax = "proto3"; option java_package = "com.github.damontecres.wholphin.preferences"; option java_multiple_files = true; +enum ShowNextUpWhen{ + END_OF_PLAYBACK = 0; + DURING_CREDITS = 1; +} + enum SkipSegmentBehavior{ IGNORE = 0; AUTO_SKIP = 1; @@ -52,6 +57,7 @@ message PlaybackPreferences { PlaybackOverrides overrides = 15; int64 pass_out_protection_ms = 16; PrefContentScale global_content_scale = 17; + ShowNextUpWhen show_next_up_when = 18; } message HomePagePreferences{ diff --git a/app/src/main/res/values/preferences.xml b/app/src/main/res/values/preferences.xml index bc8574f4..1f7ba4d4 100644 --- a/app/src/main/res/values/preferences.xml +++ b/app/src/main/res/values/preferences.xml @@ -1,5 +1,53 @@ + preference.update.threshold + preference.update.lastTimestamp + + Device supports AC3/Dolby Digital + Advanced Settings + Advanced UI + Application Theme + Automatically check for updates + Delay before playing next up + Auto play next up + Check for updates + Applies to TV Series only + + Direct play ASS subtitles + Direct play PGS subtitles + Always downmix to stereo + Use FFmpeg decoder module + Default content scale + Install update + Installed version + Max items on home page rows + Choose the default items to show, others will be hidden + Customize Navigation Drawer Items + Click to switch pages + Switch nav drawer pages on focus + Passout Protection + Play theme music + Playback overrides + Remember selected tabs + Enable rewatching in next up + Seek bar steps + Useful for debugging + Send app logs to current server + Will try to send to last connected server + Send Crash Reports + Settings + Skip back when resuming playback + Skip back + Skip commercials behavior + Skip forward + Skip intro behavior + Skip outro behavior + Skip previews behavior + Skip recap behavior + Update available + URL used to check for app updates + Update URL + Disabled Lowest @@ -37,4 +85,9 @@ Never use FFmpeg decoders + + At the end of playback + During end credits/outro + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e51cc61..7e6ac782 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,104 +1,157 @@ - Wholphin + Wholphin + Wholphin - Recently added media - My media - My media (small) - Continue watching - Continue listening - Continue reading - Active recordings - Next up - Live TV - None - Search - Resume - Restart - Play - More + About + Active Recordings + Favorite + Add Server + Add User + Audio + Birthplace + Bitrate + Born + Cancel Recording + Cancel Series Recording + Cancel + Chapters + Choose %1$s + Clear image cache + Collection + Collections + Commercial + Community Rating + An error occurred! Press the button to send logs to your server. + Confirm + Continue watching + Critic Rating + %.1f seconds + Default + Delete + Died + Directed by %1$s + Director + Disabled + Discovered Servers + + Downloading… + Enabled + Enter Server IP or URL including port + Episodes + Error loading collection %1$s + External + Favorites + Size + Forced + Genres + Go to series + Go To + Hide playback controls + Hide debug info + Hide + Home + Immediate + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Library + License information + Live TV + Loading… + Mark entire series as played? + Mark entire series as unplayed? Mark as unwatched Mark as watched - Cancel - Save - Wholphin - Interface - Playback - About - Advanced Settings - Advanced UI - Install update - Installed version - Show - Hide - Enabled - Disabled - Skip forward - Skip back - Hide playback controls - Seek bar steps - Max items on home page rows - Enable rewatching in next up - Studios - Video - Audio - Genres - Play theme music - License information + Max bitrate + More like this + More + Movies + Name + Next Up + No data + No results + No scheduled recordings + No update available + None + Outro + Path + People + Play Count + Play from here + Play Show playback debug info - Delay before playing next up - Auto play next up - - - Name - Random - Date Episode Added + Playback Speed + Playback + Playlist + Preview + User Profile Settings + Queue + Recap + Recently added in %1$s + Recently added + Recently Recorded + Recently Released + Recommended + Record Program + Record Series + Unfavorite + Restart + Resume + Save + + Search + Searching… + Select Server + Select User + Show debug info + Show next up + Show + Shuffle + Skip Date Added + Date Episode Added Date Played Date Released - Skip back when resuming playback - preference.update.threshold - preference.update.lastTimestamp - Confirm - Remember selected tabs - Director - Max bitrate - Application Theme - Skip recap behavior - Skip previews behavior - Skip commercials behavior - Skip outro behavior - Skip intro behavior - Check for updates - Automatically check for updates - Update URL - URL used to check for app updates - Updates - No update available - Clear image cache - Shuffle - - Device supports AC3/Dolby Digital - Always downmix to stereo - Playback overrides - Direct play ASS subtitles - Direct play PGS subtitles + Name + Random + Studios + Submit + Download is taking a long time, you may need to restart playback + Subtitle + Subtitles + Suggestions + Switch servers + Switch + Top Rated Unwatched + Trailer Trailers - Passout Protection - More like this - Unfavorite - Favorite - Favorites - Customize Navigation Drawer Items - User Profile Settings - Choose the default items to show, others will be hidden - Applies to TV Series only - Send Crash Reports - Will try to send to last connected server - Send app logs to current server - Useful for debugging - Default content scale - Use FFmpeg decoder module - Switch nav drawer pages on focus - Click to switch pages + DVR Schedule + Guide + Season + Seasons + TV Shows + Interface + Unknown + Updates + Version + Video Scale + Video + Watch live + %1$d years old + + + %d downloads + %d download + %d downloads + + + %d hours + %d hour + %d hours + + + %d seconds + %d second + %d seconds +