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

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}")
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
val userDto =
apiClient.userApi
.getCurrentUser()
.content
val sysInfo by apiClient.systemApi.getSystemInfo()
val updatedServer = server.copy(name = sysInfo.serverName)
val userDto by apiClient.userApi.getCurrentUser()
val updatedServer =
try {
val sysInfo by apiClient.systemApi.getPublicSystemInfo()
server.copy(name = sysInfo.serverName)
} catch (ex: Exception) {
Timber.w(ex, "Exception fetching public system info")
server
}
var updatedUser =
user.copy(
id = userDto.id,
@ -171,8 +173,14 @@ class ServerRepository
}
if (user != null) {
changeUser(server, user)
} else {
throw IllegalArgumentException("Authentication result's user was null")
}
} else {
throw IllegalArgumentException("Authentication result's serverId not valid: ${authenticationResult.serverId}")
}
} else {
throw IllegalArgumentException("Authentication result's access token was null")
}
}

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -64,7 +64,8 @@ class GenreViewModel
}
}
suspend fun positionOfLetter(letter: Char): Int {
suspend fun positionOfLetter(letter: Char): Int =
withContext(Dispatchers.IO) {
val request =
GetGenresRequest(
parentId = itemId,
@ -73,7 +74,7 @@ class GenreViewModel
enableTotalRecordCount = true,
)
val result by GetGenresRequestHandler.execute(api, request)
return result.totalRecordCount
return@withContext result.totalRecordCount
}
}

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,14 @@ data class SortAndDirection(
val direction: SortOrder,
) {
fun flip() = copy(direction = direction.flip())
companion object {
val DEFAULT =
SortAndDirection(
ItemSortBy.SORT_NAME,
SortOrder.ASCENDING,
)
}
}
fun SortOrder.flip() = if (this == SortOrder.ASCENDING) SortOrder.DESCENDING else SortOrder.ASCENDING
@ -20,6 +28,9 @@ val MovieSortOptions =
ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.CRITIC_RATING,
ItemSortBy.PLAY_COUNT,
ItemSortBy.RANDOM,
)
@ -30,6 +41,7 @@ val SeriesSortOptions =
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_LAST_CONTENT_ADDED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.RANDOM,
)
@ -50,5 +62,8 @@ fun getStringRes(sort: ItemSortBy): Int =
ItemSortBy.DATE_LAST_CONTENT_ADDED -> R.string.sort_by_date_episode_added
ItemSortBy.DATE_PLAYED -> R.string.sort_by_date_played
ItemSortBy.RANDOM -> R.string.sort_by_random
ItemSortBy.COMMUNITY_RATING -> R.string.community_rating
ItemSortBy.CRITIC_RATING -> R.string.critic_rating
ItemSortBy.PLAY_COUNT -> R.string.play_count
else -> throw IllegalArgumentException("Unsupported sort option: $sort")
}

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

View file

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

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -52,7 +54,13 @@ fun CollectionFolderMovie(
val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
val tabs = listOf("Recommended", "Library", "Collections", "Genres")
val tabs =
listOf(
stringResource(R.string.recommended),
stringResource(R.string.library),
stringResource(R.string.collections),
stringResource(R.string.genres),
)
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
@ -29,6 +30,7 @@ import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -55,7 +57,12 @@ fun FavoritesPage(
)
}
val tabs = listOf("Movies", "TV Shows", "Episodes")
val tabs =
listOf(
stringResource(R.string.movies),
stringResource(R.string.tv_shows),
stringResource(R.string.episodes),
)
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }

View file

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

View file

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

View file

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

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

View file

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

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -30,6 +31,7 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -165,7 +167,14 @@ fun ProgramDialog(
)
}
Text(
text = if (isSeriesRecording) "Cancel Series Recording" else "Record Series",
text =
if (isSeriesRecording) {
stringResource(
R.string.cancel_series_recording,
)
} else {
stringResource(R.string.record_series)
},
)
}
}
@ -193,7 +202,16 @@ fun ProgramDialog(
)
}
Text(
text = if (isRecording) "Cancel Recording" else "Record Program",
text =
if (isRecording) {
stringResource(
R.string.cancel_recording,
)
} else {
stringResource(
R.string.record_program,
)
},
)
}
}
@ -210,11 +228,11 @@ fun ProgramDialog(
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Delete",
contentDescription = stringResource(R.string.delete),
tint = Color.Green.copy(alpha = .75f),
)
Text(
text = "Watch live",
text = stringResource(R.string.watch_live),
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

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

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@ -66,6 +67,7 @@ import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -212,7 +214,7 @@ fun PlaybackControls(
.padding(end = 32.dp),
) {
Text(
text = "Skip ${segment.type.serialName}",
text = stringResource(R.string.skip) + " " + stringResource(segment.type.stringRes),
)
}
}
@ -332,7 +334,7 @@ fun LeftPlaybackButtons(
val options =
buildList {
addAll(moreButtonOptions.options.keys)
add(if (showDebugInfo) "Hide debug info" else "Show debug info")
add(stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info))
}
BottomDialog(
choices = options,
@ -407,7 +409,12 @@ fun RightPlaybackButtons(
val currentChoice =
subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex } ?: subtitleStreams.size
BottomDialog(
choices = options + listOf("None", "Search & Download"),
choices =
options +
listOf(
stringResource(R.string.none),
stringResource(R.string.search_and_download),
),
currentChoice = currentChoice,
onDismissRequest = {
onControllerInteraction.invoke()
@ -434,7 +441,12 @@ fun RightPlaybackButtons(
)
}
if (showOptionsDialog) {
val options = listOf("Audio Track", "Playback Speed", "Video Scale")
val options =
listOf(
stringResource(R.string.audio),
stringResource(R.string.playback_speed),
stringResource(R.string.video_scale),
)
BottomDialog(
choices = options,
currentChoice = null,

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

@ -1,16 +1,8 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.ui.main.HomeSection
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
val supportedHomeSection =
setOf(
HomeSection.LATEST_MEDIA,
HomeSection.NEXT_UP,
HomeSection.RESUME,
)
val supportItemKinds =
setOf(
BaseItemKind.MOVIE,

View file

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

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util
import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R
@ -11,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import java.time.LocalDate
@ -164,3 +166,15 @@ fun formatBytes(
}
return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit])
}
@get:StringRes
val MediaSegmentType.stringRes: Int
get() =
when (this) {
MediaSegmentType.UNKNOWN -> R.string.unknown
MediaSegmentType.COMMERCIAL -> R.string.commercial
MediaSegmentType.PREVIEW -> R.string.preview
MediaSegmentType.RECAP -> R.string.recap
MediaSegmentType.OUTRO -> R.string.outro
MediaSegmentType.INTRO -> R.string.intro
}

View file

@ -10,31 +10,77 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Simple service to play theme song music
*/
@OptIn(UnstableApi::class)
@Singleton
class ThemeSongPlayer
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val api: ApiClient,
) {
private var player: Player? = null
private val player: Player by lazy {
ExoPlayer
.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(authOkHttpClient),
),
).build()
}
@OptIn(UnstableApi::class)
fun play(
suspend fun playThemeFor(
itemId: UUID,
volume: ThemeSongVolume,
): Boolean =
withContext(Dispatchers.IO) {
if (volume == ThemeSongVolume.DISABLED || volume == ThemeSongVolume.UNRECOGNIZED) {
return@withContext false
}
val themeSongs by api.libraryApi.getThemeSongs(itemId)
return@withContext themeSongs.items.randomOrNull()?.let { theme ->
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
theme.id,
container =
listOf(
Codec.Audio.OPUS,
Codec.Audio.MP3,
Codec.Audio.AAC,
Codec.Audio.FLAC,
),
)
Timber.v("Found theme song for $itemId")
withContext(Dispatchers.Main) {
play(volume, url)
}
true
} ?: false
}
private fun play(
volumeLevel: ThemeSongVolume,
url: String,
) {
stop()
val volumeLevel =
when (volume) {
when (volumeLevel) {
ThemeSongVolume.UNRECOGNIZED,
ThemeSongVolume.DISABLED,
-> return
@ -45,24 +91,16 @@ class ThemeSongPlayer
ThemeSongVolume.HIGH -> .5f
ThemeSongVolume.HIGHEST -> 75f
}
val player =
ExoPlayer
.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(authOkHttpClient),
),
).build()
.apply {
this.volume = volumeLevel
playWhenReady = true
player.apply {
volume = volumeLevel
setMediaItem(MediaItem.fromUri(url))
prepare()
play()
}
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
this.player = player
}
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_multiple_files = true;
enum ShowNextUpWhen{
END_OF_PLAYBACK = 0;
DURING_CREDITS = 1;
}
enum SkipSegmentBehavior{
IGNORE = 0;
AUTO_SKIP = 1;
@ -52,6 +57,7 @@ message PlaybackPreferences {
PlaybackOverrides overrides = 15;
int64 pass_out_protection_ms = 16;
PrefContentScale global_content_scale = 17;
ShowNextUpWhen show_next_up_when = 18;
}
message HomePagePreferences{

View file

@ -1,5 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<item>Disabled</item>
<item>Lowest</item>
@ -37,4 +85,9 @@
<item>Never use FFmpeg decoders</item>
</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>

View file

@ -1,104 +1,157 @@
<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="home_section_library">My media</string>
<string name="home_section_library_small">My media (small)</string>
<string name="home_section_resume">Continue watching</string>
<string name="home_section_resume_audio">Continue listening</string>
<string name="home_section_resume_book">Continue reading</string>
<string name="home_section_active_recordings">Active recordings</string>
<string name="home_section_next_up">Next up</string>
<string name="home_section_livetv">Live TV</string>
<string name="home_section_none">None</string>
<string name="search">Search</string>
<string name="resume">Resume</string>
<string name="restart">Restart</string>
<string name="play">Play</string>
<string name="more">More</string>
<string name="about">About</string>
<string name="active_recordings">Active Recordings</string>
<string name="add_favorite">Favorite</string>
<string name="add_server">Add Server</string>
<string name="add_user">Add User</string>
<string name="audio">Audio</string>
<string name="birthplace">Birthplace</string>
<string name="bitrate">Bitrate</string>
<string name="born">Born</string>
<string name="cancel_recording">Cancel Recording</string>
<string name="cancel_series_recording">Cancel Series Recording</string>
<string name="cancel">Cancel</string>
<string name="chapters">Chapters</string>
<string name="choose_stream">Choose %1$s</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_watched">Mark as watched</string>
<string name="cancel">Cancel</string>
<string name="save">Save</string>
<string name="app_name_long">Wholphin</string>
<string name="ui_interface">Interface</string>
<string name="playback">Playback</string>
<string name="about">About</string>
<string name="advanced_settings">Advanced Settings</string>
<string name="advanced_ui">Advanced UI</string>
<string name="install_update">Install update</string>
<string name="installed_version">Installed version</string>
<string name="show">Show</string>
<string name="hide">Hide</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="skip_forward_preference">Skip forward</string>
<string name="skip_back_preference">Skip back</string>
<string name="hide_controller_timeout">Hide playback controls</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="max_bitrate">Max bitrate</string>
<string name="more_like_this">More like this</string>
<string name="more">More</string>
<string name="movies">Movies</string>
<string name="name">Name</string>
<string name="next_up">Next Up</string>
<string name="no_data">No data</string>
<string name="no_results">No results</string>
<string name="no_scheduled_recordings">No scheduled recordings</string>
<string name="no_update_available">No update available</string>
<string name="none">None</string>
<string name="outro">Outro</string>
<string name="path">Path</string>
<string name="people">People</string>
<string name="play_count">Play Count</string>
<string name="play_from_here">Play from here</string>
<string name="play">Play</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="auto_play_next">Auto play next up</string>
<string name="sort_by_name">Name</string>
<string name="sort_by_random">Random</string>
<string name="sort_by_date_episode_added">Date Episode Added</string>
<string name="playback_speed">Playback Speed</string>
<string name="playback">Playback</string>
<string name="playlist">Playlist</string>
<string name="preview">Preview</string>
<string name="profile_specific_settings">User Profile Settings</string>
<string name="queue">Queue</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_episode_added">Date Episode Added</string>
<string name="sort_by_date_played">Date Played</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="pref_key_update_last_check_threshold">preference.update.threshold</string>
<string name="pref_key_update_last_check">preference.update.lastTimestamp</string>
<string name="confirm">Confirm</string>
<string name="remember_selected_tab">Remember selected tabs</string>
<string name="director">Director</string>
<string name="max_bitrate">Max bitrate</string>
<string name="app_theme">Application Theme</string>
<string name="skip_recap_behavior">Skip recap behavior</string>
<string name="skip_previews_behavior">Skip previews behavior</string>
<string name="skip_comercials_behavior">Skip commercials behavior</string>
<string name="skip_outro_behavior">Skip outro behavior</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="sort_by_name">Name</string>
<string name="sort_by_random">Random</string>
<string name="studios">Studios</string>
<string name="submit">Submit</string>
<string name="subtitle_download_too_long">Download is taking a long time, you may need to restart playback</string>
<string name="subtitle">Subtitle</string>
<string name="subtitles">Subtitles</string>
<string name="suggestions">Suggestions</string>
<string name="switch_servers">Switch servers</string>
<string name="switch_user">Switch</string>
<string name="top_unwatched">Top Rated Unwatched</string>
<string name="trailer">Trailer</string>
<string name="trailers">Trailers</string>
<string name="pass_out_protection">Passout Protection</string>
<string name="more_like_this">More like this</string>
<string name="remove_favorite">Unfavorite</string>
<string name="add_favorite">Favorite</string>
<string name="favorites">Favorites</string>
<string name="nav_drawer_pins">Customize Navigation Drawer Items</string>
<string name="profile_specific_settings">User Profile Settings</string>
<string name="nav_drawer_pins_summary">Choose the default items to show, others will be hidden</string>
<string name="combine_continue_next_summary">Applies to TV Series only</string>
<string name="send_crash_reports">Send Crash Reports</string>
<string name="send_crash_reports_summary">Will try to send to last connected server</string>
<string name="send_app_logs">Send app logs to current server</string>
<string name="send_app_logs_summary">Useful for debugging</string>
<string name="global_content_scale">Default content scale</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>
<string name="nav_drawer_switch_on_focus">Switch nav drawer pages on focus</string>
<string name="nav_drawer_switch_on_focus_summary_off">Click to switch pages</string>
<string name="tv_dvr_schedule">DVR Schedule</string>
<string name="tv_guide">Guide</string>
<string name="tv_season">Season</string>
<string name="tv_seasons">Seasons</string>
<string name="tv_shows">TV Shows</string>
<string name="ui_interface">Interface</string>
<string name="unknown">Unknown</string>
<string name="updates">Updates</string>
<string name="version">Version</string>
<string name="video_scale">Video Scale</string>
<string name="video">Video</string>
<string name="watch_live">Watch live</string>
<string name="years_old">%1$d years old</string>
<plurals name="downloads">
<item quantity="zero">%d downloads</item>
<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>