From 715dc1abda9928265137b648144ed26ec45a8a5a Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 1 Nov 2025 19:25:19 -0400 Subject: [PATCH 1/8] Fix network on main during letter position lookups (#136) Maybe fixes #135 Yet another network-on-main exception fix. --- .../ui/components/CollectionFolderGrid.kt | 40 ++++++++++--------- .../wholphin/ui/components/GenreCardGrid.kt | 23 ++++++----- .../wholphin/ui/detail/CardGrid.kt | 7 +++- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 0a7e1ff6..8d5139ca 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 @@ -158,26 +158,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 + } } } 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/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 9a7d3d77..bea5d281 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 @@ -59,7 +59,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 @@ -350,7 +352,10 @@ fun CardGrid( 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) From d76f829804c5cc8186d9a5fb2dc9d76a5a450b5c Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 2 Nov 2025 10:51:59 -0500 Subject: [PATCH 2/8] Option to show next up during credits/outros (#130) Adds an option to the show the next up during the end credits (outro) instead of at the very end of the video. The next up pushes up from the bottom and the video scales down to fit above. Still a WIP though ### Sample screenshot next_up_credits Large --- .../wholphin/preferences/AppPreference.kt | 26 ++++++- .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/ui/playback/PlaybackPage.kt | 17 +++-- .../wholphin/ui/playback/PlaybackViewModel.kt | 67 +++++++++++-------- app/src/main/proto/WholphinDataStore.proto | 6 ++ app/src/main/res/values/preferences.xml | 5 ++ app/src/main/res/values/strings.xml | 2 + 7 files changed, 87 insertions(+), 37 deletions(-) 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..5ef7a763 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 @@ -602,6 +602,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 +626,6 @@ val basicPreferences = listOf( AppPreference.HomePageItems, AppPreference.RewatchNextUp, - AppPreference.CombineContinueNext, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.ThemeColors, @@ -625,11 +637,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 +682,9 @@ val advancedPreferences = title = R.string.ui_interface, preferences = listOf( + AppPreference.CombineContinueNext, 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/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 50a1d81b..ec9dc464 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 @@ -69,9 +69,11 @@ 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 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 +164,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 +216,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 @@ -418,7 +419,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 +477,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..d1b523a6 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 @@ -33,6 +33,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 +98,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 +154,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 +240,7 @@ class PlaybackViewModel ): Boolean = withContext(Dispatchers.IO) { Timber.i("Playing ${base.id}") + autoSkippedSegments.clear() if (base.type !in supportItemKinds) { showToast( context, @@ -358,11 +355,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 +659,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 } 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..6e38778a 100644 --- a/app/src/main/res/values/preferences.xml +++ b/app/src/main/res/values/preferences.xml @@ -37,4 +37,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..f2ca9395 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,5 +100,7 @@ Use FFmpeg decoder module Switch nav drawer pages on focus Click to switch pages + Show next up + Next Up From fe0f96d4137d2651481dbdbb63682a6f2df35a79 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:06:06 -0500 Subject: [PATCH 3/8] Revert nav drawer on focus #127 (#140) Temporarily reverts #127 and reopens #95 It needs more refinement. --- .../github/damontecres/wholphin/preferences/AppPreference.kt | 3 ++- .../java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 5ef7a763..579c4e75 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 @@ -683,7 +683,8 @@ val advancedPreferences = preferences = listOf( AppPreference.CombineContinueNext, - AppPreference.NavDrawerSwitchOnFocus, + // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 +// AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, ), ), 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..bc18b47c 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) From 78322ab0a503f51de055ad6546eec60091d3db2b Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:28:18 -0500 Subject: [PATCH 4/8] Fix deserialization error during login (#145) Fixes #144 The error was occurring when querying for server information. The endpoint used returns information about previous installations and seems some may be missing a field marked as required in the API. This causes the deserialization error. Since the query is just to get the server's name for the UI, we can use the public server info endpoint instead and also wrap it in a try-catch since it's not absolutely required. Additionally, this PR adds exceptions for cases when necessary information is not available (user id, access token, etc). --- .../wholphin/data/ServerRepository.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) 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") } } From d658ec16f3b2be411e57303e72ce67133453cb41 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 2 Nov 2025 17:33:51 -0500 Subject: [PATCH 5/8] Release v0.2.5 From 08577b8a31642850a32a5b46c5cf0fa17873f9bd Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:50:47 -0500 Subject: [PATCH 6/8] Play theme song for movies (#149) Closes #133 Fixes #142 Theme songs will play for both TV shows and movies, if available. Also, if there are multiple theme songs, one will be picked randomly instead of always playing the first. Additionally, the music now waits to start until the UI has loaded. --- .../wholphin/ui/detail/movie/MovieDetails.kt | 20 +++-- .../ui/detail/movie/MovieViewModel.kt | 27 ++++++- .../ui/detail/series/SeriesDetails.kt | 19 ++--- .../ui/detail/series/SeriesOverview.kt | 18 ++--- .../ui/detail/series/SeriesViewModel.kt | 30 +------ .../wholphin/util/ThemeSongPlayer.kt | 78 ++++++++++++++----- 6 files changed, 121 insertions(+), 71 deletions(-) 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..11ce4ba4 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, @@ -157,7 +167,7 @@ fun MovieDetails( 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 = { @@ -203,7 +213,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/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index d51d9523..82671199 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 @@ -12,15 +12,18 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -40,9 +43,10 @@ class MovieViewModel @Inject constructor( api: ApiClient, - val navigationManager: NavigationManager, + private val navigationManager: NavigationManager, val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, + private val themeSongPlayer: ThemeSongPlayer, ) : LoadingItemViewModel(api) { private lateinit var itemId: UUID @@ -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/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 85ff5a9d..fb2f7a04 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 @@ -83,15 +83,6 @@ fun SeriesDetails( modifier: Modifier = Modifier, viewModel: SeriesViewModel = hiltViewModel(), ) { - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { - viewModel.release() - } - } LaunchedEffect(Unit) { viewModel.init(preferences, destination.itemId, destination.item, null) } @@ -113,6 +104,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, 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..75ea4555 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 @@ -72,15 +72,6 @@ fun SeriesOverview( viewModel: SeriesViewModel = hiltViewModel(), initialSeasonEpisode: SeasonEpisodeIds? = null, ) { - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { - viewModel.release() - } - } val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } @@ -168,6 +159,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, 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..0937006d 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 @@ -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() } } } 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() } } From 40267be633e76828cb2e7184c01e52fb0428f914 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:15:41 -0500 Subject: [PATCH 7/8] Remember library/collection sorting (#150) Closes #148 Closes #83 Remember the chosen sort order & direction per user profile on device. Also adds a few more sort options. This is only saved on the device and not saved/loaded from the server. --- .../6.json | 303 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 22 +- .../wholphin/data/LibraryDisplayInfoDao.kt | 29 ++ .../wholphin/data/model/LibraryDisplayInfo.kt | 40 +++ .../wholphin/hilt/DatabaseModule.kt | 5 + .../ui/components/CollectionFolderGrid.kt | 82 +++-- .../wholphin/ui/data/SortAndDirection.kt | 15 + .../wholphin/ui/detail/ItemViewModel.kt | 20 +- .../wholphin/ui/detail/PlaylistDetails.kt | 2 +- .../ui/detail/movie/MovieViewModel.kt | 8 +- .../ui/detail/series/SeriesViewModel.kt | 8 +- app/src/main/res/values/strings.xml | 3 + 12 files changed, 486 insertions(+), 51 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/6.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt 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/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/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/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 8d5139ca..38f34a07 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 @@ -26,8 +26,11 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +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 @@ -62,6 +65,8 @@ class CollectionFolderViewModel @Inject constructor( api: ApiClient, + private val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Loading) val pager = MutableLiveData>(listOf()) @@ -71,7 +76,7 @@ class CollectionFolderViewModel fun init( itemId: UUID?, potential: BaseItem?, - sortAndDirection: SortAndDirection, + initialSortAndDirection: SortAndDirection?, recursive: Boolean, filter: GetItemsFilter, ): Job = @@ -82,12 +87,41 @@ class CollectionFolderViewModel ) + 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 +160,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, ), ) @@ -198,18 +240,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() @@ -226,11 +264,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, 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/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/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 54551834..49538cca 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 @@ -102,7 +102,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, 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 82671199..a27507ca 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 @@ -48,17 +48,14 @@ class MovieViewModel 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, @@ -124,7 +121,6 @@ class MovieViewModel } } } - } fun setWatched(played: Boolean) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { 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 0937006d..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 @@ -84,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 @@ -263,7 +263,7 @@ class SeriesViewModel if (listIndex != null) { refreshEpisode(itemId, listIndex) } else { - fetchItem(seriesId, null) + fetchItem(seriesId) } } @@ -272,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) } @@ -284,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2ca9395..67fd8735 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,5 +102,8 @@ Click to switch pages Show next up Next Up + Community Rating + Critic Rating + Play Count From 2840fa3f2ac1c698715ef7d59037c009a99085b1 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:38:12 -0500 Subject: [PATCH 8/8] Extract strings into resources (#151) Move all strings into resource files to make future translating easier No user facing changes in this PR --- .../damontecres/wholphin/hilt/AppModule.kt | 7 +- .../wholphin/preferences/AppPreference.kt | 49 +++- .../wholphin/ui/cards/ChapterRow.kt | 4 +- .../wholphin/ui/cards/PersonRow.kt | 4 +- .../ui/components/CollectionFolderGrid.kt | 9 +- .../wholphin/ui/components/Dialogs.kt | 20 +- .../wholphin/ui/components/ErrorMessage.kt | 6 +- .../wholphin/ui/components/LoadingRow.kt | 6 +- .../ui/components/RecommendedMovie.kt | 12 +- .../ui/components/RecommendedTvShow.kt | 14 +- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 32 ++- .../wholphin/ui/detail/CardGrid.kt | 28 +- .../ui/detail/CollectionFolderLiveTv.kt | 6 +- .../ui/detail/CollectionFolderMovie.kt | 10 +- .../wholphin/ui/detail/CollectionFolderTv.kt | 9 +- .../wholphin/ui/detail/DetailUtils.kt | 32 ++- .../wholphin/ui/detail/FavoritesPage.kt | 9 +- .../wholphin/ui/detail/PersonPage.kt | 28 +- .../wholphin/ui/detail/PlaylistDetails.kt | 9 +- .../wholphin/ui/detail/livetv/DvrSchedule.kt | 6 +- .../ui/detail/livetv/LiveTvViewModel.kt | 6 +- .../ui/detail/livetv/ProgramDialog.kt | 26 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 9 +- .../ui/detail/movie/MovieDetailsHeader.kt | 4 +- .../ui/detail/movie/MovieViewModel.kt | 6 +- .../ui/detail/series/FocusedEpisodeFooter.kt | 2 +- .../ui/detail/series/SeriesDetails.kt | 24 +- .../ui/detail/series/SeriesOverview.kt | 14 +- .../ui/detail/series/SeriesOverviewContent.kt | 6 +- .../damontecres/wholphin/ui/main/HomePage.kt | 20 +- .../wholphin/ui/main/HomeSection.kt | 30 --- .../wholphin/ui/main/HomeViewModel.kt | 17 +- .../wholphin/ui/main/SearchPage.kt | 16 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 6 +- .../ui/playback/DownloadSubtitlesDialog.kt | 17 +- .../wholphin/ui/playback/Models.kt | 6 +- .../wholphin/ui/playback/NextUpEpisode.kt | 4 +- .../wholphin/ui/playback/PlaybackControls.kt | 20 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 12 +- .../wholphin/ui/playback/PlaybackPage.kt | 5 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 3 +- .../ui/preferences/PreferencesContent.kt | 8 +- .../wholphin/ui/setup/InstallUpdatePage.kt | 8 +- .../wholphin/ui/setup/ServerList.kt | 10 +- .../wholphin/ui/setup/SwitchServerContent.kt | 12 +- .../wholphin/ui/setup/SwitchUserContent.kt | 4 +- .../damontecres/wholphin/ui/setup/UserList.kt | 11 +- .../damontecres/wholphin/util/Constants.kt | 8 - .../wholphin/util/CrashReportSenderFactory.kt | 2 +- .../damontecres/wholphin/util/Formatting.kt | 14 + app/src/main/res/values/preferences.xml | 48 ++++ app/src/main/res/values/strings.xml | 244 +++++++++++------- 52 files changed, 616 insertions(+), 306 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeSection.kt 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/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 579c4e75..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 -> 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 38f34a07..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,6 +28,7 @@ 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 @@ -46,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 @@ -65,6 +69,7 @@ class CollectionFolderViewModel @Inject constructor( api: ApiClient, + @param:ApplicationContext private val context: Context, private val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, ) : ItemViewModel(api) { @@ -83,7 +88,7 @@ class CollectionFolderViewModel viewModelScope.launch( LoadingExceptionHandler( loading, - "Error loading collection $itemId", + context.getString(R.string.error_loading_collection, itemId), ) + Dispatchers.IO, ) { if (itemId != null) { @@ -292,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/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/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index bea5d281..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 @@ -298,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), ) @@ -326,6 +327,8 @@ fun CardGrid( } } } + val context = LocalContext.current + val letters = context.getString(R.string.jump_letters) // Letters val currentLetter = remember(focusedIndex) { @@ -344,10 +347,11 @@ fun CardGrid( null } } - ?: LETTERS[0] + ?: letters[0] } if (showLetterButtons && pager.isNotEmpty()) { AlphabetButtons( + letters = letters, currentLetter = currentLetter, modifier = Modifier.align(Alignment.CenterVertically), letterClicked = { letter -> @@ -404,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 @@ -428,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() @@ -452,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/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 49538cca..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 @@ -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 e26dd5da..d69e6eab 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.detail.series.SeasonEpisode @@ -16,6 +18,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.launch import kotlinx.coroutines.sync.Mutex @@ -45,6 +48,7 @@ const val MAX_HOURS = 48L class LiveTvViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, ) : ViewModel() { @@ -169,7 +173,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 11ce4ba4..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 @@ -150,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(), ) @@ -162,6 +162,7 @@ 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, @@ -172,7 +173,10 @@ fun MovieDetails( 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, @@ -188,6 +192,7 @@ fun MovieDetails( )?.let { source -> chooseVersion = chooseStream( + context = context, streams = source.mediaStreams.orEmpty(), type = type, onClick = { trackIndex -> 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 a27507ca..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 @@ -25,6 +27,7 @@ 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 @@ -43,6 +46,7 @@ class MovieViewModel @Inject constructor( api: ApiClient, + @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, @@ -79,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() 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 fb2f7a04..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,6 +85,7 @@ fun SeriesDetails( modifier: Modifier = Modifier, viewModel: SeriesViewModel = hiltViewModel(), ) { + val context = LocalContext.current LaunchedEffect(Unit) { viewModel.init(preferences, destination.itemId, destination.item, null) } @@ -136,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) @@ -146,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(), ) @@ -161,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 }, @@ -343,7 +348,7 @@ fun SeriesDetailsContent( } item { ItemRow( - title = "Seasons", + title = stringResource(R.string.tv_seasons), items = seasons, onClickItem = { position = SEASONS_ROW @@ -444,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(), ) @@ -487,6 +492,7 @@ fun SeriesDetailsHeader( } fun buildDialogForSeason( + context: Context, s: BaseItem, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, @@ -494,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 75ea4555..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,6 +74,7 @@ fun SeriesOverview( viewModel: SeriesViewModel = hiltViewModel(), initialSeasonEpisode: SeasonEpisodeIds? = null, ) { + val context = LocalContext.current val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } @@ -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/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 bc18b47c..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 @@ -324,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, @@ -345,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, @@ -413,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 ec9dc464..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 @@ -72,6 +74,7 @@ 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 @@ -411,7 +414,7 @@ fun PlaybackPage( modifier = Modifier.focusRequester(focusRequester), ) { Text( - text = "Skip ${segment.type.serialName}", + text = stringResource(R.string.skip) + " " + stringResource(segment.type.stringRes), ) } } 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 d1b523a6..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 @@ -963,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/res/values/preferences.xml b/app/src/main/res/values/preferences.xml index 6e38778a..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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67fd8735..7e6ac782 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,109 +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 - Show next up - Next Up - Community Rating - Critic Rating - Play Count + 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 +