Merge branch 'main' into develop/epg-fix

This commit is contained in:
Damontecres 2025-11-03 15:43:45 -05:00
commit 72b3fac44c
No known key found for this signature in database
65 changed files with 1370 additions and 501 deletions

View file

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

View file

@ -10,17 +10,21 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser 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 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 org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID import java.util.UUID
@Database( @Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class], entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
version = 5, version = 6,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
AutoMigration(4, 5), AutoMigration(4, 5),
AutoMigration(5, 6),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -30,6 +34,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun itemPlaybackDao(): ItemPlaybackDao abstract fun itemPlaybackDao(): ItemPlaybackDao
abstract fun serverPreferencesDao(): ServerPreferencesDao abstract fun serverPreferencesDao(): ServerPreferencesDao
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
} }
class Converters { class Converters {
@ -38,6 +44,18 @@ class Converters {
@TypeConverter @TypeConverter
fun convertToUUID(str: String): UUID = str.toUUID() 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 { object Migrations {

View file

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

View file

@ -84,13 +84,15 @@ class ServerRepository
} }
Timber.v("Changing user to ${user.name} on ${server.url}") Timber.v("Changing user to ${user.name} on ${server.url}")
apiClient.update(baseUrl = server.url, accessToken = user.accessToken) apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
val userDto = val userDto by apiClient.userApi.getCurrentUser()
apiClient.userApi val updatedServer =
.getCurrentUser() try {
.content val sysInfo by apiClient.systemApi.getPublicSystemInfo()
val sysInfo by apiClient.systemApi.getSystemInfo() server.copy(name = sysInfo.serverName)
} catch (ex: Exception) {
val updatedServer = server.copy(name = sysInfo.serverName) Timber.w(ex, "Exception fetching public system info")
server
}
var updatedUser = var updatedUser =
user.copy( user.copy(
id = userDto.id, id = userDto.id,
@ -171,8 +173,14 @@ class ServerRepository
} }
if (user != null) { if (user != null) {
changeUser(server, user) 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")
} }
} }

View file

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

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.provider.Settings import android.provider.Settings
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -47,9 +48,11 @@ annotation class IoCoroutineScope
object AppModule { object AppModule {
@Provides @Provides
@Singleton @Singleton
fun clientInfo(): ClientInfo = fun clientInfo(
@ApplicationContext context: Context,
): ClientInfo =
ClientInfo( ClientInfo(
name = "Wholphin", name = context.getString(R.string.app_name),
version = BuildConfig.VERSION_NAME, version = BuildConfig.VERSION_NAME,
) )

View file

@ -9,6 +9,7 @@ import androidx.room.Room
import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao 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.Migrations
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -51,6 +52,10 @@ object DatabaseModule {
@Singleton @Singleton
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
@Provides
@Singleton
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -70,7 +70,11 @@ sealed interface AppPreference<T> {
}, },
summarizer = { value -> summarizer = { value ->
if (value != null) { if (value != null) {
"$value seconds" WholphinApplication.instance.resources.getQuantityString(
R.plurals.seconds,
value.toInt(),
value.toInt(),
)
} else { } else {
null null
} }
@ -95,7 +99,11 @@ sealed interface AppPreference<T> {
}, },
summarizer = { value -> summarizer = { value ->
if (value != null) { if (value != null) {
"$value seconds" WholphinApplication.instance.resources.getQuantityString(
R.plurals.seconds,
value.toInt(),
value.toInt(),
)
} else { } else {
null null
} }
@ -137,7 +145,14 @@ sealed interface AppPreference<T> {
setter = { prefs, value -> setter = { prefs, value ->
prefs.updatePlaybackPreferences { controllerTimeoutMs = 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 = val SeekBarSteps =
@ -243,7 +258,7 @@ sealed interface AppPreference<T> {
}, },
summarizer = { value -> summarizer = { value ->
if (value == 0L) { if (value == 0L) {
"Disabled" WholphinApplication.instance.getString(R.string.disabled)
} else { } else {
"${value}s" "${value}s"
} }
@ -262,10 +277,16 @@ sealed interface AppPreference<T> {
prefs.updatePlaybackPreferences { autoPlayNextDelaySeconds = value } prefs.updatePlaybackPreferences { autoPlayNextDelaySeconds = value }
}, },
summarizer = { value -> summarizer = { value ->
if (value == 0L) { if (value == null) {
"Immediate" ""
} else if (value == 0L) {
WholphinApplication.instance.getString(R.string.immediate)
} else { } else {
"$value seconds" WholphinApplication.instance.resources.getQuantityString(
R.plurals.seconds,
value.toInt(),
value.toInt(),
)
} }
}, },
) )
@ -284,10 +305,16 @@ sealed interface AppPreference<T> {
} }
}, },
summarizer = { value -> summarizer = { value ->
if (value == 0L) { if (value == null) {
"Disabled" ""
} else if (value == 0L) {
WholphinApplication.instance.getString(R.string.disabled)
} else { } else {
"$value hours" WholphinApplication.instance.resources.getQuantityString(
R.plurals.hours,
value.toInt(),
value.toInt(),
)
} }
}, },
) )
@ -485,7 +512,7 @@ sealed interface AppPreference<T> {
val SkipCommercials = val SkipCommercials =
AppChoicePreference<SkipSegmentBehavior>( AppChoicePreference<SkipSegmentBehavior>(
title = R.string.skip_comercials_behavior, title = R.string.skip_commercials_behavior,
defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP,
getter = { it.playbackPreferences.skipCommercials }, getter = { it.playbackPreferences.skipCommercials },
setter = { prefs, value -> setter = { prefs, value ->
@ -602,6 +629,19 @@ sealed interface AppPreference<T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.nav_drawer_switch_on_focus_summary_off, summaryOff = R.string.nav_drawer_switch_on_focus_summary_off,
) )
val ShowNextUpTiming =
AppChoicePreference<ShowNextUpWhen>(
title = R.string.show_next_up_when,
defaultValue = ShowNextUpWhen.END_OF_PLAYBACK,
getter = { it.playbackPreferences.showNextUpWhen },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { showNextUpWhen = value }
},
displayValues = R.array.show_next_up_when_options,
indexToValue = { ShowNextUpWhen.forNumber(it) },
valueToIndex = { it.number },
)
} }
} }
@ -613,7 +653,6 @@ val basicPreferences =
listOf( listOf(
AppPreference.HomePageItems, AppPreference.HomePageItems,
AppPreference.RewatchNextUp, AppPreference.RewatchNextUp,
AppPreference.CombineContinueNext,
AppPreference.PlayThemeMusic, AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab, AppPreference.RememberSelectedTab,
AppPreference.ThemeColors, AppPreference.ThemeColors,
@ -625,11 +664,17 @@ val basicPreferences =
listOf( listOf(
AppPreference.SkipForward, AppPreference.SkipForward,
AppPreference.SkipBack, AppPreference.SkipBack,
AppPreference.ControllerTimeout, AppPreference.SkipBackOnResume,
),
),
PreferenceGroup(
title = R.string.next_up,
preferences =
listOf(
AppPreference.ShowNextUpTiming,
AppPreference.AutoPlayNextUp, AppPreference.AutoPlayNextUp,
AppPreference.AutoPlayNextDelay, AppPreference.AutoPlayNextDelay,
AppPreference.PassOutProtection, AppPreference.PassOutProtection,
AppPreference.SkipBackOnResume,
), ),
), ),
PreferenceGroup( PreferenceGroup(
@ -664,7 +709,10 @@ val advancedPreferences =
title = R.string.ui_interface, title = R.string.ui_interface,
preferences = preferences =
listOf( listOf(
AppPreference.NavDrawerSwitchOnFocus, AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
), ),
), ),
PreferenceGroup( PreferenceGroup(

View file

@ -43,6 +43,7 @@ class AppPreferencesSerializer
skipRecaps = AppPreference.SkipRecaps.defaultValue skipRecaps = AppPreference.SkipRecaps.defaultValue
passOutProtectionMs = passOutProtectionMs =
AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds
showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue
overrides = overrides =
PlaybackOverrides PlaybackOverrides

View file

@ -11,9 +11,11 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
@Composable @Composable
@ -28,7 +30,7 @@ fun ChapterRow(
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = "Chapters", text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )

View file

@ -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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -34,7 +36,7 @@ fun PersonRow(
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = "People", text = stringResource(R.string.people),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -19,6 +20,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -26,8 +28,12 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -43,6 +49,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -62,6 +69,9 @@ class CollectionFolderViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
@param:ApplicationContext private val context: Context,
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf()) val pager = MutableLiveData<List<BaseItem?>>(listOf())
@ -71,23 +81,52 @@ class CollectionFolderViewModel
fun init( fun init(
itemId: UUID?, itemId: UUID?,
potential: BaseItem?, potential: BaseItem?,
sortAndDirection: SortAndDirection, initialSortAndDirection: SortAndDirection?,
recursive: Boolean, recursive: Boolean,
filter: GetItemsFilter, filter: GetItemsFilter,
): Job = ): Job =
viewModelScope.launch( viewModelScope.launch(
LoadingExceptionHandler( LoadingExceptionHandler(
loading, loading,
"Error loading collection $itemId", context.getString(R.string.error_loading_collection, itemId),
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
if (itemId != null) { 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, sortAndDirection: SortAndDirection,
recursive: Boolean, recursive: Boolean,
filter: GetItemsFilter, filter: GetItemsFilter,
@ -126,17 +165,25 @@ class CollectionFolderViewModel
recursive = recursive, recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) }, excludeItemIds = item?.let { listOf(item.id) },
sortBy = sortBy =
listOf( buildList {
sortAndDirection.sort, add(sortAndDirection.sort)
ItemSortBy.SORT_NAME, if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
ItemSortBy.PRODUCTION_YEAR, add(ItemSortBy.SORT_NAME)
), }
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(ItemSortBy.PRODUCTION_YEAR)
}
},
sortOrder = sortOrder =
listOf( buildList {
sortAndDirection.direction, add(sortAndDirection.direction)
SortOrder.ASCENDING, if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
SortOrder.ASCENDING, add(SortOrder.ASCENDING)
), }
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(SortOrder.ASCENDING)
}
},
fields = SlimItemFields, fields = SlimItemFields,
), ),
) )
@ -158,26 +205,28 @@ class CollectionFolderViewModel
} }
suspend fun positionOfLetter(letter: Char): Int? = suspend fun positionOfLetter(letter: Char): Int? =
item.value?.let { item -> withContext(Dispatchers.IO) {
val includeItemTypes = item.value?.let { item ->
when (item.data.collectionType) { val includeItemTypes =
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) when (item.data.collectionType) {
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
else -> listOf() else -> listOf()
} }
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = item.id, parentId = item.id,
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
nameLessThan = letter.toString(), nameLessThan = letter.toString(),
limit = 0, limit = 0,
enableTotalRecordCount = true, enableTotalRecordCount = true,
recursive = true, recursive = true,
) )
val result by GetItemsRequestHandler.execute(api, request) val result by GetItemsRequestHandler.execute(api, request)
result.totalRecordCount result.totalRecordCount
}
} }
} }
@ -196,18 +245,14 @@ fun CollectionFolderGrid(
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection = initialSortAndDirection: SortAndDirection? = null,
SortAndDirection(
ItemSortBy.SORT_NAME,
SortOrder.ASCENDING,
),
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(itemId, item, initialSortAndDirection, recursive, initialFilter) 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 filter by viewModel.filter.observeAsState(initialFilter)
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
@ -224,11 +269,11 @@ fun CollectionFolderGrid(
preferences, preferences,
item, item,
pager, pager,
sortAndDirection = sortAndDirection, sortAndDirection = sortAndDirection!!,
modifier = modifier, modifier = modifier,
onClickItem = onClickItem, onClickItem = onClickItem,
onSortChange = { onSortChange = {
viewModel.loadResults(it, recursive, filter) viewModel.onSortChange(it, recursive, filter)
}, },
showTitle = showTitle, showTitle = showTitle,
positionCallback = positionCallback, positionCallback = positionCallback,
@ -252,7 +297,7 @@ fun CollectionFolderGridContent(
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, 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 = val sortOptions =
when (item?.data?.collectionType) { when (item?.data?.collectionType) {
CollectionType.MOVIES -> MovieSortOptions CollectionType.MOVIES -> MovieSortOptions

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import android.view.KeyEvent import android.view.KeyEvent
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -462,12 +463,13 @@ fun ConfirmDialogContent(
} }
fun chooseVersionParams( fun chooseVersionParams(
context: Context,
sources: List<MediaSourceInfo>, sources: List<MediaSourceInfo>,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
fromLongClick = false, fromLongClick = false,
title = "Play Version", title = context.getString(R.string.choose_stream, context.getString(R.string.version)),
items = items =
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
val videoStream = 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( fun chooseStream(
context: Context,
streams: List<MediaStream>, streams: List<MediaStream>,
type: MediaStreamType, type: MediaStreamType,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
fromLongClick = false, fromLongClick = false,
title = "Choose ${type.serialName}", // TODO title = context.getString(R.string.choose_stream, context.getString(resourceFor(type))),
items = items =
buildList { buildList {
if (type == MediaStreamType.SUBTITLE) { if (type == MediaStreamType.SUBTITLE) {
add( add(
DialogItem( DialogItem(
headlineContent = { headlineContent = {
Text(text = "None") Text(text = stringResource(R.string.none))
}, },
supportingContent = { supportingContent = {
}, },

View file

@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -50,7 +52,7 @@ fun ErrorMessage(
modifier = modifier.padding(16.dp), modifier = modifier.padding(16.dp),
) { ) {
Text( 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, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
@ -60,7 +62,7 @@ fun ErrorMessage(
}, },
) { ) {
Text( Text(
text = "Send Logs", text = stringResource(R.string.send_app_logs),
) )
} }
message?.let { message?.let {

View file

@ -64,17 +64,18 @@ class GenreViewModel
} }
} }
suspend fun positionOfLetter(letter: Char): Int { suspend fun positionOfLetter(letter: Char): Int =
val request = withContext(Dispatchers.IO) {
GetGenresRequest( val request =
parentId = itemId, GetGenresRequest(
nameLessThan = letter.toString(), parentId = itemId,
limit = 0, nameLessThan = letter.toString(),
enableTotalRecordCount = true, limit = 0,
) enableTotalRecordCount = true,
val result by GetGenresRequestHandler.execute(api, request) )
return result.totalRecordCount val result by GetGenresRequestHandler.execute(api, request)
} return@withContext result.totalRecordCount
}
} }
@Composable @Composable

View file

@ -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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color 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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.BaseItem
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -70,7 +72,7 @@ fun LoadingRow(
-> ->
LoadingRowPlaceholder( LoadingRowPlaceholder(
title = title, title = title,
message = "Loading...", message = stringResource(R.string.loading),
modifier = modifier, modifier = modifier,
) )
@ -88,7 +90,7 @@ fun LoadingRow(
} else if (showIfEmpty) { } else if (showIfEmpty) {
LoadingRowPlaceholder( LoadingRowPlaceholder(
title = title, title = title,
message = "No results", message = stringResource(R.string.no_results),
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -8,6 +8,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences 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.data.RowColumn
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow 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.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -113,11 +113,11 @@ class RecommendedMovieViewModel
rows.forEach { it.init() } rows.forEach { it.init() }
val homeRows = val homeRows =
listOf( listOf(
HomeRow(HomeSection.RESUME, resumeItems, "Continue Watching"), HomeRow(R.string.continue_watching, resumeItems),
HomeRow(HomeSection.LATEST_MEDIA, recentlyReleasedItems, "Recently Released"), HomeRow(R.string.recently_released, recentlyReleasedItems),
HomeRow(HomeSection.LATEST_MEDIA, recentlyAddedItems, "Recently Added"), HomeRow(R.string.recently_added, recentlyAddedItems),
HomeRow(HomeSection.NONE, suggestedItems, "Suggestions"), HomeRow(R.string.suggestions, suggestedItems),
HomeRow(HomeSection.NONE, unwatchedTopRatedItems, "Top Rated Unwatched"), HomeRow(R.string.top_unwatched, unwatchedTopRatedItems),
).filter { it.items.isNotEmpty() } ).filter { it.items.isNotEmpty() }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@RecommendedMovieViewModel.rows.value = homeRows this@RecommendedMovieViewModel.rows.value = homeRows

View file

@ -8,6 +8,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences 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.data.RowColumn
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow 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.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -124,12 +124,12 @@ class RecommendedTvShowViewModel
rows.forEach { it.init() } rows.forEach { it.init() }
val homeRows = val homeRows =
listOf( listOf(
HomeRow(HomeSection.RESUME, resumeItems, "Continue Watching"), HomeRow(R.string.continue_watching, resumeItems),
HomeRow(HomeSection.NEXT_UP, nextUpItems, "Next Up"), HomeRow(R.string.next_up, nextUpItems),
HomeRow(HomeSection.LATEST_MEDIA, recentlyReleasedItems, "Recently Released"), HomeRow(R.string.recently_released, recentlyReleasedItems),
HomeRow(HomeSection.LATEST_MEDIA, recentlyAddedItems, "Recently Added"), HomeRow(R.string.recently_added, recentlyAddedItems),
HomeRow(HomeSection.NONE, suggestedItems, "Suggestions"), HomeRow(R.string.suggestions, suggestedItems),
HomeRow(HomeSection.NONE, unwatchedTopRatedItems, "Top Rated Unwatched"), HomeRow(R.string.top_unwatched, unwatchedTopRatedItems),
).filter { it.items.isNotEmpty() } ).filter { it.items.isNotEmpty() }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@RecommendedTvShowViewModel.rows.value = homeRows this@RecommendedTvShowViewModel.rows.value = homeRows

View file

@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text 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.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
@ -74,24 +76,30 @@ fun MediaSourceInfo(
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = "Name: ${source.name}", text = stringResource(R.string.name) + ": ${source.name}",
) )
Text( Text(
text = "ID: ${source.id}", text = "ID: ${source.id}",
) )
if (showFilePath) { if (showFilePath) {
Text( Text(
text = "Path: ${source.path}", text = stringResource(R.string.path) + ": ${source.path}",
) )
} }
source.size?.let { size -> source.size?.let { size ->
Text( Text(
text = "Size: ${formatBytes(size)}", text = stringResource(R.string.file_size) + ": ${formatBytes(size)}",
) )
} }
source.bitrate?.let { bitrate -> source.bitrate?.let { bitrate ->
Text( Text(
text = "Bitrate: ${formatBytes(bitrate, byteRateSuffixes)}", text =
stringResource(R.string.bitrate) + ": ${
formatBytes(
bitrate,
byteRateSuffixes,
)
}",
) )
} }
source.mediaStreams?.letNotEmpty { streams -> source.mediaStreams?.letNotEmpty { streams ->
@ -109,7 +117,7 @@ fun MediaSourceInfo(
stream.profile?.let(::add) stream.profile?.let(::add)
} }
Text( Text(
text = "Video: " + data.joinToString(" - "), text = stringResource(R.string.video) + ": " + data.joinToString(" - "),
) )
} }
@ -121,10 +129,10 @@ fun MediaSourceInfo(
stream.channelLayout?.let(::add) stream.channelLayout?.let(::add)
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) } stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName) 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(
text = "Audio ${index + 1}: " + data.joinToString(" - "), text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "),
) )
} }
@ -133,12 +141,14 @@ fun MediaSourceInfo(
buildList { buildList {
stream.language?.let { add(languageName(it)) } stream.language?.let { add(languageName(it)) }
stream.codec?.let(::add) stream.codec?.let(::add)
if (stream.isDefault) add("Default") if (stream.isDefault) add(stringResource(R.string.default_track))
if (stream.isForced) add("Forced") if (stream.isForced) add(stringResource(R.string.forced_track))
if (stream.isExternal) add("External") if (stream.isExternal) add(stringResource(R.string.external_track))
} }
Text( Text(
text = "Subtitle ${index + 1}: " + data.joinToString(" - "), text =
stringResource(R.string.subtitle) + " ${index + 1}: " +
data.joinToString(" - "),
) )
} }
} }

View file

@ -10,6 +10,14 @@ data class SortAndDirection(
val direction: SortOrder, val direction: SortOrder,
) { ) {
fun flip() = copy(direction = direction.flip()) 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 fun SortOrder.flip() = if (this == SortOrder.ASCENDING) SortOrder.DESCENDING else SortOrder.ASCENDING
@ -20,6 +28,9 @@ val MovieSortOptions =
ItemSortBy.PREMIERE_DATE, ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED, ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED, ItemSortBy.DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.CRITIC_RATING,
ItemSortBy.PLAY_COUNT,
ItemSortBy.RANDOM, ItemSortBy.RANDOM,
) )
@ -30,6 +41,7 @@ val SeriesSortOptions =
ItemSortBy.DATE_CREATED, ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_LAST_CONTENT_ADDED, ItemSortBy.DATE_LAST_CONTENT_ADDED,
ItemSortBy.DATE_PLAYED, ItemSortBy.DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.RANDOM, 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_LAST_CONTENT_ADDED -> R.string.sort_by_date_episode_added
ItemSortBy.DATE_PLAYED -> R.string.sort_by_date_played ItemSortBy.DATE_PLAYED -> R.string.sort_by_date_played
ItemSortBy.RANDOM -> R.string.sort_by_random 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") else -> throw IllegalArgumentException("Unsupported sort option: $sort")
} }

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button import androidx.tv.material3.Button
@ -59,7 +60,9 @@ import com.github.damontecres.wholphin.ui.playback.isForwardButton
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
private const val DEBUG = false private const val DEBUG = false
@ -296,7 +299,7 @@ fun CardGrid(
// focusedIndex = -1 // focusedIndex = -1
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Text( Text(
text = "No results", text = stringResource(R.string.no_results),
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
@ -324,6 +327,8 @@ fun CardGrid(
} }
} }
} }
val context = LocalContext.current
val letters = context.getString(R.string.jump_letters)
// Letters // Letters
val currentLetter = val currentLetter =
remember(focusedIndex) { remember(focusedIndex) {
@ -342,15 +347,19 @@ fun CardGrid(
null null
} }
} }
?: LETTERS[0] ?: letters[0]
} }
if (showLetterButtons && pager.isNotEmpty()) { if (showLetterButtons && pager.isNotEmpty()) {
AlphabetButtons( AlphabetButtons(
letters = letters,
currentLetter = currentLetter, currentLetter = currentLetter,
modifier = Modifier.align(Alignment.CenterVertically), modifier = Modifier.align(Alignment.CenterVertically),
letterClicked = { letter -> letterClicked = { letter ->
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {
val jumpPosition = letterPosition.invoke(letter) val jumpPosition =
withContext(Dispatchers.IO) {
letterPosition.invoke(letter)
}
Timber.d("Alphabet jump to $jumpPosition") Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) { if (jumpPosition >= 0) {
gridState.scrollToItem(jumpPosition) gridState.scrollToItem(jumpPosition)
@ -399,18 +408,18 @@ fun JumpButton(
} }
} }
private const val LETTERS = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@Composable @Composable
fun AlphabetButtons( fun AlphabetButtons(
letters: String,
currentLetter: Char, currentLetter: Char,
letterClicked: (Char) -> Unit, letterClicked: (Char) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val index = LETTERS.indexOf(currentLetter) val index = letters.indexOf(currentLetter)
LaunchedEffect(currentLetter) { LaunchedEffect(currentLetter) {
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {
val firstVisibleItemIndex = listState.firstVisibleItemIndex val firstVisibleItemIndex = listState.firstVisibleItemIndex
@ -423,19 +432,19 @@ fun AlphabetButtons(
} }
} }
} }
val focusRequesters = remember { List(LETTERS.length) { FocusRequester() } } val focusRequesters = remember { List(letters.length) { FocusRequester() } }
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier =
modifier.focusProperties { modifier.focusProperties {
onEnter = { onEnter = {
focusRequesters[index.coerceIn(0, LETTERS.length - 1)].tryRequestFocus() focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
} }
}, },
) { ) {
items( items(
LETTERS.length, letters.length,
key = { LETTERS[it] }, key = { letters[it] },
) { index -> ) { index ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
@ -447,17 +456,17 @@ fun AlphabetButtons(
contentPadding = PaddingValues(2.dp), contentPadding = PaddingValues(2.dp),
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
letterClicked.invoke(LETTERS[index]) letterClicked.invoke(letters[index])
}, },
) { ) {
val color = val color =
if (!focused && LETTERS[index] == currentLetter) { if (!focused && letters[index] == currentLetter) {
MaterialTheme.colorScheme.tertiary MaterialTheme.colorScheme.tertiary
} else { } else {
LocalContentColor.current LocalContentColor.current
} }
Text( Text(
text = LETTERS[index].toString(), text = letters[index].toString(),
color = color, color = color,
) )
} }

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -33,6 +34,7 @@ import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -96,8 +98,8 @@ fun CollectionFolderLiveTv(
val tabs = val tabs =
listOf( listOf(
TabId("Guide", UUID.randomUUID()), TabId(stringResource(R.string.tv_guide), UUID.randomUUID()),
TabId("DVR Schedule", UUID.randomUUID()), TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()),
) + folders ) + folders
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text 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.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -52,7 +54,13 @@ fun CollectionFolderMovie(
val rememberedTabIndex = val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } 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 focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text 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.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -52,7 +54,12 @@ fun CollectionFolderTv(
val rememberedTabIndex = val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } 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 focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.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 * @param onChooseTracks callback to pick a track for the given type of the item
*/ */
fun buildMoreDialogItems( fun buildMoreDialogItems(
context: Context,
item: BaseItem, item: BaseItem,
series: BaseItem?, series: BaseItem?,
sourceId: UUID?, sourceId: UUID?,
@ -50,7 +52,7 @@ fun buildMoreDialogItems(
buildList { buildList {
add( add(
DialogItem( DialogItem(
"Play", context.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -83,7 +85,7 @@ fun buildMoreDialogItems(
series?.let { series?.let {
add( add(
DialogItem( DialogItem(
"Go to series", context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward, Icons.AutoMirrored.Filled.ArrowForward,
) { ) {
navigateTo( navigateTo(
@ -100,7 +102,10 @@ fun buildMoreDialogItems(
if (sources.size > 1) { if (sources.size > 1) {
add( add(
DialogItem( DialogItem(
"Choose Version", context.getString(
R.string.choose_stream,
context.getString(R.string.version),
),
R.string.fa_file_video, R.string.fa_file_video,
) { ) {
onChooseVersion.invoke() onChooseVersion.invoke()
@ -116,7 +121,10 @@ fun buildMoreDialogItems(
if (audioCount > 1) { if (audioCount > 1) {
add( add(
DialogItem( DialogItem(
"Choose audio", context.getString(
R.string.choose_stream,
context.getString(R.string.audio),
),
R.string.fa_volume_low, R.string.fa_volume_low,
) { ) {
onChooseTracks.invoke(MediaStreamType.AUDIO) onChooseTracks.invoke(MediaStreamType.AUDIO)
@ -126,7 +134,10 @@ fun buildMoreDialogItems(
if (subtitleCount > 0) { if (subtitleCount > 0) {
add( add(
DialogItem( DialogItem(
"Choose subtitles", context.getString(
R.string.choose_stream,
context.getString(R.string.subtitles),
),
R.string.fa_closed_captioning, R.string.fa_closed_captioning,
) { ) {
onChooseTracks.invoke(MediaStreamType.SUBTITLE) onChooseTracks.invoke(MediaStreamType.SUBTITLE)
@ -138,6 +149,7 @@ fun buildMoreDialogItems(
} }
fun buildMoreDialogItemsForHome( fun buildMoreDialogItemsForHome(
context: Context,
item: BaseItem, item: BaseItem,
seriesId: UUID?, seriesId: UUID?,
playbackPosition: Duration, playbackPosition: Duration,
@ -151,7 +163,7 @@ fun buildMoreDialogItemsForHome(
val itemId = item.id val itemId = item.id
add( add(
DialogItem( DialogItem(
"Go To", context.getString(R.string.go_to),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
) { ) {
navigateTo(item.destination()) navigateTo(item.destination())
@ -161,7 +173,7 @@ fun buildMoreDialogItemsForHome(
if (playbackPosition >= 1.seconds) { if (playbackPosition >= 1.seconds) {
add( add(
DialogItem( DialogItem(
"Resume", context.getString(R.string.resume),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -175,7 +187,7 @@ fun buildMoreDialogItemsForHome(
) )
add( add(
DialogItem( DialogItem(
"Restart", context.getString(R.string.restart),
Icons.Default.Refresh, Icons.Default.Refresh,
// iconColor = Color.Green.copy(alpha = .8f), // iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -190,7 +202,7 @@ fun buildMoreDialogItemsForHome(
} else { } else {
add( add(
DialogItem( DialogItem(
"Play", context.getString(R.string.play),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
@ -224,7 +236,7 @@ fun buildMoreDialogItemsForHome(
seriesId?.let { seriesId?.let {
add( add(
DialogItem( DialogItem(
"Go to series", context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward, Icons.AutoMirrored.Filled.ArrowForward,
) { ) {
navigateTo( navigateTo(

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text 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.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences 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() } } val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }

View file

@ -25,21 +25,11 @@ abstract class ItemViewModel(
val api: ApiClient, val api: ApiClient,
) : ViewModel() { ) : ViewModel() {
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
lateinit var itemId: UUID
suspend fun fetchItem( suspend fun fetchItem(itemId: UUID): BaseItem =
itemId: UUID,
potential: BaseItem?,
): BaseItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// val fetchedItem = this@ItemViewModel.itemId = itemId
// 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)
// }
// }
val it = api.userLibraryApi.getItem(itemId).content val it = api.userLibraryApi.getItem(itemId).content
val fetchedItem = BaseItem.from(it, api) val fetchedItem = BaseItem.from(it, api)
return@withContext fetchedItem.let { return@withContext fetchedItem.let {
@ -89,10 +79,8 @@ abstract class LoadingItemViewModel(
open suspend fun fetchAndSetItem(itemId: UUID): BaseItem = open suspend fun fetchAndSetItem(itemId: UUID): BaseItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val fetchedItem = api.userLibraryApi.getItem(itemId).content val item = fetchItem(itemId)
val item = BaseItem.from(fetchedItem, api)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@LoadingItemViewModel.item.value = item
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
return@withContext item return@withContext item

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -258,7 +260,7 @@ fun PersonPageContent(
} }
item { item {
LoadingRow( LoadingRow(
title = rowTitle("Movies", movies), title = rowTitle(stringResource(R.string.movies), movies),
state = movies, state = movies,
rowIndex = MOVIE_ROW, rowIndex = MOVIE_ROW,
position = position, position = position,
@ -271,7 +273,7 @@ fun PersonPageContent(
} }
item { item {
LoadingRow( LoadingRow(
title = rowTitle("Series", series), title = rowTitle(stringResource(R.string.tv_shows), series),
state = series, state = series,
rowIndex = SERIES_ROW, rowIndex = SERIES_ROW,
position = position, position = position,
@ -284,7 +286,7 @@ fun PersonPageContent(
} }
item { item {
LoadingRow( LoadingRow(
title = rowTitle("Episodes", episodes), title = rowTitle(stringResource(R.string.episodes), episodes),
state = episodes, state = episodes,
rowIndex = EPISODE_ROW, rowIndex = EPISODE_ROW,
position = position, position = position,
@ -365,9 +367,14 @@ fun PersonHeader(
val age = if (deathdate == null) birthdate.until(LocalDate.now())?.years else null val age = if (deathdate == null) birthdate.until(LocalDate.now())?.years else null
val text = val text =
if (age != null) { if (age != null) {
"Born: ${formatDate(it)} ($age years old)" stringResource(R.string.born) + ": ${formatDate(it)} (${
stringResource(
R.string.years_old,
age,
)
})"
} else { } else {
"Born: ${formatDate(it)}" stringResource(R.string.born) + ": ${formatDate(it)}"
} }
Text( Text(
text = text, text = text,
@ -379,7 +386,7 @@ fun PersonHeader(
} }
birthPlace?.let { birthPlace?.let {
Text( Text(
text = "Birthplace: $it", text = stringResource(R.string.birthplace) + ": $it",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
@ -390,9 +397,14 @@ fun PersonHeader(
val age = birthdate?.until(it)?.years val age = birthdate?.until(it)?.years
val text = val text =
if (age != null) { if (age != null) {
"Died: ${formatDate(it)} ($age years old)" stringResource(R.string.died) + ": ${formatDate(it)} (${
stringResource(
R.string.years_old,
age,
)
})"
} else { } else {
"Died: ${formatDate(it)}" stringResource(R.string.died) + ": ${formatDate(it)}"
} }
Text( Text(
text = text, text = text,

View file

@ -42,6 +42,8 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -102,7 +104,7 @@ class PlaylistViewModel
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
) { ) {
val playlist = fetchItem(playlistId, null) val playlist = fetchItem(playlistId)
val request = val request =
GetPlaylistItemsRequest( GetPlaylistItemsRequest(
playlistId = playlist.id, playlistId = playlist.id,
@ -123,6 +125,7 @@ fun PlaylistDetails(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaylistViewModel = hiltViewModel(), viewModel: PlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(destination.itemId) viewModel.init(destination.itemId)
} }
@ -170,13 +173,13 @@ fun PlaylistDetails(
items = items =
listOf( listOf(
DialogItem( DialogItem(
"Go to", context.getString(R.string.go_to),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
) { ) {
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
DialogItem( DialogItem(
"Play from here", context.getString(R.string.play_from_here),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
) { ) {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
@ -282,7 +285,7 @@ fun PlaylistDetailsContent(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
Text( Text(
text = playlist.name ?: "Playlist", text = playlist.name ?: stringResource(R.string.playlist),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium, style = MaterialTheme.typography.displayMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View file

@ -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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -31,6 +32,7 @@ import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -202,7 +204,7 @@ fun DvrScheduleContent(
if (activeRecordings.isEmpty() && scheduledRecordings.isEmpty()) { if (activeRecordings.isEmpty() && scheduledRecordings.isEmpty()) {
item { item {
Text( Text(
text = "No scheduled recordings", text = stringResource(R.string.no_scheduled_recordings),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -213,7 +215,7 @@ fun DvrScheduleContent(
if (activeRecordings.isNotEmpty()) { if (activeRecordings.isNotEmpty()) {
item { item {
Text( Text(
text = "Active", text = stringResource(R.string.active_recordings),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View file

@ -1,9 +1,11 @@
package com.github.damontecres.wholphin.ui.detail.livetv package com.github.damontecres.wholphin.ui.detail.livetv
import android.content.Context
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
@ -17,6 +19,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -46,6 +49,7 @@ const val MAX_HOURS = 48L
class LiveTvViewModel class LiveTvViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
@ -70,7 +74,13 @@ class LiveTvViewModel
if (!firstLoad) { if (!firstLoad) {
loading.value = LoadingState.Loading loading.value = LoadingState.Loading
} }
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Could not fetch channels")) { viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Could not fetch channels",
),
) {
val channelData by api.liveTvApi.getLiveTvChannels( val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest( GetLiveTvChannelsRequest(
startIndex = 0, startIndex = 0,
@ -179,7 +189,7 @@ class LiveTvViewModel
startHours = it.toFloat(), startHours = it.toFloat(),
endHours = (it + 1).toFloat(), endHours = (it + 1).toFloat(),
duration = 60.seconds, duration = 60.seconds,
name = "No data", name = context.getString(R.string.no_data),
subtitle = null, subtitle = null,
seasonEpisode = null, seasonEpisode = null,
isRecording = false, isRecording = false,

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@ -30,6 +31,7 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -165,7 +167,14 @@ fun ProgramDialog(
) )
} }
Text( 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(
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( Icon(
imageVector = Icons.Default.PlayArrow, imageVector = Icons.Default.PlayArrow,
contentDescription = "Delete", contentDescription = stringResource(R.string.delete),
tint = Color.Green.copy(alpha = .75f), tint = Color.Green.copy(alpha = .75f),
) )
Text( Text(
text = "Watch live", text = stringResource(R.string.watch_live),
) )
} }
} }

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -109,6 +110,15 @@ fun MovieDetails(
-> LoadingPage() -> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { movie -> item?.let { movie ->
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
MovieDetailsContent( MovieDetailsContent(
preferences = preferences, preferences = preferences,
movie = movie, movie = movie,
@ -118,10 +128,10 @@ fun MovieDetails(
trailers = trailers, trailers = trailers,
similar = similar, similar = similar,
onClickItem = { onClickItem = {
viewModel.navigationManager.navigateTo(it.destination()) viewModel.navigateTo(it.destination())
}, },
onClickPerson = { onClickPerson = {
viewModel.navigationManager.navigateTo( viewModel.navigateTo(
Destination.MediaItem( Destination.MediaItem(
it.id, it.id,
BaseItemKind.PERSON, BaseItemKind.PERSON,
@ -129,7 +139,7 @@ fun MovieDetails(
) )
}, },
playOnClick = { playOnClick = {
viewModel.navigationManager.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
movie.id, movie.id,
it.inWholeMilliseconds, it.inWholeMilliseconds,
@ -140,7 +150,7 @@ fun MovieDetails(
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = movie.name ?: "Unknown", title = movie.name ?: context.getString(R.string.unknown),
overview = movie.data.overview, overview = movie.data.overview,
files = movie.data.mediaSources.orEmpty(), files = movie.data.mediaSources.orEmpty(),
) )
@ -152,17 +162,21 @@ fun MovieDetails(
title = movie.name + " (${movie.data.productionYear ?: ""})", title = movie.name + " (${movie.data.productionYear ?: ""})",
items = items =
buildMoreDialogItems( buildMoreDialogItems(
context = context,
item = movie, item = movie,
watched = movie.data.userData?.played ?: false, watched = movie.data.userData?.played ?: false,
favorite = movie.data.userData?.isFavorite ?: false, favorite = movie.data.userData?.isFavorite ?: false,
series = null, series = null,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.sourceId,
navigateTo = viewModel.navigationManager::navigateTo, navigateTo = viewModel::navigateTo,
onClickWatch = viewModel::setWatched, onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite, onClickFavorite = viewModel::setFavorite,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
chooseVersionParams(movie.data.mediaSources!!) { idx -> chooseVersionParams(
context,
movie.data.mediaSources!!,
) { idx ->
val source = movie.data.mediaSources!![idx] val source = movie.data.mediaSources!![idx]
viewModel.savePlayVersion( viewModel.savePlayVersion(
movie, movie,
@ -178,6 +192,7 @@ fun MovieDetails(
)?.let { source -> )?.let { source ->
chooseVersion = chooseVersion =
chooseStream( chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(), streams = source.mediaStreams.orEmpty(),
type = type, type = type,
onClick = { trackIndex -> onClick = { trackIndex ->
@ -203,7 +218,7 @@ fun MovieDetails(
trailerOnClick = { trailer -> trailerOnClick = { trailer ->
when (trailer) { when (trailer) {
is LocalTrailer -> is LocalTrailer ->
viewModel.navigationManager.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
itemId = trailer.baseItem.id, itemId = trailer.baseItem.id,
item = trailer.baseItem, item = trailer.baseItem,

View file

@ -147,7 +147,7 @@ fun MovieDetailsHeader(
?.joinToString(", ") { it.name!! } ?.joinToString(", ") { it.name!! }
?.let { ?.let {
Text( Text(
text = "Directed by $it", text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -183,7 +183,7 @@ fun MovieDetailsHeader(
?.let { ?.let {
if (it.isNotNullOrBlank()) { if (it.isNotNullOrBlank()) {
TitleValueText( TitleValueText(
"Subtitles", stringResource(R.string.subtitles),
it, it,
modifier = Modifier.widthIn(max = 200.dp), modifier = Modifier.widthIn(max = 200.dp),
) )

View file

@ -1,7 +1,9 @@
package com.github.damontecres.wholphin.ui.detail.movie package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -12,16 +14,20 @@ import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer 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.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -40,21 +46,20 @@ class MovieViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, @param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,
) : LoadingItemViewModel(api) { ) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf()) val chapters = MutableLiveData<List<Chapter>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
fun init(itemId: UUID): Job? { fun init(itemId: UUID): Job? =
this.itemId = itemId viewModelScope.launch(
return viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
loading, loading,
@ -78,7 +83,7 @@ class MovieViewModel
// TODO would be nice to clean up the trailer name // TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "") // ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ") // ?.removePrefix(" - ")
?: "Trailer" ?: context.getString(R.string.trailer)
RemoteTrailer(name, url) RemoteTrailer(name, url)
} }
}.orEmpty() }.orEmpty()
@ -120,7 +125,6 @@ class MovieViewModel
} }
} }
} }
}
fun setWatched(played: Boolean) = fun setWatched(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
@ -181,4 +185,25 @@ class MovieViewModel
} }
} }
} }
fun maybePlayThemeSong(
seriesId: UUID,
playThemeSongs: ThemeSongVolume,
) {
viewModelScope.launchIO {
themeSongPlayer.playThemeFor(seriesId, playThemeSongs)
addCloseable {
themeSongPlayer.stop()
}
}
}
fun release() {
themeSongPlayer.stop()
}
fun navigateTo(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
} }

View file

@ -82,7 +82,7 @@ fun FocusedEpisodeFooter(
?.let { ?.let {
if (it.isNotNullOrBlank()) { if (it.isNotNullOrBlank()) {
TitleValueText( TitleValueText(
"Subtitles", stringResource(R.string.subtitles),
it, it,
modifier = Modifier.widthIn(max = 160.dp), modifier = Modifier.widthIn(max = 160.dp),
) )

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.series package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -33,6 +34,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -83,15 +85,7 @@ fun SeriesDetails(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
) { ) {
LifecycleStartEffect(destination.itemId) { val context = LocalContext.current
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(preferences, destination.itemId, destination.item, null) viewModel.init(preferences, destination.itemId, destination.item, null)
} }
@ -113,6 +107,16 @@ fun SeriesDetails(
-> LoadingPage() -> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { item -> item?.let { item ->
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
val played = item.data.userData?.played ?: false val played = item.data.userData?.played ?: false
SeriesDetailsContent( SeriesDetailsContent(
preferences = preferences, preferences = preferences,
@ -135,7 +139,8 @@ fun SeriesDetails(
onLongClickItem = { season -> onLongClickItem = { season ->
seasonDialog = seasonDialog =
buildDialogForSeason( buildDialogForSeason(
season, context = context,
s = season,
onClickItem = { viewModel.navigateTo(it.destination()) }, onClickItem = { viewModel.navigateTo(it.destination()) },
markPlayed = { played -> markPlayed = { played ->
viewModel.setSeasonWatched(season.id, played) viewModel.setSeasonWatched(season.id, played)
@ -145,7 +150,7 @@ fun SeriesDetails(
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = item.name ?: "Unknown", title = item.name ?: context.getString(R.string.unknown),
overview = item.data.overview, overview = item.data.overview,
files = listOf(), files = listOf(),
) )
@ -160,7 +165,8 @@ fun SeriesDetails(
if (showWatchConfirmation) { if (showWatchConfirmation) {
ConfirmDialog( ConfirmDialog(
title = item.name ?: "", 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 = { onCancel = {
showWatchConfirmation = false showWatchConfirmation = false
}, },
@ -342,7 +348,7 @@ fun SeriesDetailsContent(
} }
item { item {
ItemRow( ItemRow(
title = "Seasons", title = stringResource(R.string.tv_seasons),
items = seasons, items = seasons,
onClickItem = { onClickItem = {
position = SEASONS_ROW position = SEASONS_ROW
@ -443,7 +449,7 @@ fun SeriesDetailsHeader(
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = series.name ?: "Unknown", text = series.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@ -486,6 +492,7 @@ fun SeriesDetailsHeader(
} }
fun buildDialogForSeason( fun buildDialogForSeason(
context: Context,
s: BaseItem, s: BaseItem,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit, markPlayed: (Boolean) -> Unit,
@ -493,26 +500,26 @@ fun buildDialogForSeason(
val items = val items =
buildList { buildList {
add( add(
DialogItem("Go to", Icons.Default.PlayArrow) { DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) {
onClickItem.invoke(s) onClickItem.invoke(s)
}, },
) )
if (s.data.userData?.played == true) { if (s.data.userData?.played == true) {
add( add(
DialogItem("Mark as unplayed", R.string.fa_eye) { DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) {
markPlayed.invoke(false) markPlayed.invoke(false)
}, },
) )
} else { } else {
add( 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) markPlayed.invoke(true)
}, },
) )
} }
} }
return DialogParams( return DialogParams(
title = s.name ?: "Season", title = s.name ?: context.getString(R.string.tv_season),
fromLongClick = true, fromLongClick = true,
items = items, items = items,
) )

View file

@ -13,8 +13,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect 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.BaseItem
import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -72,15 +74,7 @@ fun SeriesOverview(
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
initialSeasonEpisode: SeasonEpisodeIds? = null, initialSeasonEpisode: SeasonEpisodeIds? = null,
) { ) {
LifecycleStartEffect(destination.itemId) { val context = LocalContext.current
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
@ -168,6 +162,15 @@ fun SeriesOverview(
LoadingState.Success -> { LoadingState.Success -> {
series?.let { series -> series?.let { series ->
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
fun buildMoreForEpisode( fun buildMoreForEpisode(
ep: BaseItem, ep: BaseItem,
@ -178,7 +181,8 @@ fun SeriesOverview(
title = series.name + " - " + ep.data.seasonEpisode, title = series.name + " - " + ep.data.seasonEpisode,
items = items =
buildMoreDialogItems( buildMoreDialogItems(
ep, context = context,
item = ep,
watched = ep.data.userData?.played ?: false, watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
series = series, series = series,
@ -208,7 +212,10 @@ fun SeriesOverview(
}, },
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
chooseVersionParams(ep.data.mediaSources!!) { idx -> chooseVersionParams(
context,
ep.data.mediaSources!!,
) { idx ->
val source = ep.data.mediaSources!![idx] val source = ep.data.mediaSources!![idx]
viewModel.savePlayVersion( viewModel.savePlayVersion(
ep, ep,
@ -224,6 +231,7 @@ fun SeriesOverview(
)?.let { source -> )?.let { source ->
chooseVersion = chooseVersion =
chooseStream( chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(), streams = source.mediaStreams.orEmpty(),
type = type, type = type,
onClick = { trackIndex -> onClick = { trackIndex ->
@ -313,7 +321,7 @@ fun SeriesOverview(
episodeList?.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = it.name ?: "Unknown", title = it.name ?: context.getString(R.string.unknown),
overview = it.data.overview, overview = it.data.overview,
files = it.data.mediaSources.orEmpty(), files = it.data.mediaSources.orEmpty(),
) )

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -42,6 +43,7 @@ import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.data.model.aspectRatioFloat
@ -178,7 +180,9 @@ fun SeriesOverviewContent(
.focusRequester(focusRequesters[index]), .focusRequester(focusRequesters[index]),
) { ) {
Text( Text(
text = season.name ?: "Season ${season.data.indexNumber}", text =
season.name
?: (stringResource(R.string.tv_season) + " ${season.data.indexNumber}"),
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
) )
} }

View file

@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository 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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers 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.libraryApi
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi 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.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -88,7 +84,7 @@ class SeriesViewModel
"Error loading series $seriesId", "Error loading series $seriesId",
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
val item = fetchItem(seriesId, potential) val item = fetchItem(seriesId)
val seasons = getSeasons(item) val seasons = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season // If a particular season was requested, fetch those episodes, otherwise get the first season
@ -141,34 +137,14 @@ class SeriesViewModel
/** /**
* If the series has a theme song & app settings allow, play it * If the series has a theme song & app settings allow, play it
*/ */
@OptIn(UnstableApi::class)
fun maybePlayThemeSong( fun maybePlayThemeSong(
seriesId: UUID, seriesId: UUID,
playThemeSongs: ThemeSongVolume, playThemeSongs: ThemeSongVolume,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val themeSongs = api.libraryApi.getThemeSongs(seriesId).content themeSongPlayer.playThemeFor(seriesId, playThemeSongs)
themeSongs.items.firstOrNull()?.let { theme -> addCloseable {
theme.mediaSources?.firstOrNull()?.let { source -> themeSongPlayer.stop()
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()
}
}
}
} }
} }
} }
@ -287,7 +263,7 @@ class SeriesViewModel
if (listIndex != null) { if (listIndex != null) {
refreshEpisode(itemId, listIndex) refreshEpisode(itemId, listIndex)
} else { } else {
fetchItem(seriesId, null) fetchItem(seriesId)
} }
} }
@ -296,7 +272,7 @@ class SeriesViewModel
played: Boolean, played: Boolean,
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
setWatched(seasonId, played, null) setWatched(seasonId, played, null)
val series = fetchItem(seriesId, null) val series = fetchItem(seriesId)
val seasons = getSeasons(series) val seasons = getSeasons(series)
this@SeriesViewModel.seasons.setValueOnMain(seasons) this@SeriesViewModel.seasons.setValueOnMain(seasons)
} }
@ -308,7 +284,7 @@ class SeriesViewModel
} else { } else {
api.playStateApi.markUnplayedItem(seriesId) api.playStateApi.markUnplayedItem(seriesId)
} }
val series = fetchItem(seriesId, null) val series = fetchItem(seriesId)
val seasons = getSeasons(series) val seasons = getSeasons(series)
this@SeriesViewModel.seasons.setValueOnMain(seasons) this@SeriesViewModel.seasons.setValueOnMain(seasons)
} }

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -71,10 +72,20 @@ import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration import kotlin.time.Duration
data class HomeRow( data class HomeRow(
val section: HomeSection, @param:StringRes val titleRes: Int?,
val title: String?,
val items: List<BaseItem?>, val items: List<BaseItem?>,
val title: String? = null, ) {
) constructor(
@StringRes titleRes: Int,
items: List<BaseItem?>,
) : this(titleRes, null, items)
constructor(
title: String,
items: List<BaseItem?>,
) : this(null, title, items)
}
@Composable @Composable
fun HomePage( fun HomePage(
@ -121,6 +132,7 @@ fun HomePage(
onLongClickItem = { onLongClickItem = {
val dialogItems = val dialogItems =
buildMoreDialogItemsForHome( buildMoreDialogItemsForHome(
context = context,
item = it, item = it,
seriesId = it.data.seriesId, seriesId = it.data.seriesId,
playbackPosition = playbackPosition =
@ -242,7 +254,7 @@ fun HomePageContent(
itemsIndexed(homeRows) { rowIndex, row -> itemsIndexed(homeRows) { rowIndex, row ->
if (row.items.isNotEmpty()) { if (row.items.isNotEmpty()) {
ItemRow( ItemRow(
title = row.title ?: stringResource(row.section.nameRes), title = row.title ?: row.titleRes?.let { stringResource(it) } ?: "",
items = row.items, items = row.items,
onClickItem = onClickItem, onClickItem = onClickItem,
cardOnFocus = { isFocused, index -> cardOnFocus = { isFocused, index ->

View file

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

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
@ -15,6 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -40,6 +43,7 @@ import javax.inject.Inject
class HomeViewModel class HomeViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
@ -86,7 +90,7 @@ class HomeViewModel
if (resume.isNotEmpty()) { if (resume.isNotEmpty()) {
add( add(
HomeRow( HomeRow(
section = HomeSection.RESUME, titleRes = R.string.recently_added,
items = resume, items = resume,
), ),
) )
@ -94,7 +98,7 @@ class HomeViewModel
if (nextUp.isNotEmpty()) { if (nextUp.isNotEmpty()) {
add( add(
HomeRow( HomeRow(
section = HomeSection.NEXT_UP, titleRes = R.string.next_up,
items = nextUp, items = nextUp,
), ),
) )
@ -191,10 +195,10 @@ class HomeViewModel
.mapNotNull { view -> .mapNotNull { view ->
val title = val title =
if (view.collectionType == CollectionType.LIVETV) { if (view.collectionType == CollectionType.LIVETV) {
"Recently Recorded" context.getString(R.string.recently_recorded)
} else { } 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 = val viewId =
if (view.collectionType == CollectionType.LIVETV) { if (view.collectionType == CollectionType.LIVETV) {
api.liveTvApi api.liveTvApi
@ -222,9 +226,8 @@ class HomeViewModel
.content .content
.map { BaseItem.from(it, api, true) } .map { BaseItem.from(it, api, true) }
HomeRow( HomeRow(
section = HomeSection.LATEST_MEDIA,
items = latest,
title = title, title = title,
items = latest,
) )
} }
} }

View file

@ -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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -28,6 +30,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
@ -153,6 +156,7 @@ fun SearchPage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(), viewModel: SearchViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val movies by viewModel.movies.observeAsState(SearchResult.NoQuery) val movies by viewModel.movies.observeAsState(SearchResult.NoQuery)
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery)
@ -198,7 +202,7 @@ fun SearchPage(
} }
} }
searchResultRow( searchResultRow(
title = "Movies", title = context.getString(R.string.movies),
result = movies, result = movies,
rowIndex = MOVIE_ROW, rowIndex = MOVIE_ROW,
position = position, position = position,
@ -208,7 +212,7 @@ fun SearchPage(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
searchResultRow( searchResultRow(
title = "Collections", title = context.getString(R.string.collections),
result = collections, result = collections,
rowIndex = COLLECTION_ROW, rowIndex = COLLECTION_ROW,
position = position, position = position,
@ -218,7 +222,7 @@ fun SearchPage(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
searchResultRow( searchResultRow(
title = "Series", title = context.getString(R.string.tv_shows),
result = series, result = series,
rowIndex = SERIES_ROW, rowIndex = SERIES_ROW,
position = position, position = position,
@ -228,7 +232,7 @@ fun SearchPage(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
searchResultRow( searchResultRow(
title = "Episodes", title = context.getString(R.string.episodes),
result = episodes, result = episodes,
rowIndex = EPISODE_ROW, rowIndex = EPISODE_ROW,
position = position, position = position,
@ -308,7 +312,7 @@ fun LazyListScope.searchResultRow(
SearchResult.Searching -> SearchResult.Searching ->
SearchResultPlaceholder( SearchResultPlaceholder(
title = title, title = title,
message = "Searching...", message = stringResource(R.string.searching),
modifier = modifier, modifier = modifier,
) )
@ -316,7 +320,7 @@ fun LazyListScope.searchResultRow(
if (r.items.isEmpty()) { if (r.items.isEmpty()) {
SearchResultPlaceholder( SearchResultPlaceholder(
title = title, title = title,
message = "No results", message = stringResource(R.string.no_results),
modifier = modifier, modifier = modifier,
) )
} else { } else {

View file

@ -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) { LaunchedEffect(derivedFocusedIndex) {
val index = derivedFocusedIndex val index = derivedFocusedIndex
delay(600) delay(600)
@ -323,7 +324,7 @@ fun NavDrawer(
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -2 } LaunchedEffect(focused) { if (focused) focusedIndex = -2 }
IconNavItem( IconNavItem(
text = "Search", text = stringResource(R.string.search),
icon = Icons.Default.Search, icon = Icons.Default.Search,
selected = selectedIndex == -2, selected = selectedIndex == -2,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -344,7 +345,7 @@ fun NavDrawer(
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -1 } LaunchedEffect(focused) { if (focused) focusedIndex = -1 }
IconNavItem( IconNavItem(
text = "Home", text = stringResource(R.string.home),
icon = Icons.Default.Home, icon = Icons.Default.Home,
selected = selectedIndex == -1, selected = selectedIndex == -1,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -412,7 +413,7 @@ fun NavDrawer(
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE }
IconNavItem( IconNavItem(
text = "Settings", text = stringResource(R.string.settings),
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
selected = false, selected = false,
interactionSource = interactionSource, interactionSource = interactionSource,

View file

@ -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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.graphicsLayer 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.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
@ -35,6 +37,7 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogItemDivider import com.github.damontecres.wholphin.ui.components.DialogItemDivider
@ -58,7 +61,7 @@ fun DownloadSubtitlesContent(
SubtitleSearch.Searching -> { SubtitleSearch.Searching -> {
Wrapper { Wrapper {
Text( Text(
text = "Searching...", text = stringResource(R.string.searching),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -68,7 +71,7 @@ fun DownloadSubtitlesContent(
SubtitleSearch.Downloading -> { SubtitleSearch.Downloading -> {
Wrapper { Wrapper {
Text( Text(
text = "Downloading...", text = stringResource(R.string.downloading),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -199,9 +202,15 @@ fun convertRemoteSubtitles(
op.providerName?.let(::add) op.providerName?.let(::add)
op.threeLetterIsoLanguageName?.let(::add) op.threeLetterIsoLanguageName?.let(::add)
if (op.forced == true) { 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(
text = strings.joinToString(" - "), text = strings.joinToString(" - "),

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
data class SubtitleStream( data class SubtitleStream(
val index: Int, val index: Int,
val language: String?, val language: String?,
@ -17,7 +20,8 @@ data class SubtitleStream(
language, language,
title, title,
codec, codec,
).joinToString(" - ").ifBlank { "Unknown" } ).joinToString(" - ")
.ifBlank { WholphinApplication.instance.getString(R.string.unknown) }
} }
data class AudioStream( data class AudioStream(

View file

@ -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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -34,6 +35,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -68,7 +70,7 @@ fun NextUpEpisode(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
Text( Text(
text = "Up Next...", text = stringResource(R.string.next_up) + "...",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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.seekForward
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.stringRes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -212,7 +214,7 @@ fun PlaybackControls(
.padding(end = 32.dp), .padding(end = 32.dp),
) { ) {
Text( Text(
text = "Skip ${segment.type.serialName}", text = stringResource(R.string.skip) + " " + stringResource(segment.type.stringRes),
) )
} }
} }
@ -332,7 +334,7 @@ fun LeftPlaybackButtons(
val options = val options =
buildList { buildList {
addAll(moreButtonOptions.options.keys) 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( BottomDialog(
choices = options, choices = options,
@ -407,7 +409,12 @@ fun RightPlaybackButtons(
val currentChoice = val currentChoice =
subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex } ?: subtitleStreams.size subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex } ?: subtitleStreams.size
BottomDialog( BottomDialog(
choices = options + listOf("None", "Search & Download"), choices =
options +
listOf(
stringResource(R.string.none),
stringResource(R.string.search_and_download),
),
currentChoice = currentChoice, currentChoice = currentChoice,
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
@ -434,7 +441,12 @@ fun RightPlaybackButtons(
) )
} }
if (showOptionsDialog) { 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( BottomDialog(
choices = options, choices = options,
currentChoice = null, currentChoice = null,

View file

@ -38,11 +38,13 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
@ -199,7 +201,7 @@ fun PlaybackOverlay(
}, },
) { ) {
Text( Text(
text = "Chapters", text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
LazyRow( LazyRow(
@ -240,7 +242,7 @@ fun PlaybackOverlay(
} }
if (playlist.hasNext()) { if (playlist.hasNext()) {
Text( Text(
text = "Queue", text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = modifier =
Modifier Modifier
@ -282,7 +284,7 @@ fun PlaybackOverlay(
}, },
) { ) {
Text( Text(
text = "Queue", text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
LazyRow( LazyRow(
@ -489,7 +491,7 @@ fun Controller(
when (nextState) { when (nextState) {
OverlayViewState.CHAPTERS -> OverlayViewState.CHAPTERS ->
Text( Text(
text = "Chapters", text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = modifier =
Modifier Modifier
@ -501,7 +503,7 @@ fun Controller(
OverlayViewState.QUEUE -> OverlayViewState.QUEUE ->
Text( Text(
text = "Queue", text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = modifier =
Modifier Modifier

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@ -60,6 +61,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation 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.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -69,9 +71,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode import com.github.damontecres.wholphin.util.seasonEpisode
import com.github.damontecres.wholphin.util.stringRes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID import java.util.UUID
@ -162,7 +167,6 @@ fun PlaybackPage(
val scaledModifier = val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val controllerFocusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player) val playPauseState = rememberPlayPauseButtonState(player)
val seekBarState = rememberSeekBarState(player, scope) val seekBarState = rememberSeekBarState(player, scope)
@ -215,9 +219,9 @@ fun PlaybackPage(
Box( Box(
modifier 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( Box(
modifier = modifier =
Modifier Modifier
@ -410,7 +414,7 @@ fun PlaybackPage(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
) { ) {
Text( Text(
text = "Skip ${segment.type.serialName}", text = stringResource(R.string.skip) + " " + stringResource(segment.type.stringRes),
) )
} }
} }
@ -418,7 +422,13 @@ fun PlaybackPage(
// Next up episode // Next up episode
BackHandler(nextUp != null) { BackHandler(nextUp != null) {
viewModel.navigationManager.goBack() if (player.isPlaying) {
scope.launch(ExceptionHandler()) {
viewModel.cancelUpNextEpisode()
}
} else {
viewModel.navigationManager.goBack()
}
} }
AnimatedVisibility( AnimatedVisibility(
nextUp != null, nextUp != null,
@ -470,7 +480,7 @@ fun PlaybackPage(
Modifier Modifier
.padding(8.dp) .padding(8.dp)
// .height(128.dp) // .height(128.dp)
.fillMaxHeight(.5f) .fillMaxHeight(1 - playerSize)
.fillMaxWidth(.66f) .fillMaxWidth(.66f)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(

View file

@ -19,6 +19,7 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -33,6 +34,7 @@ import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus 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.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -97,12 +99,6 @@ import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
enum class TranscodeType {
DIRECT_PLAY,
DIRECT_STREAM,
TRANSCODE,
}
data class StreamDecision( data class StreamDecision(
val itemId: UUID, val itemId: UUID,
val type: PlayMethod, val type: PlayMethod,
@ -159,6 +155,7 @@ class PlaybackViewModel
val trickplay = MutableLiveData<TrickplayInfo?>(null) val trickplay = MutableLiveData<TrickplayInfo?>(null)
val chapters = MutableLiveData<List<Chapter>>(listOf()) val chapters = MutableLiveData<List<Chapter>>(listOf())
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null) val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
private val autoSkippedSegments = mutableSetOf<UUID>()
private lateinit var preferences: UserPreferences private lateinit var preferences: UserPreferences
private lateinit var deviceProfile: DeviceProfile private lateinit var deviceProfile: DeviceProfile
@ -244,6 +241,7 @@ class PlaybackViewModel
): Boolean = ): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}") Timber.i("Playing ${base.id}")
autoSkippedSegments.clear()
if (base.type !in supportItemKinds) { if (base.type !in supportItemKinds) {
showToast( showToast(
context, context,
@ -358,11 +356,9 @@ class PlaybackViewModel
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
) )
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.audioStreams.value = audioStreams
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
@ -664,39 +660,53 @@ class PlaybackViewModel
.firstOrNull { .firstOrNull {
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
} }
if (currentSegment != null) { if (currentSegment != null && autoSkippedSegments.add(currentSegment.id)) {
Timber.d( Timber.d(
"Found media segment for %s: %s, %s", "Found media segment for %s: %s, %s",
currentSegment.itemId, currentSegment.itemId,
currentSegment.id, currentSegment.id,
currentSegment.type, currentSegment.type,
) )
val behavior = val playlist = this@PlaybackViewModel.playlist.value
when (currentSegment.type) {
MediaSegmentType.COMMERCIAL -> prefs.skipCommercials if (currentSegment.type == MediaSegmentType.OUTRO &&
MediaSegmentType.PREVIEW -> prefs.skipPreviews prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
MediaSegmentType.RECAP -> prefs.skipRecaps playlist != null && playlist.hasNext()
MediaSegmentType.OUTRO -> prefs.skipOutros ) {
MediaSegmentType.INTRO -> prefs.skipIntros val nextItem = playlist.peek()
MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE Timber.v("Setting next up during outro to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem
} }
withContext(Dispatchers.Main) { } else {
when (behavior) { val behavior =
SkipSegmentBehavior.AUTO_SKIP -> { when (currentSegment.type) {
this@PlaybackViewModel.currentSegment.value = null MediaSegmentType.COMMERCIAL -> prefs.skipCommercials
player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) 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 -> { SkipSegmentBehavior.ASK_TO_SKIP -> {
this@PlaybackViewModel.currentSegment.value = currentSegment this@PlaybackViewModel.currentSegment.value =
} currentSegment
}
else -> { else -> {
this@PlaybackViewModel.currentSegment.value = null this@PlaybackViewModel.currentSegment.value = null
}
} }
} }
} }
} else { } else if (currentSegment == null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentSegment.value = null this@PlaybackViewModel.currentSegment.value = null
} }
@ -954,7 +964,7 @@ class PlaybackViewModel
if (maxAttempts == 0) { if (maxAttempts == 0) {
showToast( showToast(
context, context,
"Download is taking a long time, you may need to restart playback", context.getString(R.string.subtitle_download_too_long),
) )
} else { } else {
// Find the new subtitle stream // Find the new subtitle stream

View file

@ -97,9 +97,9 @@ fun PreferencesContent(
} }
val screenTitle = val screenTitle =
when (preferenceScreenOption) { when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> "Preferences" PreferenceScreenOption.BASIC -> R.string.settings
PreferenceScreenOption.ADVANCED -> "Advanced Preferences" PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
PreferenceScreenOption.USER_INTERFACE -> "User Interface Preferences" PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface
} }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
@ -126,7 +126,7 @@ fun PreferencesContent(
) { ) {
stickyHeader { stickyHeader {
Text( Text(
text = screenTitle, text = stringResource(screenTitle),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -42,6 +43,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -221,7 +223,7 @@ fun InstallUpdatePageContent(
).padding(16.dp), ).padding(16.dp),
) { ) {
Text( Text(
text = "Update available", text = stringResource(R.string.update_available),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -234,14 +236,14 @@ fun InstallUpdatePageContent(
onClick = onInstallRelease, onClick = onInstallRelease,
) { ) {
Text( Text(
text = "Download & Update", text = stringResource(R.string.download_and_update),
) )
} }
Button( Button(
onClick = onCancel, onClick = onCancel,
) { ) {
Text( Text(
text = "Cancel", text = stringResource(R.string.cancel),
) )
} }
} }

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
@ -96,7 +97,7 @@ fun ServerList(
ListItem( ListItem(
enabled = true, enabled = true,
selected = false, selected = false,
headlineContent = { Text(text = "Add Server") }, headlineContent = { Text(text = stringResource(R.string.add_server)) },
leadingContent = { leadingContent = {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
@ -116,11 +117,14 @@ fun ServerList(
title = server.name ?: server.url, title = server.name ?: server.url,
dialogItems = dialogItems =
listOf( 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) onSwitchServer.invoke(server)
}, },
DialogItem( DialogItem(
"Delete", stringResource(R.string.delete),
Icons.Default.Delete, Icons.Default.Delete,
Color.Red.copy(alpha = .8f), Color.Red.copy(alpha = .8f),
) { ) {

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -31,6 +32,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation 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.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.EditTextBox
@ -78,7 +80,7 @@ fun SwitchServerContent(
), ),
) { ) {
Text( Text(
text = "Select Server", text = stringResource(R.string.select_server),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -120,13 +122,13 @@ fun SwitchServerContent(
), ),
) { ) {
Text( Text(
text = "Discovered Servers", text = stringResource(R.string.discovered_servers),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
if (discoveredServers.isEmpty()) { if (discoveredServers.isEmpty()) {
Text( Text(
text = "Searching...", text = stringResource(R.string.searching),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -184,7 +186,7 @@ fun SwitchServerContent(
.fillMaxWidth(.4f), .fillMaxWidth(.4f),
) { ) {
Text( Text(
text = "Enter Server URL", text = stringResource(R.string.enter_server_url),
) )
EditTextBox( EditTextBox(
value = url, value = url,
@ -227,7 +229,7 @@ fun SwitchServerContent(
if (state == LoadingState.Loading) { if (state == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp)) CircularProgress(Modifier.size(32.dp))
} else { } else {
Text(text = "Submit") Text(text = stringResource(R.string.submit))
} }
} }
} }

View file

@ -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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext 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.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType 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.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation 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.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.EditTextBox
@ -94,7 +96,7 @@ fun SwitchUserContent(
), ),
) { ) {
Text( Text(
text = "Select User", text = stringResource(R.string.select_user),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )

View file

@ -67,7 +67,7 @@ fun UserList(
ListItem( ListItem(
enabled = true, enabled = true,
selected = false, selected = false,
headlineContent = { Text(text = "Add User") }, headlineContent = { Text(text = stringResource(R.string.add_user)) },
leadingContent = { leadingContent = {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
@ -84,7 +84,7 @@ fun UserList(
ListItem( ListItem(
enabled = true, enabled = true,
selected = false, selected = false,
headlineContent = { Text(text = "Switch servers") }, headlineContent = { Text(text = stringResource(R.string.switch_servers)) },
leadingContent = { leadingContent = {
Text( Text(
text = stringResource(R.string.fa_arrow_left_arrow_right), text = stringResource(R.string.fa_arrow_left_arrow_right),
@ -102,11 +102,14 @@ fun UserList(
title = user.name ?: user.id.toString(), title = user.name ?: user.id.toString(),
dialogItems = dialogItems =
listOf( 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) onSwitchUser.invoke(user)
}, },
DialogItem( DialogItem(
"Delete", stringResource(R.string.delete),
Icons.Default.Delete, Icons.Default.Delete,
Color.Red.copy(alpha = .8f), Color.Red.copy(alpha = .8f),
) { ) {

View file

@ -1,16 +1,8 @@
package com.github.damontecres.wholphin.util 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.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
val supportedHomeSection =
setOf(
HomeSection.LATEST_MEDIA,
HomeSection.NEXT_UP,
HomeSection.RESUME,
)
val supportItemKinds = val supportItemKinds =
setOf( setOf(
BaseItemKind.MOVIE, BaseItemKind.MOVIE,

View file

@ -48,7 +48,7 @@ class CrashReportSender : ReportSender {
val api = val api =
createJellyfin { createJellyfin {
this.context = context this.context = context
clientInfo = AppModule.clientInfo() clientInfo = AppModule.clientInfo(context)
deviceInfo = AppModule.deviceInfo(context) deviceInfo = AppModule.deviceInfo(context)
apiClientFactory = okHttpFactory apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory socketConnectionFactory = okHttpFactory

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util package com.github.damontecres.wholphin.util
import android.os.Build import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemDto 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.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import java.time.LocalDate import java.time.LocalDate
@ -164,3 +166,15 @@ fun formatBytes(
} }
return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) 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
}

View file

@ -10,31 +10,77 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Simple service to play theme song music * Simple service to play theme song music
*/ */
@OptIn(UnstableApi::class)
@Singleton @Singleton
class ThemeSongPlayer class ThemeSongPlayer
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, @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) suspend fun playThemeFor(
fun play( itemId: UUID,
volume: ThemeSongVolume, 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, url: String,
) { ) {
stop() stop()
val volumeLevel = val volumeLevel =
when (volume) { when (volumeLevel) {
ThemeSongVolume.UNRECOGNIZED, ThemeSongVolume.UNRECOGNIZED,
ThemeSongVolume.DISABLED, ThemeSongVolume.DISABLED,
-> return -> return
@ -45,24 +91,16 @@ class ThemeSongPlayer
ThemeSongVolume.HIGH -> .5f ThemeSongVolume.HIGH -> .5f
ThemeSongVolume.HIGHEST -> 75f ThemeSongVolume.HIGHEST -> 75f
} }
val player = player.apply {
ExoPlayer volume = volumeLevel
.Builder(context) setMediaItem(MediaItem.fromUri(url))
.setMediaSourceFactory( prepare()
DefaultMediaSourceFactory( play()
OkHttpDataSource.Factory(authOkHttpClient), }
),
).build()
.apply {
this.volume = volumeLevel
playWhenReady = true
}
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
this.player = player
} }
fun stop() { fun stop() {
player?.release() Timber.v("Stopping theme song")
player.stop()
} }
} }

View file

@ -3,6 +3,11 @@ syntax = "proto3";
option java_package = "com.github.damontecres.wholphin.preferences"; option java_package = "com.github.damontecres.wholphin.preferences";
option java_multiple_files = true; option java_multiple_files = true;
enum ShowNextUpWhen{
END_OF_PLAYBACK = 0;
DURING_CREDITS = 1;
}
enum SkipSegmentBehavior{ enum SkipSegmentBehavior{
IGNORE = 0; IGNORE = 0;
AUTO_SKIP = 1; AUTO_SKIP = 1;
@ -52,6 +57,7 @@ message PlaybackPreferences {
PlaybackOverrides overrides = 15; PlaybackOverrides overrides = 15;
int64 pass_out_protection_ms = 16; int64 pass_out_protection_ms = 16;
PrefContentScale global_content_scale = 17; PrefContentScale global_content_scale = 17;
ShowNextUpWhen show_next_up_when = 18;
} }
message HomePagePreferences{ message HomePagePreferences{

View file

@ -1,5 +1,53 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="pref_key_update_last_check_threshold" translatable="false">preference.update.threshold</string>
<string name="pref_key_update_last_check" translatable="false">preference.update.lastTimestamp</string>
<string name="ac3_supported">Device supports AC3/Dolby Digital</string>
<string name="advanced_settings">Advanced Settings</string>
<string name="advanced_ui">Advanced UI</string>
<string name="app_theme">Application Theme</string>
<string name="auto_check_for_updates">Automatically check for updates</string>
<string name="auto_play_next_delay">Delay before playing next up</string>
<string name="auto_play_next">Auto play next up</string>
<string name="check_for_updates">Check for updates</string>
<string name="combine_continue_next_summary">Applies to TV Series only</string>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
<string name="direct_play_ass">Direct play ASS subtitles</string>
<string name="direct_play_pgs">Direct play PGS subtitles</string>
<string name="downmix_stereo">Always downmix to stereo</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>
<string name="global_content_scale">Default content scale</string>
<string name="install_update">Install update</string>
<string name="installed_version">Installed version</string>
<string name="max_homepage_items">Max items on home page rows</string>
<string name="nav_drawer_pins_summary">Choose the default items to show, others will be hidden</string>
<string name="nav_drawer_pins">Customize Navigation Drawer Items</string>
<string name="nav_drawer_switch_on_focus_summary_off">Click to switch pages</string>
<string name="nav_drawer_switch_on_focus">Switch nav drawer pages on focus</string>
<string name="pass_out_protection">Passout Protection</string>
<string name="play_theme_music">Play theme music</string>
<string name="playback_overrides">Playback overrides</string>
<string name="remember_selected_tab">Remember selected tabs</string>
<string name="rewatch_next_up">Enable rewatching in next up</string>
<string name="seek_bar_steps">Seek bar steps</string>
<string name="send_app_logs_summary">Useful for debugging</string>
<string name="send_app_logs">Send app logs to current server</string>
<string name="send_crash_reports_summary">Will try to send to last connected server</string>
<string name="send_crash_reports">Send Crash Reports</string>
<string name="settings">Settings</string>
<string name="skip_back_on_resume_preference">Skip back when resuming playback</string>
<string name="skip_back_preference">Skip back</string>
<string name="skip_commercials_behavior">Skip commercials behavior</string>
<string name="skip_forward_preference">Skip forward</string>
<string name="skip_intro_behavior">Skip intro behavior</string>
<string name="skip_outro_behavior">Skip outro behavior</string>
<string name="skip_previews_behavior">Skip previews behavior</string>
<string name="skip_recap_behavior">Skip recap behavior</string>
<string name="update_available">Update available</string>
<string name="update_url_summary">URL used to check for app updates</string>
<string name="update_url">Update URL</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
<item>Lowest</item> <item>Lowest</item>
@ -37,4 +85,9 @@
<item>Never use FFmpeg decoders</item> <item>Never use FFmpeg decoders</item>
</string-array> </string-array>
<string-array name="show_next_up_when_options">
<item>At the end of playback</item>
<item>During end credits/outro</item>
</string-array>
</resources> </resources>

View file

@ -1,104 +1,157 @@
<resources> <resources>
<string name="app_name">Wholphin</string> <string name="app_name" translatable="false">Wholphin</string>
<string name="app_name_long" translatable="false">Wholphin</string>
<string name="home_section_latest_media">Recently added media</string> <string name="about">About</string>
<string name="home_section_library">My media</string> <string name="active_recordings">Active Recordings</string>
<string name="home_section_library_small">My media (small)</string> <string name="add_favorite">Favorite</string>
<string name="home_section_resume">Continue watching</string> <string name="add_server">Add Server</string>
<string name="home_section_resume_audio">Continue listening</string> <string name="add_user">Add User</string>
<string name="home_section_resume_book">Continue reading</string> <string name="audio">Audio</string>
<string name="home_section_active_recordings">Active recordings</string> <string name="birthplace">Birthplace</string>
<string name="home_section_next_up">Next up</string> <string name="bitrate">Bitrate</string>
<string name="home_section_livetv">Live TV</string> <string name="born">Born</string>
<string name="home_section_none">None</string> <string name="cancel_recording">Cancel Recording</string>
<string name="search">Search</string> <string name="cancel_series_recording">Cancel Series Recording</string>
<string name="resume">Resume</string> <string name="cancel">Cancel</string>
<string name="restart">Restart</string> <string name="chapters">Chapters</string>
<string name="play">Play</string> <string name="choose_stream">Choose %1$s</string>
<string name="more">More</string> <string name="clear_image_cache">Clear image cache</string>
<string name="collection">Collection</string>
<string name="collections">Collections</string>
<string name="commercial">Commercial</string>
<string name="community_rating">Community Rating</string>
<string name="compose_error_message_title">An error occurred! Press the button to send logs to your server.</string>
<string name="confirm">Confirm</string>
<string name="continue_watching">Continue watching</string>
<string name="critic_rating">Critic Rating</string>
<string name="decimal_seconds">%.1f seconds</string>
<string name="default_track">Default</string>
<string name="delete">Delete</string>
<string name="died">Died</string>
<string name="directed_by">Directed by %1$s</string>
<string name="director">Director</string>
<string name="disabled">Disabled</string>
<string name="discovered_servers">Discovered Servers</string>
<string name="download_and_update"><![CDATA[Download & Update]]></string>
<string name="downloading">Downloading…</string>
<string name="enabled">Enabled</string>
<string name="enter_server_url">Enter Server IP or URL including port</string>
<string name="episodes">Episodes</string>
<string name="error_loading_collection">Error loading collection %1$s</string>
<string name="external_track">External</string>
<string name="favorites">Favorites</string>
<string name="file_size">Size</string>
<string name="forced_track">Forced</string>
<string name="genres">Genres</string>
<string name="go_to_series">Go to series</string>
<string name="go_to">Go To</string>
<string name="hide_controller_timeout">Hide playback controls</string>
<string name="hide_debug_info">Hide debug info</string>
<string name="hide">Hide</string>
<string name="home">Home</string>
<string name="immediate">Immediate</string>
<string name="intro">Intro</string>
<string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string>
<string name="library">Library</string>
<string name="license_info">License information</string>
<string name="live_tv">Live TV</string>
<string name="loading">Loading…</string>
<string name="mark_entire_series_as_played">Mark entire series as played?</string>
<string name="mark_entire_series_as_unplayed">Mark entire series as unplayed?</string>
<string name="mark_unwatched">Mark as unwatched</string> <string name="mark_unwatched">Mark as unwatched</string>
<string name="mark_watched">Mark as watched</string> <string name="mark_watched">Mark as watched</string>
<string name="cancel">Cancel</string> <string name="max_bitrate">Max bitrate</string>
<string name="save">Save</string> <string name="more_like_this">More like this</string>
<string name="app_name_long">Wholphin</string> <string name="more">More</string>
<string name="ui_interface">Interface</string> <string name="movies">Movies</string>
<string name="playback">Playback</string> <string name="name">Name</string>
<string name="about">About</string> <string name="next_up">Next Up</string>
<string name="advanced_settings">Advanced Settings</string> <string name="no_data">No data</string>
<string name="advanced_ui">Advanced UI</string> <string name="no_results">No results</string>
<string name="install_update">Install update</string> <string name="no_scheduled_recordings">No scheduled recordings</string>
<string name="installed_version">Installed version</string> <string name="no_update_available">No update available</string>
<string name="show">Show</string> <string name="none">None</string>
<string name="hide">Hide</string> <string name="outro">Outro</string>
<string name="enabled">Enabled</string> <string name="path">Path</string>
<string name="disabled">Disabled</string> <string name="people">People</string>
<string name="skip_forward_preference">Skip forward</string> <string name="play_count">Play Count</string>
<string name="skip_back_preference">Skip back</string> <string name="play_from_here">Play from here</string>
<string name="hide_controller_timeout">Hide playback controls</string> <string name="play">Play</string>
<string name="seek_bar_steps">Seek bar steps</string>
<string name="max_homepage_items">Max items on home page rows</string>
<string name="rewatch_next_up">Enable rewatching in next up</string>
<string name="studios">Studios</string>
<string name="video">Video</string>
<string name="audio">Audio</string>
<string name="genres">Genres</string>
<string name="play_theme_music">Play theme music</string>
<string name="license_info">License information</string>
<string name="playback_debug_info">Show playback debug info</string> <string name="playback_debug_info">Show playback debug info</string>
<string name="auto_play_next_delay">Delay before playing next up</string> <string name="playback_speed">Playback Speed</string>
<string name="auto_play_next">Auto play next up</string> <string name="playback">Playback</string>
<string name="playlist">Playlist</string>
<string name="preview">Preview</string>
<string name="sort_by_name">Name</string> <string name="profile_specific_settings">User Profile Settings</string>
<string name="sort_by_random">Random</string> <string name="queue">Queue</string>
<string name="sort_by_date_episode_added">Date Episode Added</string> <string name="recap">Recap</string>
<string name="recently_added_in">Recently added in %1$s</string>
<string name="recently_added">Recently added</string>
<string name="recently_recorded">Recently Recorded</string>
<string name="recently_released">Recently Released</string>
<string name="recommended">Recommended</string>
<string name="record_program">Record Program</string>
<string name="record_series">Record Series</string>
<string name="remove_favorite">Unfavorite</string>
<string name="restart">Restart</string>
<string name="resume">Resume</string>
<string name="save">Save</string>
<string name="search_and_download"><![CDATA[Search & Download]]></string>
<string name="search">Search</string>
<string name="searching">Searching…</string>
<string name="select_server">Select Server</string>
<string name="select_user">Select User</string>
<string name="show_debug_info">Show debug info</string>
<string name="show_next_up_when">Show next up</string>
<string name="show">Show</string>
<string name="shuffle">Shuffle</string>
<string name="skip">Skip</string>
<string name="sort_by_date_added">Date Added</string> <string name="sort_by_date_added">Date Added</string>
<string name="sort_by_date_episode_added">Date Episode Added</string>
<string name="sort_by_date_played">Date Played</string> <string name="sort_by_date_played">Date Played</string>
<string name="sort_by_date_released">Date Released</string> <string name="sort_by_date_released">Date Released</string>
<string name="skip_back_on_resume_preference">Skip back when resuming playback</string> <string name="sort_by_name">Name</string>
<string name="pref_key_update_last_check_threshold">preference.update.threshold</string> <string name="sort_by_random">Random</string>
<string name="pref_key_update_last_check">preference.update.lastTimestamp</string> <string name="studios">Studios</string>
<string name="confirm">Confirm</string> <string name="submit">Submit</string>
<string name="remember_selected_tab">Remember selected tabs</string> <string name="subtitle_download_too_long">Download is taking a long time, you may need to restart playback</string>
<string name="director">Director</string> <string name="subtitle">Subtitle</string>
<string name="max_bitrate">Max bitrate</string> <string name="subtitles">Subtitles</string>
<string name="app_theme">Application Theme</string> <string name="suggestions">Suggestions</string>
<string name="skip_recap_behavior">Skip recap behavior</string> <string name="switch_servers">Switch servers</string>
<string name="skip_previews_behavior">Skip previews behavior</string> <string name="switch_user">Switch</string>
<string name="skip_comercials_behavior">Skip commercials behavior</string> <string name="top_unwatched">Top Rated Unwatched</string>
<string name="skip_outro_behavior">Skip outro behavior</string> <string name="trailer">Trailer</string>
<string name="skip_intro_behavior">Skip intro behavior</string>
<string name="check_for_updates">Check for updates</string>
<string name="auto_check_for_updates">Automatically check for updates</string>
<string name="update_url">Update URL</string>
<string name="update_url_summary">URL used to check for app updates</string>
<string name="updates">Updates</string>
<string name="no_update_available">No update available</string>
<string name="clear_image_cache">Clear image cache</string>
<string name="shuffle">Shuffle</string>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
<string name="ac3_supported">Device supports AC3/Dolby Digital</string>
<string name="downmix_stereo">Always downmix to stereo</string>
<string name="playback_overrides">Playback overrides</string>
<string name="direct_play_ass">Direct play ASS subtitles</string>
<string name="direct_play_pgs">Direct play PGS subtitles</string>
<string name="trailers">Trailers</string> <string name="trailers">Trailers</string>
<string name="pass_out_protection">Passout Protection</string> <string name="tv_dvr_schedule">DVR Schedule</string>
<string name="more_like_this">More like this</string> <string name="tv_guide">Guide</string>
<string name="remove_favorite">Unfavorite</string> <string name="tv_season">Season</string>
<string name="add_favorite">Favorite</string> <string name="tv_seasons">Seasons</string>
<string name="favorites">Favorites</string> <string name="tv_shows">TV Shows</string>
<string name="nav_drawer_pins">Customize Navigation Drawer Items</string> <string name="ui_interface">Interface</string>
<string name="profile_specific_settings">User Profile Settings</string> <string name="unknown">Unknown</string>
<string name="nav_drawer_pins_summary">Choose the default items to show, others will be hidden</string> <string name="updates">Updates</string>
<string name="combine_continue_next_summary">Applies to TV Series only</string> <string name="version">Version</string>
<string name="send_crash_reports">Send Crash Reports</string> <string name="video_scale">Video Scale</string>
<string name="send_crash_reports_summary">Will try to send to last connected server</string> <string name="video">Video</string>
<string name="send_app_logs">Send app logs to current server</string> <string name="watch_live">Watch live</string>
<string name="send_app_logs_summary">Useful for debugging</string> <string name="years_old">%1$d years old</string>
<string name="global_content_scale">Default content scale</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> <plurals name="downloads">
<string name="nav_drawer_switch_on_focus">Switch nav drawer pages on focus</string> <item quantity="zero">%d downloads</item>
<string name="nav_drawer_switch_on_focus_summary_off">Click to switch pages</string> <item quantity="one">%d download</item>
<item quantity="other">%d downloads</item>
</plurals>
<plurals name="hours">
<item quantity="zero">%d hours</item>
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</plurals>
<plurals name="seconds">
<item quantity="zero">%d seconds</item>
<item quantity="one">%d second</item>
<item quantity="other">%d seconds</item>
</plurals>
</resources> </resources>