mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/epg-fix
This commit is contained in:
commit
72b3fac44c
65 changed files with 1370 additions and 501 deletions
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class AppPreferencesSerializer
|
|||
skipRecaps = AppPreference.SkipRecaps.defaultValue
|
||||
passOutProtectionMs =
|
||||
AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds
|
||||
showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue
|
||||
|
||||
overrides =
|
||||
PlaybackOverrides
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(" - "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ fun FocusedEpisodeFooter(
|
|||
?.let {
|
||||
if (it.isNotNullOrBlank()) {
|
||||
TitleValueText(
|
||||
"Subtitles",
|
||||
stringResource(R.string.subtitles),
|
||||
it,
|
||||
modifier = Modifier.widthIn(max = 160.dp),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(" - "),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue