Merge branch 'main' into develop/ac3-test

This commit is contained in:
Damontecres 2026-05-11 18:01:36 -04:00
commit 2d193f4aee
No known key found for this signature in database
134 changed files with 6151 additions and 4180 deletions

View file

@ -29,6 +29,7 @@ body:
options:
- ExoPlayer (default)
- MPV
- External App (please specify)
validations:
required: true
- type: input

View file

@ -16,7 +16,7 @@ concurrency:
env:
BRANCH_NAME: ${{ github.ref_name }}
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
jobs:
build:

View file

@ -9,7 +9,7 @@ defaults:
env:
BUILD_DIRS_ARTIFACT: build-dirs
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
jobs:
pre-commit:

View file

@ -11,7 +11,7 @@ defaults:
env:
APK_SHORT_NAME: Wholphin.apk
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
TAG_NAME: ${{ github.ref_name }}
jobs:

View file

@ -34,6 +34,14 @@ Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: ht
Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
#### Extensions
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
## Code organization
Code is split into several packages:
@ -43,21 +51,22 @@ Code is split into several packages:
- `ui` - User interface code and ViewModels
- `util` - Utility classes and functions
## Extensions
### Settings
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
There are a few different ways user settings are stored:
1. `AppPreferences` via DataStore
2. Room database
3. Key-Value DataStore
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
#### App preferences
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
These are generally settings that apply across the whole app regardless of the currently active user.
### App settings
The `AppPreferences` object can be retrieved from the `UserPreferencesService` or directly via injecting `DataStore<AppPreferences>`.
App settings are available with the `AppPreferences` object and defined by different `AppPreference` objects (note the `s` differences).
The `AppPreference` (note the `s` differences) objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`.
The `AppPreference` objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`.
#### How to add a new app setting
##### How to add a new app setting
1. Add entry in `WholphinDataStore.proto` & build to generate classes
2. Add new `AppPreference` object in `AppPreference.kt`
@ -65,3 +74,24 @@ The `AppPreference` objects are used to create the UI for configuring settings u
4. Update `AppPreferencesSerializer` to set the default value for new installs
5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades
- Since preferences use proto3, the [default values](https://protobuf.dev/programming-guides/proto3/#default) are zero, false, or the first enum, so only need this step if the default value is different
#### Room settings
These are settings were generally are applied per user. They are stored in several different tables. The entities are defined in `com.github.damontecres.wholphin.data.model`.
Additionally, all server and user info for both Jellyfin and Seerr are stored in Room tables.
Some examples are:
- `JellyfinServer`
- `JellyfinUser`
- `SeerrServer`
- `LibraryDisplayInfo`
- `ItemPlayback`
Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSON in a column instead of defined entities. This should be used sparingly.
#### Key-value settings
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
This should be used sparingly.

View file

@ -1,4 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ProductFlavor
import com.android.build.api.variant.FilterConfiguration
import com.google.protobuf.gradle.id
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
@ -37,7 +39,15 @@ val gitDescribe =
.standardOutput.asText
.getOrElse("v0.0.0")
android {
kotlin {
compilerOptions {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11
javaParameters = true
}
}
configure<ApplicationExtension> {
namespace = "com.github.damontecres.wholphin"
compileSdk = 36
@ -98,18 +108,6 @@ android {
isDebuggable = true
applicationIdSuffix = ".debug"
}
applicationVariants.all {
val variant = this
variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output ->
val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" }
val outputFileName =
"Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk"
output.outputFileName = outputFileName
}
}
}
flavorDimensions += "version"
productFlavors {
@ -148,20 +146,11 @@ android {
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11
javaParameters = true
}
}
buildFeatures {
buildConfig = true
compose = true
}
room {
schemaDirectory("$projectDir/schemas")
}
splits {
abi {
@ -195,6 +184,26 @@ android {
}
}
androidComponents {
onVariants(selector().all()) { variant ->
variant.outputs
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
.forEach { output ->
val abi =
output
.getFilter(FilterConfiguration.FilterType.ABI)
.let { if (it != null) "-${it.identifier}" else "" }
val outputFileName =
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
output.outputFileName = outputFileName
}
}
}
room {
schemaDirectory("$projectDir/schemas")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}"

View file

@ -45,7 +45,8 @@
android:theme="@style/Theme.Wholphin"
android:name=".WholphinApplication"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
android:networkSecurityConfig="@xml/network_security_config"
android:largeHeap="true">
<activity
android:name=".MainActivity"
android:exported="true"

View file

@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler
@ -55,6 +54,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@ -83,6 +83,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: MainActivityViewModel by viewModels()
private val playExternalViewModel: PlayExternalViewModel by viewModels()
@Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@ -153,12 +154,10 @@ class MainActivity : AppCompatActivity() {
if (backStackStr != null) {
Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull()
if (lastDest is Destination.Playback ||
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
if (lastDest.isPlayback) {
Timber.v("Restoring back stack with playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
@ -220,14 +219,7 @@ class MainActivity : AppCompatActivity() {
signInAuto = appPreferences.signInAutomatically
}
CoilConfig(
diskCacheSizeBytes =
appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let {
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
} else {
it
}
},
prefs = appPreferences,
okHttpClient = okHttpClient,
debugLogging = false,
enableCache = true,
@ -279,6 +271,14 @@ class MainActivity : AppCompatActivity() {
super.onRestart()
Timber.d("onRestart")
viewModel.appStart()
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
if (lastDest.isPlayback) {
Timber.v("onRestart: go back from playback")
navigationManager.goBack()
}
}
}
override fun onStop() {
@ -479,3 +479,9 @@ class MainActivityViewModel
}
}
}
private val Destination?.isPlayback: Boolean
get() =
this is Destination.Playback ||
this is Destination.PlaybackList ||
this is Destination.Slideshow

View file

@ -85,7 +85,7 @@ fun MainContent(
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
server = key.server,
Modifier.fillMaxSize(),
)
}

View file

@ -23,6 +23,8 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -30,6 +32,7 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
@ -48,6 +51,10 @@ class WholphinDreamService :
@Inject
lateinit var preferencesDataStore: DataStore<AppPreferences>
@AuthOkHttpClient
@Inject
lateinit var okHttpClient: OkHttpClient
private val lifecycleRegistry = LifecycleRegistry(this)
private val savedStateRegistryController =
@ -88,6 +95,12 @@ class WholphinDreamService :
preferencesDataStore.data.collectLatest { prefs = it }
}
prefs?.let { prefs ->
CoilConfig(
prefs = prefs,
okHttpClient = okHttpClient,
debugLogging = false,
enableCache = true,
)
WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) {
ProvideLocalClock {
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference

View file

@ -15,7 +15,11 @@ sealed interface ExtrasItem {
val parentId: UUID
val type: ExtraType
val destination: Destination
val title: String?
val imageUrl: String?
val title: String
val subtitle: String?
val isPlayed: Boolean
val playedPercentage: Double
/**
* Represents multiple extras of the same type
@ -24,11 +28,16 @@ sealed interface ExtrasItem {
override val parentId: UUID,
override val type: ExtraType,
val items: List<BaseItem>,
override val imageUrl: String?,
override val title: String,
override val subtitle: String,
override val isPlayed: Boolean,
) : ExtrasItem {
override val destination: Destination =
Destination.ItemGrid(null, type.stringRes, items.map { it.id })
override val title: String? = null
override val playedPercentage
get() = -1.0
}
/**
@ -38,12 +47,18 @@ sealed interface ExtrasItem {
override val parentId: UUID,
override val type: ExtraType,
val item: BaseItem,
override val imageUrl: String?,
override val title: String,
override val subtitle: String?,
) : ExtrasItem {
override val destination: Destination =
Destination.Playback(
item = item,
)
override val title: String? get() = item.title
override val isPlayed: Boolean
get() = item.played
override val playedPercentage
get() = item.data.userData?.playedPercentage ?: 0.0
}
}

View file

@ -73,7 +73,7 @@ class ServerRepository
suspend fun changeUser(
server: JellyfinServer,
user: JellyfinUser,
): CurrentUser? =
): CurrentUser =
withContext(ioDispatcher) {
if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server")
@ -104,15 +104,16 @@ class ServerRepository
currentUserId = updatedUser.id.toServerString()
}.build()
}
val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser)
_current.value = currentUser
_currentUserDto.value = userDto
}
getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
return@withContext _current.value
return@withContext currentUser
}
/**

View file

@ -12,7 +12,7 @@ import java.util.UUID
* This is not the same thing as a Jellyfin server playlist
*/
class Playlist(
items: List<BaseItem>,
items: List<PlaylistItem>,
startIndex: Int = 0,
) {
val items = items.subList(startIndex, items.size)
@ -23,15 +23,15 @@ class Playlist(
fun hasNext(): Boolean = (index + 1) < items.size
fun getPreviousAndReverse(): BaseItem = items[--index]
fun getPreviousAndReverse(): PlaylistItem = items[--index]
fun getAndAdvance(): BaseItem = items[++index]
fun getAndAdvance(): PlaylistItem = items[++index]
fun peek(): BaseItem? = items.getOrNull(index + 1)
fun peek(): PlaylistItem? = items.getOrNull(index + 1)
fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size)
fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size)
fun advanceTo(id: UUID): BaseItem? {
fun advanceTo(id: UUID): PlaylistItem? {
while (hasNext()) {
val potential = getAndAdvance()
if (potential.id == id) {
@ -52,3 +52,22 @@ data class PlaylistInfo(
val count: Int,
val mediaType: MediaType,
)
sealed interface PlaylistItem {
val id: UUID
val item: BaseItem
data class Media(
override val item: BaseItem,
) : PlaylistItem {
override val id: UUID
get() = item.id
}
data class Intro(
override val item: BaseItem,
) : PlaylistItem {
override val id: UUID
get() = item.id
}
}

View file

@ -117,30 +117,6 @@ sealed interface AppPreference<Pref, T> {
},
)
// val GridJumpButtons =
// AppSwitchPreference<AppPreferences>(
// title = R.string.show_grid_jump_buttons,
// defaultValue = true,
// getter = { it.interfacePreferences.showGridJumpButtons },
// setter = { prefs, value ->
// prefs.updateInterfacePreferences { showGridJumpButtons = value }
// },
// summaryOn = R.string.enabled,
// summaryOff = R.string.disabled,
// )
// val ShowGridFooter =
// AppSwitchPreference<AppPreferences>(
// title = R.string.grid_position_footer,
// defaultValue = true,
// getter = { it.interfacePreferences.showPositionFooter },
// setter = { prefs, value ->
// prefs.updateInterfacePreferences { showPositionFooter = value }
// },
// summaryOn = R.string.show,
// summaryOff = R.string.hide,
// )
val ControllerTimeout =
AppSliderPreference<AppPreferences>(
title = R.string.hide_controller_timeout,
@ -513,6 +489,18 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled,
)
val CinemaMode =
AppSwitchPreference<AppPreferences>(
title = R.string.cinema_mode,
defaultValue = true,
getter = { it.playbackPreferences.cinemaMode },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { cinemaMode = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val RememberSelectedTab =
AppSwitchPreference<AppPreferences>(
title = R.string.remember_selected_tab,
@ -664,6 +652,13 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
)
val SkipSegments =
AppDestinationPreference<AppPreferences>(
title = R.string.skip_behavior,
summary = R.string.skip_behavior_summary,
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
)
val GlobalContentScale =
AppChoicePreference<AppPreferences, PrefContentScale>(
title = R.string.global_content_scale,
@ -1177,6 +1172,21 @@ val MpvPreferences =
),
)
val SkipSegmentPreferences =
listOf(
PreferenceGroup(
title = R.string.skip,
preferences =
listOf(
AppPreference.SkipIntros,
AppPreference.SkipOutros,
AppPreference.SkipCommercials,
AppPreference.SkipPreviews,
AppPreference.SkipRecaps,
),
),
)
val advancedPreferences =
buildList {
add(
@ -1203,7 +1213,9 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.OneClickPause,
AppPreference.CinemaMode,
AppPreference.GlobalContentScale,
AppPreference.SkipSegments,
AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching,
AppPreference.ResolutionSwitching,
@ -1211,19 +1223,6 @@ val advancedPreferences =
),
),
)
add(
PreferenceGroup(
title = R.string.skip,
preferences =
listOf(
AppPreference.SkipIntros,
AppPreference.SkipOutros,
AppPreference.SkipCommercials,
AppPreference.SkipPreviews,
AppPreference.SkipRecaps,
),
),
)
add(
PreferenceGroup(
title = R.string.player_backend,

View file

@ -54,6 +54,7 @@ class AppPreferencesSerializer
refreshRateSwitching =
AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
cinemaMode = AppPreference.CinemaMode.defaultValue
overrides =
PlaybackOverrides

View file

@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@ -327,5 +328,11 @@ class AppUpgradeHandler
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.6.2-18-g0"))) {
appPreferences.updateData {
it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue }
}
}
}
}

View file

@ -1,10 +1,15 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.pluralRes
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.ExtraType
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@ -19,6 +24,8 @@ class ExtrasService
@Inject
constructor(
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val imageUrlService: ImageUrlService,
) {
/**
* Get the [ExtrasItem]s for the given item
@ -39,9 +46,45 @@ class ExtrasService
extrasMap
.mapNotNull { (type, items) ->
if (items.size == 1) {
ExtrasItem.Single(itemId, type, items.first())
val item = items.first()
val title =
item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
val subtitle =
if (item.title == null) {
context.resources.getQuantityString(type.pluralRes, 1)
} else {
null
}
ExtrasItem.Single(
parentId = itemId,
type = type,
item = item,
title = title,
subtitle = subtitle,
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
)
} else if (items.size > 1) {
ExtrasItem.Group(itemId, type, items)
val title =
context.resources.getQuantityString(type.pluralRes, items.size)
val subtitle =
context.resources.getQuantityString(
R.plurals.items,
items.size,
items.size,
)
ExtrasItem.Group(
parentId = itemId,
type = type,
items = items,
title = title,
subtitle = subtitle,
isPlayed = items.all { it.played },
imageUrl =
imageUrlService.getItemImageUrl(
items.random(),
ImageType.PRIMARY,
),
)
} else {
null
}

View file

@ -6,12 +6,15 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.combinePair
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
@ -68,6 +71,16 @@ class MediaManagementService
}
}
suspend fun collectCanDelete(
itemFlow: Flow<BaseItem?>,
update: (Boolean) -> Unit,
) {
itemFlow.combinePair(userPreferencesService.flow).collectLatest { (item, prefs) ->
val canDelete = item?.let { canDelete(item, prefs.appPreferences) } ?: false
update.invoke(canDelete)
}
}
/**
* Delete the item.
*

View file

@ -19,14 +19,6 @@ class PlaybackLifecycleObserver
private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) {
// TODO
// val lastDest = navigationManager.backStack.lastOrNull()
// if (lastDest is Destination.Playback ||
// lastDest is Destination.PlaybackList ||
// lastDest is Destination.Slideshow
// ) {
// navigationManager.goBack()
// }
wasPlaying = null
}

View file

@ -106,7 +106,13 @@ class PlayerFactory
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
if (useLibAss) {
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
val renderType =
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AssRenderType.OVERLAY_CANVAS
} else {
AssRenderType.OVERLAY_OPEN_GL
}
assHandler = AssHandler(renderType)
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory(

View file

@ -7,12 +7,14 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
@ -22,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@ -247,14 +250,14 @@ class PlaylistCreator
-> {
val list =
buildList {
add(BaseItem(item, false))
add(PlaylistItem.Media(BaseItem(item, false)))
if (item.partCount.gt(1)) {
api.videosApi
.getAdditionalPart(item.id)
.content.items
.map {
BaseItem(it, false)
PlaylistItem.Media(BaseItem(it, false))
}.let(::addAll)
}
}
@ -274,14 +277,14 @@ class PlaylistCreator
}
}
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<BaseItem> =
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<PlaylistItem> =
buildList {
this@convertAndAddParts.forEach { ep ->
add(BaseItem(ep, useSeriesForPrimary))
add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary)))
if (ep.partCount.gt(1)) {
val parts =
api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
BaseItem(part, useSeriesForPrimary)
PlaylistItem.Media(BaseItem(part, useSeriesForPrimary))
}
addAll(parts)
}

View file

@ -4,7 +4,7 @@ import android.content.Context
import coil3.imageLoader
import coil3.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.components.CurrentItem
import com.github.damontecres.wholphin.ui.components.ScreensaverItem
import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager
@ -154,30 +154,20 @@ class ScreensaverService
/**
* Create a flow of items to show on the screensaver
*/
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
fun createItemFlow(scope: CoroutineScope): Flow<ScreensaverItem?> =
flow {
val prefs =
userPreferencesService.flow
.first()
.appPreferences
.interfacePreferences.screensaverPreference
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
val request =
GetItemsRequest(
recursive = true,
includeItemTypes = itemTypes,
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
sortBy = listOf(ItemSortBy.RANDOM),
maxOfficialRating = maxAge?.toString(),
hasParentalRating = maxAge?.let { true },
)
val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
try {
createPager()
} catch (ex: Exception) {
Timber.e(ex, "Error creating pager for screensaver")
emit(ScreensaverItem.Error(ex))
return@flow
}
Timber.v("Got %s items", pager.size)
var index = 0
if (pager.isEmpty()) {
emit(null)
emit(ScreensaverItem.Empty)
} else {
val duration =
userPreferencesService
@ -213,7 +203,14 @@ class ScreensaverService
.build(),
).job
.await()
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
emit(
ScreensaverItem.CurrentItem(
item,
backdropUrl,
logoUrl,
title ?: "",
),
)
delay(duration)
}
}
@ -228,6 +225,26 @@ class ScreensaverService
}
}
}.flowOn(Dispatchers.Default).cancellable()
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
val prefs =
userPreferencesService.flow
.first()
.appPreferences
.interfacePreferences.screensaverPreference
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
val request =
GetItemsRequest(
recursive = true,
includeItemTypes = itemTypes,
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
sortBy = listOf(ItemSortBy.RANDOM),
maxOfficialRating = maxAge?.toString(),
hasParentalRating = maxAge?.let { true },
)
return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
}
}
data class ScreensaverState(

View file

@ -340,7 +340,7 @@ class UpdateChecker
context.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
arrayOf("$ASSET_NAME%", APK_MIME_TYPE),
)
Timber.i("Deleted $deletedRows rows")
} else {

View file

@ -16,10 +16,32 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.Options
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import okhttp3.OkHttpClient
import timber.log.Timber
import kotlin.time.ExperimentalTime
@Composable
fun CoilConfig(
prefs: AppPreferences,
okHttpClient: OkHttpClient,
debugLogging: Boolean,
enableCache: Boolean = true,
) = CoilConfig(
diskCacheSizeBytes =
prefs.advancedPreferences.imageDiskCacheSizeBytes.let {
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
} else {
it
}
},
okHttpClient = okHttpClient,
debugLogging = debugLogging,
enableCache = enableCache,
)
/**
* Configure Coil image loading
*/

View file

@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@ -70,6 +71,8 @@ fun EpisodeCard(
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
val density = LocalDensity.current
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
@ -102,6 +105,7 @@ fun EpisodeCard(
watchedPercent = dto?.userData?.playedPercentage,
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false,
fillWidth = imageWidthPx,
modifier =
Modifier
.fillMaxSize(),

View file

@ -1,19 +1,13 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.pluralRes
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun ExtrasRow(
@ -22,52 +16,27 @@ fun ExtrasRow(
onLongClickItem: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val resources = LocalResources.current
ItemRow(
title = stringResource(R.string.extras),
items = extras,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardContent = { index, item, mod, onClick, onLongClick ->
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember {
val item =
when (item) {
is ExtrasItem.Group -> item.items.random()
is ExtrasItem.Single -> item.item
null -> null
}
imageUrlService.getItemImageUrl(item, ImageType.PRIMARY)
}
SeasonCard(
title =
when (item) {
is ExtrasItem.Group -> item.type.pluralRes.let { resources.getQuantityString(it, item.items.size) }
is ExtrasItem.Single -> item.title ?: item.type.pluralRes.let { resources.getQuantityString(it, 1) }
null -> null
},
title = item?.title,
name = null,
subtitle =
if (item is ExtrasItem.Single && item.title != null) {
item.type.pluralRes.let { resources.getQuantityString(it, 1) }
} else if (item is ExtrasItem.Group) {
resources.getQuantityString(R.plurals.items, item.items.size, item.items.size)
} else {
null
},
subtitle = item?.subtitle,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
showImageOverlay = true,
imageHeight = Cards.height2x3 * .75f,
imageHeight = Cards.heightEpisode,
imageWidth = Dp.Unspecified,
imageUrl = imageUrl,
imageUrl = item?.imageUrl,
isFavorite = false,
isPlayed = false,
isPlayed = item?.isPlayed == true,
unplayedItemCount = -1,
playedPercentage = -1.0,
playedPercentage = item?.playedPercentage ?: 0.0,
numberOfVersions = -1,
aspectRatio = AspectRatios.FOUR_THREE, // TODO
)

View file

@ -47,6 +47,8 @@ fun GridCard(
imageContentScale: ContentScale = ContentScale.Fit,
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
showTitle: Boolean = true,
fillWidth: Int? = null,
fillHeight: Int? = null,
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
@ -95,6 +97,8 @@ fun GridCard(
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false,
contentScale = imageContentScale,
fillWidth = fillWidth,
fillHeight = fillHeight,
modifier =
Modifier
.fillMaxWidth()

View file

@ -24,11 +24,9 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
@ -63,20 +61,19 @@ fun ItemCardImage(
imageType: ImageType = ImageType.PRIMARY,
useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit,
fillWidth: Int? = null,
fillHeight: Int? = null,
) {
val imageUrlService = LocalImageUrlService.current
var size by remember { mutableStateOf(IntSize.Zero) }
val imageUrl =
remember(size, item) {
if (size != IntSize.Zero && item != null) {
remember(item) {
item?.let {
imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = size.width,
fillHeight = size.height,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
ItemCardImage(
@ -88,13 +85,7 @@ fun ItemCardImage(
unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent,
numberOfVersions = numberOfVersions,
modifier =
modifier.onLayoutRectChanged(
throttleMillis = 100,
debounceMillis = 25,
) {
size = IntSize(width = it.width, height = it.height)
},
modifier = modifier,
useFallbackText = useFallbackText,
contentScale = contentScale,
)

View file

@ -1,11 +1,15 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -32,6 +37,7 @@ import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.graphicsLayer
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.datastore.core.DataStore
@ -45,6 +51,7 @@ import coil3.compose.AsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
@ -53,6 +60,7 @@ import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@ -67,12 +75,20 @@ class ScreensaverViewModel
val currentItem = screensaverService.createItemFlow(viewModelScope)
}
data class CurrentItem(
val item: BaseItem,
val backdropUrl: String,
val logoUrl: String?,
val title: String,
)
sealed interface ScreensaverItem {
data class Error(
val exception: Exception,
) : ScreensaverItem
data object Empty : ScreensaverItem
data class CurrentItem(
val item: BaseItem,
val backdropUrl: String,
val logoUrl: String?,
val title: String,
) : ScreensaverItem
}
@Composable
fun AppScreensaver(
@ -93,7 +109,7 @@ fun AppScreensaver(
@OptIn(ExperimentalCoilApi::class)
@Composable
fun AppScreensaverContent(
currentItem: CurrentItem?,
currentItem: ScreensaverItem?,
showClock: Boolean,
duration: Duration,
animate: Boolean,
@ -116,61 +132,83 @@ fun AppScreensaverContent(
repeatMode = RepeatMode.Reverse,
),
)
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.backdropUrl)
.transitionFactory(CrossFadeFactory(2000.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
},
)
var logoError by remember(currentItem) { mutableStateOf(false) }
if (!logoError) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.logoUrl)
.transitionFactory(CrossFadeFactory(750.milliseconds))
.build(),
contentDescription = "Logo",
onError = {
logoError = true
},
modifier =
Modifier
.align(Alignment.BottomStart)
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
} else {
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
.fillMaxWidth(.5f)
.fillMaxHeight(.3f),
) {
Text(
text = currentItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart),
when (currentItem) {
ScreensaverItem.Empty -> {
ScreensaverPlaceholder(
text = stringResource(R.string.no_results),
duration = duration,
modifier = Modifier.fillMaxSize(),
)
}
is ScreensaverItem.Error -> {
ScreensaverPlaceholder(
text = "Error connecting to Jellyfin server",
duration = duration,
modifier = Modifier.fillMaxSize(),
)
}
null,
is ScreensaverItem.CurrentItem,
-> {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.backdropUrl)
.transitionFactory(CrossFadeFactory(2000.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
},
)
var logoError by remember(currentItem) { mutableStateOf(false) }
if (!logoError) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.logoUrl)
.transitionFactory(CrossFadeFactory(750.milliseconds))
.build(),
contentDescription = "Logo",
onError = {
logoError = true
},
modifier =
Modifier
.align(Alignment.BottomStart)
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
} else {
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
.fillMaxWidth(.5f)
.fillMaxHeight(.3f),
) {
Text(
text = currentItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart),
)
}
}
}
}
val largeRadialGradient =
@ -212,3 +250,53 @@ fun AppScreensaverContent(
}
}
}
@Composable
fun ScreensaverPlaceholder(
text: String,
duration: Duration,
modifier: Modifier = Modifier,
) {
var alignment by remember { mutableStateOf(Alignment.BottomStart) }
val alignments =
remember(alignment) {
mutableListOf(
Alignment.TopStart,
Alignment.TopCenter,
Alignment.TopEnd,
Alignment.CenterStart,
Alignment.Center,
Alignment.CenterEnd,
Alignment.BottomStart,
Alignment.BottomCenter,
Alignment.BottomEnd,
).apply { remove(alignment) }
}
LaunchedEffect(Unit) {
while (true) {
delay(duration)
alignment = alignments.random()
}
}
AnimatedContent(
targetState = alignment,
label = "alignment animation",
transitionSpec = {
fadeIn(animationSpec = tween(500, delayMillis = 100))
.togetherWith(fadeOut(animationSpec = tween(500, 100)))
},
modifier = modifier,
) { align ->
Box(Modifier.fillMaxSize()) {
Text(
text = text,
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(align)
.padding(40.dp),
)
}
}
}

View file

@ -66,6 +66,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
@ -74,13 +75,14 @@ import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
@ -133,7 +135,7 @@ class CollectionFolderViewModel
private val savedStateHandle: SavedStateHandle,
api: ApiClient,
@param:ApplicationContext private val context: Context,
private val serverRepository: ServerRepository,
val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
@ -142,6 +144,7 @@ class CollectionFolderViewModel
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
private val musicService: MusicService,
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService,
@Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@ -570,7 +573,44 @@ fun CollectionFolderGrid(
itemId.toServerString(),
initialFilter,
recursive,
onClickItem,
GridClickActions(onClickItem = onClickItem),
sortOptions,
playEnabled,
viewModelKey = viewModelKey,
defaultViewOptions = defaultViewOptions,
modifier = modifier,
initialSortAndDirection = initialSortAndDirection,
showTitle = showTitle,
positionCallback = positionCallback,
useSeriesForPrimary = useSeriesForPrimary,
filterOptions = filterOptions,
focusRequesterOnEmpty = focusRequesterOnEmpty,
)
@Composable
fun CollectionFolderGrid(
preferences: UserPreferences,
itemId: UUID,
initialFilter: CollectionFolderFilter,
recursive: Boolean,
actions: GridClickActions,
sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier,
viewModelKey: String? = itemId.toServerString(),
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
) = CollectionFolderGrid(
preferences,
itemId.toServerString(),
initialFilter,
recursive,
actions,
sortOptions,
playEnabled,
viewModelKey = viewModelKey,
@ -590,7 +630,7 @@ fun CollectionFolderGrid(
itemId: String,
initialFilter: CollectionFolderFilter,
recursive: Boolean,
onClickItem: (Int, BaseItem) -> Unit,
actions: GridClickActions,
sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
@ -625,11 +665,106 @@ fun CollectionFolderGrid(
val item by viewModel.item.observeAsState()
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(viewModel.position, itemId, watched)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(viewModel.position, itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = { viewModel.deleteItem(viewModel.position, it) },
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = { result ->
// Not supported on this page
},
onClearChosenStreams = {
// Not supported on this page
},
)
}
val gridActions =
remember(actions) {
GridClickActions(
onClickItem = actions.onClickItem,
onLongClickItem =
actions.onLongClickItem ?: { position, item ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
onClickPlayAll =
actions.onClickPlayAll ?: { shuffle ->
itemId.toUUIDOrNull()?.let {
val destination =
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
Destination.Slideshow(
parentId = it,
index = 0,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
recursive = true,
startSlideshow = true,
)
} else {
Destination.PlaybackList(
itemId = it,
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
)
}
viewModel.navigateTo(destination)
}
Unit
},
onClickPlayRemoteButton =
actions.onClickPlayRemoteButton ?: { index, item ->
val destination =
if (item.type == BaseItemKind.PHOTO_ALBUM) {
Destination.Slideshow(
parentId = item.id,
index = index,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
recursive = true,
startSlideshow = true,
)
} else {
Destination.Playback(item)
}
viewModel.navigateTo(destination)
},
)
}
when (val state = loading) {
DataLoadingState.Loading,
DataLoadingState.Pending,
@ -662,10 +797,8 @@ fun CollectionFolderGrid(
sortAndDirection = sortAndDirection!!,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = onClickItem,
onLongClickItem = { position, item ->
moreDialog.makePresent(PositionItem(position, item))
},
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
onSortChange = {
viewModel.onSortChange(it, recursive, filter)
},
@ -689,47 +822,8 @@ fun CollectionFolderGrid(
onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = { index, item ->
val destination =
if (item.type == BaseItemKind.PHOTO_ALBUM) {
Destination.Slideshow(
parentId = item.id,
index = index,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
recursive = true,
startSlideshow = true,
)
} else {
Destination.Playback(item)
}
viewModel.navigateTo(destination)
},
onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let {
val destination =
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
Destination.Slideshow(
parentId = it,
index = 0,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
recursive = true,
startSlideshow = true,
)
} else {
Destination.PlaybackList(
itemId = it,
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
)
}
viewModel.navigateTo(destination)
}
},
onClickPlay = gridActions.onClickPlayRemoteButton!!,
onClickPlayAll = gridActions.onClickPlayAll!!,
)
AnimatedVisibility(
@ -751,47 +845,22 @@ fun CollectionFolderGrid(
}
}
}
moreDialog.compose { (position, item) ->
DialogPopup(
showDialog = true,
title = item.title ?: "",
dialogItems =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = null,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) },
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
onClickFavorite = { itemId, watched ->
viewModel.setFavorite(position, itemId, watched)
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = PositionItem(position, item)
},
onClickGoTo = {
onClickItem.invoke(position, it)
},
onClickAddToQueue = {
viewModel.addToQueue(it, 0)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },
dismissOnClick = true,
waitToLoad = true,
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
showPlaylistDialog.compose { itemId ->
@ -811,16 +880,6 @@ fun CollectionFolderGrid(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@ -1002,7 +1061,7 @@ fun CollectionFolderGridContent(
position = newPosition
positionCallback?.invoke(columns, newPosition)
},
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
@ -1011,6 +1070,7 @@ fun CollectionFolderGridContent(
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1051,17 +1111,13 @@ data class PositionItem(
data class CollectionFolderGridParameters(
val columns: Int = 6,
val spacing: Dp = 16.dp,
val cardContent: @Composable (
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1071,13 +1127,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters(
columns = 6,
spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.TALL,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1086,13 +1143,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters(
columns = 4,
spacing = 24.dp,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.Crop,
imageAspectRatio = AspectRatios.WIDE,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1101,13 +1159,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters(
columns = 6,
spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.SQUARE,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1165,3 +1224,10 @@ fun GridTitle(
overflow = TextOverflow.Ellipsis,
modifier = modifier.fillMaxWidth(),
)
data class GridClickActions(
val onClickItem: (Int, BaseItem) -> Unit,
val onLongClickItem: ((Int, BaseItem) -> Unit)? = null,
val onClickPlayAll: ((shuffle: Boolean) -> Unit)? = null,
val onClickPlayRemoteButton: ((Int, BaseItem) -> Unit)? = null,
)

View file

@ -0,0 +1,915 @@
package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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 com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.supportedPlayableTypes
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlin.time.Duration
sealed interface ContextMenu {
data class ForBaseItem(
val fromLongClick: Boolean,
val item: BaseItem,
val chosenStreams: ChosenStreams?,
val showGoTo: Boolean,
val showStreamChoices: Boolean,
val canDelete: Boolean,
val canRemoveContinueWatching: Boolean,
val canRemoveNextUp: Boolean,
val actions: ContextMenuActions,
) : ContextMenu
data class ForPerson(
val fromLongClick: Boolean,
val person: Person,
val actions: PersonContextActions,
) : ContextMenu
data class ForMusic(
val fromLongClick: Boolean,
val item: BaseItem,
val index: Int,
val canDelete: Boolean,
val canRemoveFromQueue: Boolean,
val actions: MusicContextActions,
) : ContextMenu
data class ForQueue(
val fromLongClick: Boolean,
val item: AudioItem,
val index: Int,
val actions: QueueContextActions,
) : ContextMenu
}
data class ContextMenuActions(
val navigateTo: (Destination) -> Unit,
val onShowOverview: (BaseItem) -> Unit,
val onClickWatch: (UUID, Boolean) -> Unit,
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit,
val onDeleteItem: (BaseItem) -> Unit,
val onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit,
val onChooseTracks: (ChosenTrackResult) -> Unit,
val onClearChosenStreams: (ChosenStreams?) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
val onClickAddToQueue: (BaseItem) -> Unit = {},
)
data class PersonContextActions(
val navigateTo: (Destination) -> Unit,
val onClickFavorite: (UUID, Boolean) -> Unit,
)
data class MusicContextActions(
val navigateTo: (Destination) -> Unit,
val onClickPlay: (Int, BaseItem) -> Unit,
val onClickPlayNext: (Int, BaseItem) -> Unit,
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onDeleteItem: (BaseItem) -> Unit,
val onClickAddToQueue: (BaseItem) -> Unit,
val onClickRemoveFromQueue: (Int, BaseItem) -> Unit,
val onClickGoToAlbum: (UUID) -> Unit = {
navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
},
val onClickGoToArtist: (UUID) -> Unit = {
navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
)
data class QueueContextActions(
val onNavigate: (Destination) -> Unit,
val onClickPlay: (Int, AudioItem) -> Unit,
val onClickPlayNext: (Int, AudioItem) -> Unit,
val onClickGoToAlbum: (java.util.UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
},
val onClickGoToArtist: (java.util.UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
)
data class ChosenTrackResult(
val item: BaseItem,
val streamType: MediaStreamType,
val trackIndex: Int,
val itemPlayback: ItemPlayback?,
)
@Composable
fun ContextMenuDialog(
onDismissRequest: () -> Unit,
contextMenu: ContextMenu,
getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?,
preferredSubtitleLanguage: String?,
) {
when (contextMenu) {
is ContextMenu.ForBaseItem -> {
ContextMenu(
onDismissRequest,
contextMenu,
getMediaSource,
preferredSubtitleLanguage,
contextMenu.actions,
)
}
is ContextMenu.ForPerson -> {
ContextMenu(
onDismissRequest = onDismissRequest,
item = contextMenu,
actions = contextMenu.actions,
)
}
is ContextMenu.ForMusic -> {
ContextMenu(
onDismissRequest = onDismissRequest,
item = contextMenu,
actions = contextMenu.actions,
)
}
is ContextMenu.ForQueue -> {
ContextMenu(
onDismissRequest = onDismissRequest,
item = contextMenu,
actions = contextMenu.actions,
)
}
}
}
@Composable
fun ContextMenu(
onDismissRequest: () -> Unit,
contextMenu: ContextMenu.ForBaseItem,
getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?,
preferredSubtitleLanguage: String?,
actions: ContextMenuActions,
) {
val item = contextMenu.item
val chosenStreams = contextMenu.chosenStreams
val context = LocalContext.current
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showPlayWithDialog by remember { mutableStateOf(false) }
val dialogItems =
remember(context, item, chosenStreams) {
buildContextMenuItems(
context = context,
item = item,
watched = item.played,
favorite = item.favorite,
seriesId = item.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
showGoTo = contextMenu.showGoTo,
showStreamChoices = contextMenu.showStreamChoices,
canDelete = contextMenu.canDelete,
canRemoveContinueWatching = contextMenu.canRemoveContinueWatching,
canRemoveNextUp = contextMenu.canRemoveNextUp,
actions = actions,
onChooseVersion = {
chooseVersion =
chooseVersionParams(
context,
item.data.mediaSources.orEmpty(),
chosenStreams?.source?.id?.toUUIDOrNull(),
) { idx ->
val source = item.data.mediaSources!![idx]
actions.onChooseVersion.invoke(item, source)
onDismissRequest.invoke()
}
},
onChooseTracks = { type ->
getMediaSource
?.invoke(
item.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
type = type,
currentIndex =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
} else {
chosenStreams?.subtitleStream?.index
},
onClick = { trackIndex ->
actions.onChooseTracks.invoke(
ChosenTrackResult(
item = item,
streamType = type,
trackIndex = trackIndex,
itemPlayback = chosenStreams?.itemPlayback,
),
)
onDismissRequest.invoke()
},
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
},
onShowOverview = { actions.onShowOverview.invoke(item) },
onClearChosenStreams = {
actions.onClearChosenStreams.invoke(chosenStreams)
},
onClickDelete = { showDeleteDialog = true },
onClickPlayWith = { showPlayWithDialog = true },
)
}
DialogPopup(
showDialog = true,
title = item.title ?: "",
dialogItems = dialogItems,
onDismissRequest = onDismissRequest,
dismissOnClick = false,
waitToLoad = contextMenu.fromLongClick,
)
if (chooseVersion != null) {
chooseVersion?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { chooseVersion = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}
if (showDeleteDialog) {
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = false },
onConfirm = {
actions.onDeleteItem.invoke(item)
onDismissRequest.invoke()
},
)
}
if (showPlayWithDialog) {
val dialogItems =
remember {
buildPlayWith(context) { transcode, backend ->
onDismissRequest.invoke()
actions.navigateTo(
Destination.Playback(
itemId = item.id,
positionMs = item.resumeMs,
forceTranscoding = transcode,
backend = backend,
),
)
}
}
DialogPopup(
showDialog = true,
title = stringResource(R.string.play_with),
dialogItems = dialogItems,
onDismissRequest = { showPlayWithDialog = false },
dismissOnClick = true,
waitToLoad = false,
elevation = 3.dp,
)
}
}
private fun buildContextMenuItems(
context: Context,
item: BaseItem,
seriesId: UUID?,
sourceId: UUID?,
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
showGoTo: Boolean,
showStreamChoices: Boolean,
canDelete: Boolean,
canRemoveContinueWatching: Boolean,
canRemoveNextUp: Boolean,
actions: ContextMenuActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
onClearChosenStreams: () -> Unit,
onClickDelete: () -> Unit,
onClickPlayWith: () -> Unit,
): List<DialogItem> =
buildList {
if (showGoTo) {
add(
DialogItem(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
dismissOnClick = true,
) {
actions.onClickGoTo(item)
},
)
}
if (item.type in supportedPlayableTypes) {
if (item.playbackPosition > Duration.ZERO) {
add(
DialogItem(
context.getString(R.string.resume),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.navigateTo(
Destination.Playback(
item.id,
item.playbackPosition.inWholeMilliseconds,
),
)
},
)
add(
DialogItem(
context.getString(R.string.restart),
Icons.Default.Refresh,
dismissOnClick = true,
) {
actions.navigateTo(
Destination.Playback(
item.id,
0L,
),
)
},
)
} else {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.navigateTo(
Destination.Playback(
item.id,
0L,
),
)
},
)
}
}
if (showStreamChoices) {
item.data.mediaSources?.letNotEmpty { sources ->
val source =
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } }
?: sources.firstOrNull()
source?.mediaStreams?.letNotEmpty { streams ->
val audioCount = streams.count { it.type == MediaStreamType.AUDIO }
val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE }
if (audioCount > 1) {
add(
DialogItem(
context.getString(
R.string.choose_stream,
context.getString(R.string.audio),
),
R.string.fa_volume_low,
dismissOnClick = false,
) {
onChooseTracks.invoke(MediaStreamType.AUDIO)
},
)
}
if (subtitleCount > 0) {
add(
DialogItem(
context.getString(
R.string.choose_stream,
context.getString(R.string.subtitles),
),
R.string.fa_closed_captioning,
dismissOnClick = false,
) {
onChooseTracks.invoke(MediaStreamType.SUBTITLE)
},
)
}
}
if (sources.size > 1) {
add(
DialogItem(
context.getString(
R.string.choose_stream,
context.getString(R.string.version),
),
R.string.fa_file_video,
dismissOnClick = false,
) {
onChooseVersion.invoke()
},
)
}
}
}
if (item.type == BaseItemKind.MUSIC_ALBUM) {
add(
DialogItem(
context.getString(R.string.add_to_queue),
Icons.Default.Add,
dismissOnClick = true,
) {
actions.onClickAddToQueue(item)
},
)
}
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
dismissOnClick = true,
) {
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
dismissOnClick = false,
) {
onClickDelete.invoke()
},
)
}
if (canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) {
add(
DialogItem(
text = R.string.remove_continue_watching,
iconStringRes = R.string.fa_eye,
dismissOnClick = true,
) {
actions.onClickWatch.invoke(item.id, false)
},
)
}
if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
add(
DialogItem(
text = R.string.remove_next_up,
iconStringRes = R.string.fa_tag,
dismissOnClick = true,
) {
actions.onClickRemoveFromNextUp.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
dismissOnClick = true,
) {
actions.onClickWatch.invoke(item.id, !watched)
},
)
add(
DialogItem(
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified,
dismissOnClick = true,
) {
actions.onClickFavorite.invoke(item.id, !favorite)
},
)
item.data.albumId?.let { albumId ->
add(
DialogItem(
context.getString(R.string.go_to_album),
R.string.fa_compact_disc,
dismissOnClick = true,
) {
actions.navigateTo(
Destination.MediaItem(
albumId,
BaseItemKind.MUSIC_ALBUM,
null,
),
)
},
)
}
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
add(
DialogItem(
context.getString(R.string.go_to_artist),
R.string.fa_user,
dismissOnClick = true,
) {
actions.navigateTo(
Destination.MediaItem(
artistId,
BaseItemKind.MUSIC_ARTIST,
null,
),
)
},
)
}
seriesId?.let {
add(
DialogItem(
context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward,
dismissOnClick = true,
) {
actions.navigateTo(
Destination.MediaItem(
seriesId,
BaseItemKind.SERIES,
null,
),
)
},
)
}
if (item.data.mediaSources?.isNotEmpty() == true) {
add(
DialogItem(
context.getString(R.string.media_information),
Icons.Default.Info,
dismissOnClick = true,
) {
onShowOverview.invoke()
},
)
}
if (showStreamChoices && canClearChosenStreams) {
add(
DialogItem(
context.getString(R.string.clear_track_choices),
Icons.Default.Delete,
dismissOnClick = true,
) {
onClearChosenStreams()
},
)
}
if (item.type in supportedPlayableTypes) {
add(
DialogItem(
context.getString(R.string.play_with),
Icons.Default.PlayArrow,
dismissOnClick = false,
onClick = onClickPlayWith,
),
)
}
if (item.data.mediaSources?.isNotEmpty() == true) {
add(
DialogItem(
text = R.string.send_media_info_log_to_server,
iconStringRes = R.string.fa_file_video,
dismissOnClick = true,
) {
actions.onSendMediaInfo.invoke(item.id)
},
)
}
}
private fun buildPlayWith(
context: Context,
onClick: (Boolean, PlayerBackend?) -> Unit,
) = buildList {
val entries =
PlayerBackend.entries
.filterNot { it == PlayerBackend.UNRECOGNIZED }
.zip(context.resources.getStringArray(R.array.player_backend_options))
.filterNot { it.first == PlayerBackend.PREFER_MPV }
entries.forEach { (backend, title) ->
add(
DialogItem(
title,
dismissOnClick = true,
) {
onClick.invoke(false, backend)
},
)
}
add(
DialogItem(
context.getString(R.string.transcoding),
dismissOnClick = true,
) {
onClick.invoke(true, null)
},
)
}
@Composable
fun ContextMenu(
onDismissRequest: () -> Unit,
item: ContextMenu.ForPerson,
actions: PersonContextActions,
) {
val person = item.person
val dialogItems =
buildList {
val itemId = person.id
add(
DialogItem(
stringResource(R.string.go_to),
Icons.Default.ArrowForward,
) {
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
},
)
add(
DialogItem(
text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (person.favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(itemId, !person.favorite)
},
)
}
DialogPopup(
showDialog = true,
title = person.name ?: "",
dialogItems = dialogItems,
onDismissRequest = onDismissRequest,
dismissOnClick = true,
waitToLoad = item.fromLongClick,
)
}
@Composable
fun ContextMenu(
onDismissRequest: () -> Unit,
item: ContextMenu.ForMusic,
actions: MusicContextActions,
) {
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) }
val dialogItems =
remember(context, item, actions) {
buildContextForMusic(context, item, actions, onClickDelete = {
showDeleteDialog = true
})
}
DialogPopup(
showDialog = true,
title = item.item.title ?: "",
dialogItems = dialogItems,
onDismissRequest = onDismissRequest,
dismissOnClick = false,
waitToLoad = item.fromLongClick,
)
if (showDeleteDialog) {
ConfirmDeleteDialog(
itemTitle = item.item.title ?: "",
onCancel = { showDeleteDialog = false },
onConfirm = {
actions.onDeleteItem.invoke(item.item)
onDismissRequest.invoke()
},
)
}
}
fun buildContextForMusic(
context: Context,
music: ContextMenu.ForMusic,
actions: MusicContextActions,
onClickDelete: () -> Unit,
): List<DialogItem> =
buildList {
val item = music.item
val index = music.index
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlay(index, item)
},
)
add(
DialogItem(
context.getString(R.string.play_next),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlayNext(index, item)
},
)
if (music.canRemoveFromQueue) {
add(
DialogItem(
context.getString(R.string.remove_from_queue),
Icons.Default.Delete,
dismissOnClick = true,
) {
actions.onClickRemoveFromQueue(index, item)
},
)
} else {
add(
DialogItem(
context.getString(R.string.add_to_queue),
Icons.Default.Add,
dismissOnClick = true,
) {
actions.onClickAddToQueue(item)
},
)
}
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
dismissOnClick = true,
) {
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (music.canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
dismissOnClick = false,
) {
onClickDelete.invoke()
},
)
}
add(
DialogItem(
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (item.favorite) Color.Red else Color.Unspecified,
dismissOnClick = true,
) {
actions.onClickFavorite.invoke(item.id, !item.favorite)
},
)
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
add(
DialogItem(
context.getString(R.string.go_to_album),
R.string.fa_compact_disc,
dismissOnClick = true,
) {
actions.onClickGoToAlbum.invoke(item.data.albumId!!)
},
)
}
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
add(
DialogItem(
context.getString(R.string.go_to_artist),
R.string.fa_user,
dismissOnClick = true,
) {
actions.onClickGoToArtist.invoke(
item.data.artistItems!!
.first()
.id,
)
},
)
}
}
@Composable
fun ContextMenu(
onDismissRequest: () -> Unit,
item: ContextMenu.ForQueue,
actions: QueueContextActions,
) {
val context = LocalContext.current
val dialogItems =
remember(context, item, actions) {
buildContextForMusicQueue(
context,
item.item,
item.index,
actions,
)
}
DialogPopup(
showDialog = true,
title = item.item.title ?: "",
dialogItems = dialogItems,
onDismissRequest = onDismissRequest,
dismissOnClick = false,
waitToLoad = item.fromLongClick,
)
}
fun buildContextForMusicQueue(
context: Context,
item: AudioItem,
index: Int,
actions: QueueContextActions,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlay(index, item)
},
)
add(
DialogItem(
context.getString(R.string.play_next),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlayNext(index, item)
},
)
add(
DialogItem(
context.getString(R.string.remove_from_queue),
Icons.Default.Delete,
dismissOnClick = true,
) {
actions.onClickRemoveFromQueue(index, item)
},
)
if (item.albumId != null) {
add(
DialogItem(
context.getString(R.string.go_to_album),
Icons.Default.ArrowForward,
dismissOnClick = true,
) {
actions.onClickGoToAlbum.invoke(item.albumId)
},
)
}
if (item.artistId != null) {
add(
DialogItem(
context.getString(R.string.go_to_artist),
Icons.Default.ArrowForward,
dismissOnClick = true,
) {
actions.onClickGoToArtist.invoke(item.artistId)
},
)
}
}

View file

@ -104,11 +104,13 @@ data class DialogItem(
val trailingContent: @Composable (() -> Unit)? = null,
val enabled: Boolean = true,
val selected: Boolean = false,
val dismissOnClick: Boolean = false,
) : DialogItemEntry {
constructor(
@StringRes text: Int,
@StringRes iconStringRes: Int,
iconColor: Color = Color.Unspecified,
dismissOnClick: Boolean = false,
onClick: () -> Unit,
) : this(
headlineContent = {
@ -124,11 +126,13 @@ data class DialogItem(
)
},
onClick = onClick,
dismissOnClick = dismissOnClick,
)
constructor(
text: String,
@StringRes iconStringRes: Int,
dismissOnClick: Boolean = false,
onClick: () -> Unit,
) : this(
headlineContent = {
@ -145,12 +149,14 @@ data class DialogItem(
)
},
onClick = onClick,
dismissOnClick = dismissOnClick,
)
constructor(
text: String,
icon: ImageVector,
iconColor: Color? = null,
dismissOnClick: Boolean = false,
onClick: () -> Unit,
) : this(
headlineContent = {
@ -167,10 +173,12 @@ data class DialogItem(
)
},
onClick = onClick,
dismissOnClick = dismissOnClick,
)
constructor(
text: String,
dismissOnClick: Boolean = false,
onClick: () -> Unit,
) : this(
headlineContent = {
@ -180,6 +188,7 @@ data class DialogItem(
)
},
onClick = onClick,
dismissOnClick = dismissOnClick,
)
companion object {
@ -201,7 +210,7 @@ fun DialogPopup(
dismissOnClick: Boolean = true,
waitToLoad: Boolean = true,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
elevation: Dp = 1.dp,
) {
var waiting by remember { mutableStateOf(waitToLoad) }
if (showDialog) {
@ -296,7 +305,7 @@ fun DialogPopupContent(
selected = item.selected,
enabled = !waiting && item.enabled,
onClick = {
if (dismissOnClick) {
if (dismissOnClick || item.dismissOnClick) {
onDismissRequest.invoke()
}
item.onClick.invoke()
@ -349,7 +358,7 @@ fun DialogPopup(
onDismissRequest: () -> Unit,
dismissOnClick: Boolean = true,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
elevation: Dp = 1.dp,
) = DialogPopup(
showDialog = true,
waitToLoad = params.fromLongClick,
@ -427,7 +436,7 @@ fun ScrollableDialog(
fun BasicDialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
elevation: Dp = 1.dp,
content: @Composable () -> Unit,
) {
Dialog(
@ -458,7 +467,7 @@ fun ConfirmDialog(
onCancel: () -> Unit,
onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
elevation: Dp = 1.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) = BasicDialog(
onDismissRequest = onCancel,
@ -527,10 +536,12 @@ fun ConfirmDeleteDialog(
itemTitle: String,
onCancel: () -> Unit,
onConfirm: () -> Unit,
elevation: Dp = 3.dp,
) {
BasicDialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = elevation,
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,

View file

@ -309,7 +309,7 @@ fun GenreCardGrid(
},
columns = columns,
spacing = spacing,
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GenreCard(
genre = item,
onClick = onClick,

View file

@ -0,0 +1,33 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
/**
* An invisible, no size Box which can be focused.
*
* Used to allow for transitioning to restore hidden content
*/
@Composable
fun HiddenFocusBox(
focusRequester: FocusRequester = remember { FocusRequester() },
onFocus: () -> Unit,
) = Box(
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) onFocus.invoke()
}.focusable(),
)

View file

@ -133,11 +133,12 @@ fun ItemGrid(
showJumpButtons = false,
showLetterButtons = false,
spacing = 24.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
fillWidth = widthPx,
modifier = mod,
imageAspectRatio = AspectRatios.WIDE, // TODO
)

View file

@ -69,6 +69,7 @@ import kotlin.time.Duration.Companion.seconds
*/
@Composable
fun ExpandablePlayButtons(
title: String,
resumePosition: Duration,
watched: Boolean,
favorite: Boolean,
@ -79,7 +80,7 @@ fun ExpandablePlayButtons(
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
deleteOnClick: () -> Unit,
onConfirmDelete: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
@ -163,7 +164,8 @@ fun ExpandablePlayButtons(
if (canDelete) {
item("delete") {
DeleteButton(
onClick = deleteOnClick,
title = title,
onConfirmDelete = onConfirmDelete,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
@ -381,10 +383,12 @@ fun TrailerButton(
@Composable
fun DeleteButton(
onClick: () -> Unit,
onConfirmDelete: () -> Unit,
title: String,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
var showDeleteDialog by remember { mutableStateOf(false) }
val focused by interactionSource.collectIsFocusedAsState()
val iconTint by
animateColorAsState(
@ -415,10 +419,20 @@ fun DeleteButton(
.size(24.dp),
)
},
onClick = { onClick.invoke() },
onClick = { showDeleteDialog = true },
interactionSource = interactionSource,
modifier = modifier,
)
if (showDeleteDialog) {
ConfirmDeleteDialog(
itemTitle = title,
onCancel = { showDeleteDialog = false },
onConfirm = {
onConfirmDelete.invoke()
showDeleteDialog = false
},
)
}
}
@PreviewTvSpec
@ -426,6 +440,7 @@ fun DeleteButton(
private fun ExpandablePlayButtonsPreview() {
WholphinTheme(true) {
ExpandablePlayButtons(
title = "Movie",
resumePosition = 10.seconds,
watched = false,
favorite = false,
@ -437,7 +452,7 @@ private fun ExpandablePlayButtonsPreview() {
trailers = listOf(),
trailerOnClick = {},
canDelete = true,
deleteOnClick = {},
onConfirmDelete = {},
modifier = Modifier,
)
}
@ -482,7 +497,8 @@ private fun ViewOptionsPreview() {
modifier = Modifier,
)
DeleteButton(
onClick = {},
onConfirmDelete = {},
title = "Movie",
)
SortByButton(
sortOptions = listOf(),
@ -491,7 +507,8 @@ private fun ViewOptionsPreview() {
)
}
DeleteButton(
onClick = {},
onConfirmDelete = {},
title = "Movie",
interactionSource = source,
)
}

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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
@ -18,6 +17,7 @@ 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.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -31,11 +31,11 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -60,6 +60,7 @@ import java.util.UUID
abstract class RecommendedViewModel(
@param:ApplicationContext val context: Context,
val api: ApiClient,
val serverRepository: ServerRepository,
val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService,
@ -167,10 +168,9 @@ fun RecommendedContent(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
onFocusPosition: ((RowColumn) -> Unit)? = null,
) {
val context = LocalContext.current
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect {
@ -192,6 +192,35 @@ fun RecommendedContent(
LoadingState.Success -> {
var position by rememberPosition()
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel.navigationManager::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(position, itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = { viewModel.deleteItem(position, it) },
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = { result ->
// Not supported on this page
},
onClearChosenStreams = {
// Not supported on this page
},
)
}
HomePageContent(
homeRows = rows,
position = position,
@ -199,7 +228,18 @@ fun RecommendedContent(
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { position, item ->
moreDialog.makePresent(RowColumnItem(position, item))
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
@ -226,42 +266,22 @@ fun RecommendedContent(
)
}
}
moreDialog.compose { (position, item) ->
DialogPopup(
showDialog = true,
title = item.title ?: "",
dialogItems =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = null,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
onClickFavorite = { itemId, watched ->
viewModel.setFavorite(position, itemId, watched)
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
onClickAddToQueue = {
viewModel.addToQueue(it, 0)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },
dismissOnClick = true,
waitToLoad = true,
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
showPlaylistDialog.compose { itemId ->
@ -281,16 +301,6 @@ fun RecommendedContent(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
data class RowColumnItem(

View file

@ -58,7 +58,7 @@ class RecommendedMovieViewModel
@ApplicationContext context: Context,
api: ApiClient,
musicService: MusicService,
private val serverRepository: ServerRepository,
serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
@ -70,6 +70,7 @@ class RecommendedMovieViewModel
) : RecommendedViewModel(
context,
api,
serverRepository,
navigationManager,
favoriteWatchManager,
mediaReportService,

View file

@ -57,7 +57,7 @@ class RecommendedMusicViewModel
@ApplicationContext context: Context,
api: ApiClient,
musicService: MusicService,
private val serverRepository: ServerRepository,
serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
@ -69,6 +69,7 @@ class RecommendedMusicViewModel
) : RecommendedViewModel(
context,
api,
serverRepository,
navigationManager,
favoriteWatchManager,
mediaReportService,

View file

@ -61,7 +61,7 @@ class RecommendedTvShowViewModel
@ApplicationContext context: Context,
api: ApiClient,
musicService: MusicService,
private val serverRepository: ServerRepository,
serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val lastestNextUpService: LatestNextUpService,
private val suggestionService: SuggestionService,
@ -74,6 +74,7 @@ class RecommendedTvShowViewModel
) : RecommendedViewModel(
context,
api,
serverRepository,
navigationManager,
favoriteWatchManager,
mediaReportService,

View file

@ -205,7 +205,7 @@ fun StudioCardGrid(
},
columns = columns,
spacing = spacing,
cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
StudioCard(
studio = item,
onClick = onClick,

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -34,6 +35,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -51,7 +53,8 @@ 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.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@ -62,10 +65,8 @@ import androidx.tv.material3.LocalContentColor
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.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton
@ -76,6 +77,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.ceil
private const val DEBUG = false
@ -85,6 +87,15 @@ interface CardGridItem {
val sortName: String
}
data class GridItemDetails<T : CardGridItem>(
val item: T?,
val index: Int,
val onClick: () -> Unit,
val onLongClick: () -> Unit,
val widthPx: Int,
val mod: Modifier,
)
/**
* Shows a vertical grid of [CardGridItem]s
*/
@ -102,31 +113,21 @@ fun <T : CardGridItem> CardGrid(
modifier: Modifier = Modifier,
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable (
item: T?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard(
item = item as BaseItem?,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
modifier = mod,
)
},
cardContent: @Composable (GridItemDetails<T>) -> Unit,
columns: Int = 6,
spacing: Dp = 16.dp,
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val startPosition =
remember(initialPosition, pager.size) {
initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
}
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
val currentFocusedIndex by rememberUpdatedState(focusedIndex)
val gridState =
rememberLazyGridState(
cacheWindow = fractionCacheWindow,
cacheWindow = LazyLayoutCacheWindow(aheadFraction = 2f, behindFraction = 0.5f),
initialFirstVisibleItemIndex = focusedIndex,
)
val scope = rememberCoroutineScope()
@ -135,13 +136,16 @@ fun <T : CardGridItem> CardGrid(
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
var alphabetFocus by remember { mutableStateOf(false) }
val focusOn = { index: Int ->
if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index")
if (index != focusedIndex) {
previouslyFocusedIndex = focusedIndex
val focusOn =
remember {
{ index: Int ->
if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index")
if (index != currentFocusedIndex) {
previouslyFocusedIndex = focusedIndex
}
focusedIndex = index
}
}
focusedIndex = index
}
// Wait for a recomposition to focus
val alphabetFocusRequester = remember { FocusRequester() }
@ -180,27 +184,33 @@ fun <T : CardGridItem> CardGrid(
}
}
val jump = { jump: Int ->
scope.launch(ExceptionHandler()) {
val newPosition =
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
if (DEBUG) Timber.d("newPosition=$newPosition")
focusOn(newPosition)
gridState.scrollToItem(newPosition, 0)
}
}
val jumpToTop = {
scope.launch(ExceptionHandler()) {
if (focusedIndex < (columns * 6)) {
// If close, animate the scroll
gridState.animateScrollToItem(0, 0)
} else {
gridState.scrollToItem(0, 0)
val jump =
remember {
{ jump: Int ->
scope.launch(ExceptionHandler()) {
val newPosition =
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
if (DEBUG) Timber.d("newPosition=$newPosition")
focusOn(newPosition)
gridState.scrollToItem(newPosition, 0)
}
}
}
val jumpToTop =
remember {
{
scope.launch(ExceptionHandler()) {
if (currentFocusedIndex < (columns * 6)) {
// If close, animate the scroll
gridState.animateScrollToItem(0, 0)
} else {
gridState.scrollToItem(0, 0)
}
focusOn(0)
zeroFocus.tryRequestFocus()
}
}
focusOn(0)
zeroFocus.tryRequestFocus()
}
}
val jumpToLetter: (Char) -> Unit =
remember {
@ -298,6 +308,9 @@ fun <T : CardGridItem> CardGrid(
modifier = Modifier.align(Alignment.CenterVertically),
)
}
val density = LocalDensity.current
var cardWidthPx by rememberSaveable { mutableIntStateOf(0) }
Box(
modifier = Modifier.weight(1f),
) {
@ -314,57 +327,69 @@ fun <T : CardGridItem> CardGrid(
.focusGroup()
.focusRestorer(firstFocus)
.focusProperties {
onExit = {
// Leaving the grid, so "forget" the position
// focusedIndex = -1
}
onEnter = {
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
focusedIndex = startPosition
}
}
}.onLayoutRectChanged(0, 0) {
val width = it.width
val spacingPx = with(density) { spacing.toPx() }
val cardWidth =
ceil((width - (spacingPx * (columns - 1))) / columns)
cardWidthPx = cardWidth.toInt()
Timber.v("cardWidthPx=%s", cardWidthPx)
},
) {
items(pager.size) { index ->
val mod =
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
.focusRequester(alphabetFocusRequester)
} else {
Modifier
}
val item = pager[index]
cardContent(
item,
{
if (item != null) {
focusedIndex = index
onClickItem.invoke(index, item)
}
},
{ if (item != null) onLongClickItem.invoke(index, item) },
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
val details =
remember(index, item) {
val mod =
if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
.focusRequester(alphabetFocusRequester)
} else {
Modifier
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
// savedFocusedIndex = index
// // Was focused on this, so mark unfocused
// focusedIndex = -1
}
},
)
GridItemDetails(
item = item,
index = index,
onClick = {
if (item != null) {
focusedIndex = index
onClickItem.invoke(index, item)
}
},
onLongClick = {
if (item != null) {
onLongClickItem.invoke(index, item)
}
},
widthPx = cardWidthPx,
mod =
mod
.ifElse(
index == 0,
Modifier.focusRequester(zeroFocus),
).onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
}
},
)
}
cardContent.invoke(details)
}
}
if (pager.isEmpty()) {
@ -429,11 +454,21 @@ fun <T : CardGridItem> CardGrid(
// Add end padding to push away from edge
letterClicked = jumpToLetter,
)
} else {
// Spacer ensures card sizes do not change if the alphabet buttons are not shown
Spacer(
Modifier
.padding(start = 16.dp)
.width(letterHorizontalPadding * 2 + letterButtonSize),
)
}
}
}
}
private val letterHorizontalPadding = 2.dp
private val letterButtonSize = 14.dp
@Composable
fun JumpButtons(
jump1: Int,
@ -500,7 +535,7 @@ fun AlphabetButtons(
var alphabetPickerFocused by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = letterHorizontalPadding),
verticalArrangement = Arrangement.spacedBy(1.1.dp),
state = listState,
modifier =
@ -534,14 +569,14 @@ fun AlphabetButtons(
Box(
modifier =
Modifier
.size(14.dp)
.size(letterButtonSize)
.clip(CircleShape)
.alpha(itemAlpha),
) {
Button(
modifier =
Modifier
.size(14.dp)
.size(letterButtonSize)
.focusRequester(focusRequesters[index]),
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
interactionSource = interactionSource,

View file

@ -23,14 +23,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
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.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.RecommendedMusic
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
@ -40,17 +44,36 @@ import com.github.damontecres.wholphin.ui.data.SongSortOptions
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import javax.inject.Inject
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
@HiltViewModel
@HiltViewModel(assistedFactory = CollectionFolderMusicViewModel.Factory::class)
class CollectionFolderMusicViewModel
@Inject
@AssistedInject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val musicService: MusicService,
) : ViewModel() {
private val navigationManager: NavigationManager,
val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager,
@Assisted private val itemId: UUID,
) : ViewModel(),
RememberTabManager by rememberTabManager {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): CollectionFolderMusicViewModel
}
fun play(item: BaseItem) {
if (item.type == BaseItemKind.AUDIO) {
viewModelScope.launchDefault {
@ -58,6 +81,44 @@ class CollectionFolderMusicViewModel
}
}
}
fun onClick(
index: Int,
item: BaseItem,
) {
if (item.type == BaseItemKind.AUDIO) {
viewModelScope.launchDefault {
musicService.setQueue(listOf(item), false)
}
} else {
navigationManager.navigateTo(item.destination())
}
}
fun onClickPlayAll(shuffle: Boolean) {
viewModelScope.launchDefault {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
parentId = itemId,
includeItemTypes = listOf(BaseItemKind.AUDIO),
recursive = true,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
musicService.setQueue(pager, 0, shuffle)
}
}
fun onClickPlayRemoteButton(
index: Int,
item: BaseItem,
) {
if (item.type == BaseItemKind.AUDIO) {
viewModelScope.launchDefault {
musicService.setQueue(listOf(item), false)
}
}
}
}
@Composable
@ -65,11 +126,13 @@ fun CollectionFolderMusic(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: CollectionFolderMusicViewModel = hiltViewModel(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
viewModel: CollectionFolderMusicViewModel =
hiltViewModel<CollectionFolderMusicViewModel, CollectionFolderMusicViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
) {
val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
val tabs =
listOf(
@ -88,12 +151,22 @@ fun CollectionFolderMusic(
LaunchedEffect(selectedTabIndex) {
logTab("music", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop()
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
viewModel.backdropService.clearBackdrop()
}
var showHeader by rememberSaveable { mutableStateOf(true) }
val actions =
remember {
GridClickActions(
onClickItem = viewModel::onClick,
onLongClickItem = null,
onClickPlayAll = viewModel::onClickPlayAll,
onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton,
)
}
Column(
modifier = modifier,
) {
@ -133,9 +206,7 @@ fun CollectionFolderMusic(
1 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
actions = actions,
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_albums",
initialFilter =
@ -165,9 +236,7 @@ fun CollectionFolderMusic(
2 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
actions = actions,
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_artists",
initialFilter =
@ -209,9 +278,7 @@ fun CollectionFolderMusic(
4 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item ->
viewModel.play(item)
},
actions = actions,
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_songs",
initialFilter =

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
@ -51,22 +52,32 @@ fun CollectionFolderPhotoAlbum(
var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid(
preferences = preferences,
onClickItem = { index, item ->
val destination =
if (item.type == BaseItemKind.PHOTO) {
Destination.Slideshow(
parentId = itemId,
index = index,
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
recursive = recursive,
startSlideshow = false,
)
} else {
item.destination(index)
}
viewModel.navigateTo(destination)
},
actions =
remember {
GridClickActions(
onClickItem = { index, item ->
val destination =
if (item.type == BaseItemKind.PHOTO) {
Destination.Slideshow(
parentId = itemId,
index = index,
filter =
CollectionFolderFilter(
filter = viewModel.filter.value ?: GetItemsFilter(),
),
sortAndDirection =
viewModel.sortAndDirection.value
?: SortAndDirection.DEFAULT,
recursive = recursive,
startSlideshow = false,
)
} else {
item.destination(index)
}
viewModel.navigateTo(destination)
},
)
},
itemId = itemId.toServerString(),
initialFilter = filter,
showTitle = showHeader,

View file

@ -6,21 +6,16 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.supportedPlayableTypes
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@ -36,234 +31,6 @@ data class MoreDialogActions(
val onClickAddToQueue: (BaseItem) -> Unit = {},
)
enum class ClearChosenStreams {
NONE,
ITEM_AND_SERIES,
SERIES,
}
/**
* Build the [DialogItem]s when clicking "More"
*
* If there are multiple versions, adds an option to pick one
*
* If there is more than one (ie two or more) audio track, adds an option to pick one
*
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
*
* @param item the media item to build for, typically an Episode or Movie
* @param seriesId the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
* @param sourceId the item's media source UUID
* @param onChooseVersion callback to pick a version of the item
* @param onChooseTracks callback to pick a track for the given type of the item
* @param onShowOverview callback to show overview dialog with media information
*/
fun buildMoreDialogItems(
context: Context,
item: BaseItem,
seriesId: UUID?,
sourceId: UUID?,
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
canDelete: Boolean,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
onClearChosenStreams: () -> Unit,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.navigateTo(
Destination.Playback(
item.id,
item.resumeMs ?: 0L,
),
)
},
)
item.data.mediaSources?.letNotEmpty { sources ->
val source =
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } }
?: sources.firstOrNull()
source?.mediaStreams?.letNotEmpty { streams ->
val audioCount = streams.count { it.type == MediaStreamType.AUDIO }
val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE }
if (audioCount > 1) {
add(
DialogItem(
context.getString(
R.string.choose_stream,
context.getString(R.string.audio),
),
R.string.fa_volume_low,
) {
onChooseTracks.invoke(MediaStreamType.AUDIO)
},
)
}
if (subtitleCount > 0) {
add(
DialogItem(
context.getString(
R.string.choose_stream,
context.getString(R.string.subtitles),
),
R.string.fa_closed_captioning,
) {
onChooseTracks.invoke(MediaStreamType.SUBTITLE)
},
)
}
}
if (sources.size > 1) {
add(
DialogItem(
context.getString(
R.string.choose_stream,
context.getString(R.string.version),
),
R.string.fa_file_video,
) {
onChooseVersion.invoke()
},
)
}
}
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
) {
actions.onClickWatch.invoke(item.id, !watched)
},
)
add(
DialogItem(
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(item.id, !favorite)
},
)
item.data.albumId?.let { albumId ->
add(
DialogItem(
context.getString(R.string.go_to_album),
R.string.fa_compact_disc,
) {
actions.navigateTo(
Destination.MediaItem(
albumId,
BaseItemKind.MUSIC_ALBUM,
null,
),
)
},
)
}
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
add(
DialogItem(
context.getString(R.string.go_to_artist),
R.string.fa_user,
) {
actions.navigateTo(
Destination.MediaItem(
artistId,
BaseItemKind.MUSIC_ARTIST,
null,
),
)
},
)
}
seriesId?.let {
add(
DialogItem(
context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward,
) {
actions.navigateTo(
Destination.MediaItem(
seriesId,
BaseItemKind.SERIES,
null,
),
)
},
)
}
if (item.data.mediaSources?.isNotEmpty() == true) {
add(
DialogItem(
context.getString(R.string.media_information),
Icons.Default.Info,
) {
onShowOverview.invoke()
},
)
}
if (canClearChosenStreams) {
add(
DialogItem(
context.getString(R.string.clear_track_choices),
Icons.Default.Delete,
) {
onClearChosenStreams()
},
)
}
add(
DialogItem(
context.getString(R.string.play_with_transcoding),
Icons.Default.PlayArrow,
) {
actions.navigateTo(
Destination.Playback(
item.id,
item.resumeMs ?: 0L,
forceTranscoding = true,
),
)
},
)
add(
DialogItem(
text = R.string.send_media_info_log_to_server,
iconStringRes = R.string.fa_file_video,
) {
actions.onSendMediaInfo.invoke(item.id)
},
)
}
fun buildMoreDialogItemsForHome(
context: Context,
item: BaseItem,
@ -424,29 +191,3 @@ fun buildMoreDialogItemsForHome(
},
)
}
fun buildMoreDialogItemsForPerson(
context: Context,
person: Person,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
val itemId = person.id
add(
DialogItem(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
},
)
add(
DialogItem(
text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (person.favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(itemId, !person.favorite)
},
)
}

View file

@ -29,6 +29,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
@ -109,13 +110,20 @@ fun FavoritesPage(
focusRequesters = tabFocusRequesters,
)
}
val actions =
remember {
GridClickActions(
onClickItem = { _, item -> onClickItem.invoke(item) },
)
}
// TODO playEnabled = true for movies & episodes
when (selectedTabIndex) {
// Movies
0 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_movies",
initialFilter =
CollectionFolderFilter(
@ -147,7 +155,7 @@ fun FavoritesPage(
1 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_series",
initialFilter =
CollectionFolderFilter(
@ -179,7 +187,7 @@ fun FavoritesPage(
2 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_episodes",
initialFilter =
CollectionFolderFilter(
@ -212,7 +220,7 @@ fun FavoritesPage(
3 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_videos",
initialFilter =
CollectionFolderFilter(
@ -244,7 +252,7 @@ fun FavoritesPage(
4 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_playlists",
initialFilter =
CollectionFolderFilter(
@ -276,7 +284,7 @@ fun FavoritesPage(
5 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_people",
initialFilter =
CollectionFolderFilter(

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -72,14 +73,15 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.FilterByButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.MusicContextActions
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SortByButton
@ -87,10 +89,8 @@ import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.formatDateTime
@ -371,7 +371,7 @@ fun PlaylistDetails(
val state by viewModel.state.collectAsState()
val musicState by viewModel.musicState.collectAsState()
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
@ -404,34 +404,40 @@ fun PlaylistDetails(
}
}
}
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val musicMoreActions =
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
val musicContextActions =
MusicContextActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
onClickPlayNext = { index, item -> viewModel.playNext(item) },
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
onClickAddPlaylist = { itemId ->
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
showPlaylistDialog.makePresent(itemId)
},
onClickRemoveFromQueue = {},
onClickDelete = { showDeleteDialog = it },
)
val moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
onClickAddPlaylist = { itemId ->
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel::sendMediaReport,
onClickDelete = { showDeleteDialog = it },
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
onClickRemoveFromQueue = { _, _ -> },
onDeleteItem = viewModel::deleteItem,
)
val contextActions =
remember {
ContextMenuActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
onClickAddPlaylist = { itemId ->
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel::sendMediaReport,
onDeleteItem = viewModel::deleteItem,
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
onShowOverview = {},
onChooseVersion = { _, _ -> },
onChooseTracks = {},
onClearChosenStreams = {},
onClickRemoveFromNextUp = {},
)
}
PlaylistDetailsContent(
loadingState = state.loading,
@ -448,43 +454,41 @@ fun PlaylistDetails(
}
},
onLongClickIndex = { index, item ->
longClickDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
if (item.type == BaseItemKind.AUDIO) {
buildMoreDialogForMusic(
context = context,
actions = musicMoreActions,
item = item,
index = index,
canRemove = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
)
} else {
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = item.data.seriesId,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = moreActions,
)
},
)
showContextMenu =
if (item.type == BaseItemKind.AUDIO) {
ContextMenu.ForMusic(
fromLongClick = true,
item = item,
index = index,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveFromQueue = false,
actions = musicContextActions,
)
} else {
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
}
},
filterAndSort = state.filterAndSort,
onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues,
modifier = modifier,
)
longClickDialog?.let { params ->
DialogPopup(
params = params,
onDismissRequest = { longClickDialog = null },
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
showConfirmTypeDialog?.let { (index, item, shuffle) ->
@ -510,16 +514,6 @@ fun PlaylistDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
@Composable
@ -788,6 +782,9 @@ fun PlaylistItem(
isQueued: Boolean = false,
) {
val focused by interactionSource.collectIsFocusedAsState()
val imageWidth = 160.dp
val density = LocalDensity.current
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
ListItem(
selected = false,
onClick = onClick,
@ -850,8 +847,9 @@ fun PlaylistItem(
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
watchedPercent = 0.0,
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
modifier = Modifier.width(160.dp),
modifier = Modifier.width(imageWidth),
useFallbackText = false,
fillWidth = imageWidthPx,
)
}
}

View file

@ -40,7 +40,7 @@ fun CollectionButtons(
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
onClickViewOptions: () -> Unit,
favoriteOnClick: () -> Unit,
deleteOnClick: () -> Unit,
onConfirmDelete: () -> Unit,
canDelete: Boolean,
moreOnClick: () -> Unit,
modifier: Modifier = Modifier,
@ -91,9 +91,9 @@ fun CollectionButtons(
if (canDelete) {
item("delete") {
DeleteButton(
onClick = deleteOnClick,
modifier =
Modifier,
title = state.collection?.title ?: "",
onConfirmDelete = onConfirmDelete,
modifier = Modifier,
)
}
}

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail.collection
import android.content.Context
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -16,9 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -33,7 +28,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -44,12 +38,12 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
@ -57,10 +51,8 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -85,53 +77,58 @@ fun CollectionDetails(
val state by viewModel.state.collectAsState()
// Dialogs
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<Pair<RowColumn?, BaseItem>?>(null) }
var showViewOptionsDialog by remember { mutableStateOf(false) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
fun contextActionsFor(position: RowColumn?) =
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched, position)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite, position)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = { viewModel.deleteItem(it, position) },
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = { result ->
// Not supported on this page
},
onClearChosenStreams = {
// Not supported on this page
},
)
// Actions
val onClickItem =
remember {
{ _: RowColumn, item: BaseItem -> viewModel.navigate(item.destination()) }
{ _: RowColumn, item: BaseItem -> viewModel.navigateTo(item.destination()) }
}
val onLongClickItem =
remember {
{ position: RowColumn, item: BaseItem ->
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = item.data.seriesId,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = viewModel::navigate,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched, position)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite, position)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { item -> showDeleteDialog = Pair(position, item) },
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
),
)
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
title = item.title ?: "",
items = dialogItems,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActionsFor(position),
)
}
}
@ -144,7 +141,7 @@ fun CollectionDetails(
{ filter: GetItemsFilter -> viewModel.changeFilter(filter) }
}
val onClickPlay = { _: RowColumn, item: BaseItem ->
viewModel.navigate(Destination.Playback(item = item))
viewModel.navigateTo(Destination.Playback(item = item))
}
val onClickPlayAll =
remember {
@ -158,7 +155,7 @@ fun CollectionDetails(
sortAndDirection = state.sortAndDirection,
filter = state.itemFilter,
)
viewModel.navigate(dest)
viewModel.navigateTo(dest)
}
}
val onClickViewOptions = remember { { showViewOptionsDialog = true } }
@ -201,7 +198,7 @@ fun CollectionDetails(
}
}
},
deleteOnClick =
onConfirmDelete =
remember {
{
state.collection?.let {
@ -210,43 +207,30 @@ fun CollectionDetails(
}
},
canDelete =
remember(state.collection) {
remember(
state.collection,
preferences.appPreferences.interfacePreferences.enableMediaManagement,
) {
state.collection?.let {
viewModel.canDelete(it, preferences.appPreferences)
} ?: false
},
moreOnClick = {
val collection = state.collection!!
val items =
buildMoreDialogItemsForCollection(
context = context,
item = collection,
favorite = collection.favorite,
canDelete = viewModel.canDelete(collection, preferences.appPreferences),
onClickPlayAll = onClickPlayAll,
actions =
MoreDialogActions(
navigateTo = viewModel::navigate,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched, null)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite, null)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { item -> showDeleteDialog = Pair(null, item) },
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
),
)
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = false,
title = collection.title ?: "",
items = items,
item = state.collection!!,
chosenStreams = null,
showGoTo = false,
showStreamChoices = false,
canDelete =
viewModel.canDelete(
state.collection!!,
preferences.appPreferences,
),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActionsFor(null),
)
},
)
@ -259,14 +243,22 @@ fun CollectionDetails(
onViewOptionsChange = viewModel::changeViewOptions,
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
showPlaylistDialog.compose { itemId ->
@ -286,26 +278,6 @@ fun CollectionDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item, position)
showDeleteDialog = null
},
)
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}
}
@Composable
@ -324,7 +296,7 @@ fun CollectionDetailsContent(
onClickViewOptions: () -> Unit,
overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
deleteOnClick: () -> Unit,
onConfirmDelete: () -> Unit,
canDelete: Boolean,
moreOnClick: () -> Unit,
modifier: Modifier,
@ -363,15 +335,9 @@ fun CollectionDetailsContent(
) {
// This box exists so that there is something focusable above the item content
// allowing focus to move up to restore the collection's header
Box(
modifier =
Modifier
.fillMaxWidth()
.height(0.dp)
.onFocusChanged {
if (it.isFocused) itemsContentHasFocus = false
}.focusable(),
)
HiddenFocusBox {
itemsContentHasFocus = false
}
if (state.viewOptions.cardViewOptions.showDetails) {
HomePageHeader(
item = focusedItem,
@ -429,7 +395,7 @@ fun CollectionDetailsContent(
getPossibleFilterValues = getPossibleFilterValues,
onClickViewOptions = onClickViewOptions,
favoriteOnClick = favoriteOnClick,
deleteOnClick = deleteOnClick,
onConfirmDelete = onConfirmDelete,
canDelete = canDelete,
moreOnClick = moreOnClick,
modifier =
@ -491,60 +457,3 @@ fun CollectionDetailsContent(
}
}
}
fun buildMoreDialogItemsForCollection(
context: Context,
item: BaseItem,
favorite: Boolean,
canDelete: Boolean,
onClickPlayAll: (shuffle: Boolean) -> Unit,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
onClickPlayAll.invoke(false)
},
)
add(
DialogItem(
context.getString(R.string.shuffle),
R.string.fa_shuffle,
) {
onClickPlayAll.invoke(true)
},
)
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(item.id, !favorite)
},
)
}

View file

@ -60,7 +60,7 @@ fun CollectionMixedGrid(
positionCallback = { _, newPosition ->
onFocusPosition.invoke(RowColumn(0, newPosition))
},
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
@ -69,6 +69,7 @@ fun CollectionMixedGrid(
imageAspectRatio = cardViewOptions.aspectRatio.ratio,
imageType = cardViewOptions.imageType,
showTitle = cardViewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
},

View file

@ -382,7 +382,7 @@ class CollectionViewModel
result.totalRecordCount
}
fun navigate(destination: Destination) {
fun navigateTo(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
@ -393,7 +393,9 @@ class CollectionViewModel
position: RowColumn?,
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
favoriteWatchManager.setWatched(itemId, played)
if (position != null) {
if (itemId == state.value.collection?.id) {
refreshCollection()
} else if (position != null) {
refreshItem(itemId, position, false)
}
}
@ -404,11 +406,24 @@ class CollectionViewModel
position: RowColumn?,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
if (position != null) {
if (itemId == state.value.collection?.id) {
refreshCollection()
} else if (position != null) {
refreshItem(itemId, position, false)
}
}
private fun refreshCollection() {
viewModelScope.launchDefault {
val collection =
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it, false) }
_state.update { it.copy(collection = collection) }
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,

View file

@ -142,7 +142,7 @@ fun DiscoverPersonPage(
showJumpButtons = false,
showLetterButtons = false,
spacing = 16.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
DiscoverItemCard(
item = item,
onClick = onClick,

View file

@ -31,33 +31,27 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.chooseStream
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
@ -82,10 +76,8 @@ fun EpisodeDetails(
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val canDelete by viewModel.canDelete.collectAsState()
@ -96,22 +88,36 @@ fun EpisodeDetails(
?.configuration
?.subtitleLanguagePreference
val moreActions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
)
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite,
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = viewModel::deleteItem,
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { item, source ->
viewModel.savePlayVersion(
item,
source.id!!.toUUID(),
)
},
onChooseTracks = { result ->
viewModel.saveTrackSelection(
result.item,
result.itemPlayback,
result.trackIndex,
result.streamType,
)
},
onClearChosenStreams = { viewModel.clearChosenStreams(it) },
)
}
when (val state = loading) {
is LoadingState.Error -> {
@ -152,75 +158,17 @@ fun EpisodeDetails(
ItemDetailsDialogInfo(ep)
},
moreOnClick = {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = false,
title = ep.name + " (${ep.data.productionYear ?: ""})",
items =
buildMoreDialogItems(
context = context,
item = ep,
watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false,
seriesId = ep.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
actions = moreActions,
onChooseVersion = {
chooseVersion =
chooseVersionParams(
context,
ep.data.mediaSources!!,
chosenStreams?.source?.id?.toUUIDOrNull(),
) { idx ->
val source = ep.data.mediaSources!![idx]
viewModel.savePlayVersion(
ep,
source.id!!.toUUID(),
)
}
moreDialog = null
},
onChooseTracks = { type ->
viewModel.streamChoiceService
.chooseSource(
ep.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
currentIndex =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
} else {
chosenStreams?.subtitleStream?.index
},
type = type,
onClick = { trackIndex ->
viewModel.saveTrackSelection(
ep,
chosenStreams?.itemPlayback,
trackIndex,
type,
)
},
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
},
onShowOverview = {
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
if (source != null) {
overviewDialog = ItemDetailsDialogInfo(ep)
}
},
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
canDelete = canDelete,
),
item = ep,
chosenStreams = chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = canDelete,
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
watchOnClick = {
@ -230,7 +178,7 @@ fun EpisodeDetails(
viewModel.setFavorite(ep.id, !ep.favorite)
},
canDelete = canDelete,
deleteOnClick = { showDeleteDialog = ep },
onConfirmDelete = { viewModel.deleteItem(ep) },
modifier = modifier,
)
}
@ -246,24 +194,12 @@ fun EpisodeDetails(
onDismissRequest = { overviewDialog = null },
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
chooseVersion?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { chooseVersion = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = viewModel.streamChoiceService::chooseSource,
contextMenu = contextMenu,
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
showPlaylistDialog.compose { itemId ->
@ -283,16 +219,6 @@ fun EpisodeDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0
@ -308,7 +234,7 @@ fun EpisodeDetailsContent(
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
onConfirmDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -346,6 +272,7 @@ fun EpisodeDetailsContent(
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
ExpandablePlayButtons(
title = ep.title ?: "",
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
favorite = dto.userData?.isFavorite ?: false,
@ -367,7 +294,7 @@ fun EpisodeDetailsContent(
trailers = null,
trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
onConfirmDelete = onConfirmDelete,
modifier =
Modifier
.fillMaxWidth()

View file

@ -20,7 +20,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.combinePair
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
@ -38,9 +37,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -81,13 +77,9 @@ class EpisodeViewModel
init {
init()
viewModelScope.launchDefault {
item
.asFlow()
.filterNotNull()
.combinePair(userPreferencesService.flow.map { it.appPreferences })
.collectLatest { (item, preferences) ->
canDelete.update { mediaManagementService.canDelete(item, preferences) }
}
mediaManagementService.collectCanDelete(item.asFlow()) { canDelete ->
this@EpisodeViewModel.canDelete.update { canDelete }
}
}
}

View file

@ -45,25 +45,20 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.chooseStream
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.components.PersonContextActions
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty
@ -74,11 +69,9 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
@ -101,11 +94,9 @@ fun MovieDetails(
val state by viewModel.state.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
@ -114,22 +105,36 @@ fun MovieDetails(
?.configuration
?.subtitleLanguagePreference
val moreActions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
)
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite,
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = viewModel::deleteItem,
onChooseVersion = { item, source ->
viewModel.savePlayVersion(
item,
source.id!!.toUUID(),
)
},
onChooseTracks = { result ->
viewModel.saveTrackSelection(
result.item,
result.itemPlayback,
result.trackIndex,
result.streamType,
)
},
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onClearChosenStreams = { viewModel.clearChosenStreams(it) },
)
}
when (val s = state.loading) {
is DataLoadingState.Error -> {
@ -183,72 +188,17 @@ fun MovieDetails(
ItemDetailsDialogInfo(movie)
},
moreOnClick = {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = false,
title = movie.name + " (${movie.data.productionYear ?: ""})",
items =
buildMoreDialogItems(
context = context,
item = movie,
watched = movie.data.userData?.played ?: false,
favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
canDelete = state.canDelete,
actions = moreActions,
onChooseVersion = {
chooseVersion =
chooseVersionParams(
context,
movie.data.mediaSources!!,
chosenStreams?.source?.id?.toUUIDOrNull(),
) { idx ->
val source = movie.data.mediaSources!![idx]
viewModel.savePlayVersion(
movie,
source.id!!.toUUID(),
)
}
moreDialog = null
},
onChooseTracks = { type ->
viewModel.streamChoiceService
.chooseSource(
movie.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
type = type,
currentIndex =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
} else {
chosenStreams?.subtitleStream?.index
},
onClick = { trackIndex ->
viewModel.saveTrackSelection(
movie,
chosenStreams?.itemPlayback,
trackIndex,
type,
)
},
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
},
onShowOverview = {
overviewDialog = ItemDetailsDialogInfo(movie)
},
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
),
item = movie,
chosenStreams = chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = state.canDelete,
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
watchOnClick = {
@ -258,36 +208,29 @@ fun MovieDetails(
viewModel.setFavorite(movie.id, !movie.favorite)
},
onLongClickPerson = { index, person ->
val items =
buildMoreDialogItemsForPerson(
context = context,
person = person,
actions = moreActions,
)
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForPerson(
fromLongClick = true,
title = person.name ?: "",
items = items,
person = person,
actions =
PersonContextActions(
navigateTo = viewModel::navigateTo,
onClickFavorite = viewModel::setFavorite,
),
)
},
onLongClickSimilar = { index, similar ->
val items =
buildMoreDialogItemsForHome(
context = context,
item = similar,
seriesId = null,
playbackPosition = similar.playbackPosition,
watched = similar.played,
favorite = similar.favorite,
canDelete = false,
actions = moreActions,
)
moreDialog =
DialogParams(
onLongClickSimilar = { _, similar ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
title = similar.title ?: "",
items = items,
item = similar,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = false,
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
trailerOnClick = {
@ -300,11 +243,19 @@ fun MovieDetails(
viewModel.navigateTo(item.destination)
},
canDelete = state.canDelete,
deleteOnClick = { showDeleteDialog = state.movie },
onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } },
modifier = modifier,
)
}
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = viewModel.streamChoiceService::chooseSource,
contextMenu = contextMenu,
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
@ -315,26 +266,6 @@ fun MovieDetails(
onDismissRequest = { overviewDialog = null },
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
chooseVersion?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { chooseVersion = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
showPlaylistDialog.compose { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
@ -352,16 +283,6 @@ fun MovieDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0
@ -390,7 +311,7 @@ fun MovieDetailsContent(
onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
onConfirmDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -430,6 +351,7 @@ fun MovieDetailsContent(
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
ExpandablePlayButtons(
title = movie.title ?: "",
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
favorite = dto.userData?.isFavorite ?: false,
@ -454,7 +376,7 @@ fun MovieDetailsContent(
trailerOnClick.invoke(it)
},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
onConfirmDelete = onConfirmDelete,
modifier =
Modifier
.fillMaxWidth()

View file

@ -42,7 +42,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
@ -85,18 +85,9 @@ class MovieViewModel
init {
init()
viewModelScope.launchDefault {
userPreferencesService.flow.collectLatest { preferences ->
mediaManagementService.collectCanDelete(state.map { it.movie }) { canDelete ->
_state.update {
val canDelete =
it.movie?.let {
mediaManagementService.canDelete(
it,
preferences.appPreferences,
)
}
it.copy(
canDelete = canDelete ?: false,
)
it.copy(canDelete = canDelete)
}
}
}

View file

@ -61,12 +61,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.MusicContextActions
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
@ -94,6 +94,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
@ -136,6 +137,11 @@ class AlbumViewModel
init {
init()
viewModelScope.launchDefault {
mediaManagementService.collectCanDelete(state.map { it.album }) { canDelete ->
_state.update { it.copy(canDelete = canDelete) }
}
}
}
override fun init() {
@ -275,6 +281,7 @@ data class AlbumState(
val similar: List<BaseItem>,
val loading: LoadingState,
val musicVideos: List<BaseItem?> = emptyList(),
val canDelete: Boolean = false,
) {
companion object {
val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending)
@ -308,22 +315,21 @@ fun AlbumDetailsPage(
val focusManager = LocalFocusManager.current
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
val moreDialogActions =
remember {
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
MusicContextActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, _ -> viewModel.play(false, index) },
onClickPlayNext = { _, song -> viewModel.playNext(song) },
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.AUDIO)
showPlaylistDialog.makePresent(itemId)
},
onClickRemoveFromQueue = {},
onClickDelete = { showDeleteDialog = it },
onClickRemoveFromQueue = { _, _ -> },
onDeleteItem = viewModel::deleteItem,
)
}
when (val loading = state.loading) {
@ -390,6 +396,7 @@ fun AlbumDetailsPage(
modifier = Modifier.fillMaxWidth(),
)
MusicExpandableButtons(
title = album.title ?: "",
actions =
remember {
MusicButtonActions(
@ -402,28 +409,27 @@ fun AlbumDetailsPage(
)
},
onClickMore = {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = false,
title = album.name + " (${album.data.productionYear ?: ""})",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = album,
index = 0,
canRemove = false,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
item = album,
index = 0,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
},
onConfirmDelete = {
viewModel.deleteItem(album)
},
)
},
favorite = album.favorite,
canDelete = state.canDelete,
buttonOnFocusChanged = {
if (it.isFocused) {
position = RowColumn(HEADER_ROW, 0)
@ -459,45 +465,35 @@ fun AlbumDetailsPage(
},
onLongClick = {
if (song != null) {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = true,
title = song.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
item = song,
index = index,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
}
},
onClickMore = {
if (song != null) {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = false,
title = song.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
item = song,
index = index,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
}
},
@ -559,23 +555,18 @@ fun AlbumDetailsPage(
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { index, item ->
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = true,
title = item.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = item,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
item = item,
index = index,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
},
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
@ -597,14 +588,12 @@ fun AlbumDetailsPage(
}
}
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
showPlaylistDialog.compose { itemId ->
@ -624,17 +613,6 @@ fun AlbumDetailsPage(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
showDeleteDialog = null
},
)
}
}
@Composable

View file

@ -59,12 +59,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.MusicContextActions
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
@ -92,6 +92,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
@ -135,6 +136,11 @@ class ArtistViewModel
init {
init()
viewModelScope.launchDefault {
mediaManagementService.collectCanDelete(state.map { it.artist }) { canDelete ->
_state.update { it.copy(canDelete = canDelete) }
}
}
}
override fun init() {
@ -269,6 +275,7 @@ data class ArtistState(
val similar: List<BaseItem>,
val loading: LoadingState,
val musicVideos: List<BaseItem?>,
val canDelete: Boolean,
) {
companion object {
val EMPTY =
@ -280,6 +287,7 @@ data class ArtistState(
emptyList(),
LoadingState.Pending,
emptyList(),
false,
)
}
}
@ -312,22 +320,21 @@ fun ArtistDetailsPage(
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
val moreDialogActions =
remember {
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
MusicContextActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, item -> viewModel.play(item) },
onClickPlayNext = { _, item -> viewModel.playNext(item) },
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.AUDIO)
showPlaylistDialog.makePresent(itemId)
},
onClickRemoveFromQueue = {},
onClickDelete = { showDeleteDialog = it },
onClickRemoveFromQueue = { _, _ -> },
onDeleteItem = viewModel::deleteItem,
)
}
@ -379,6 +386,7 @@ fun ArtistDetailsPage(
modifier = Modifier.fillMaxWidth(),
)
MusicExpandableButtons(
title = artist.title ?: "",
actions =
remember {
MusicButtonActions(
@ -393,28 +401,25 @@ fun ArtistDetailsPage(
)
},
onClickMore = {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = false,
title = artist.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = artist,
index = 0,
canRemove = false,
canDelete =
viewModel.canDelete(
artist,
preferences.appPreferences,
),
item = artist,
index = 0,
canDelete =
viewModel.canDelete(
artist,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
},
onConfirmDelete = { viewModel.deleteItem(artist) },
)
},
favorite = artist.favorite,
canDelete = state.canDelete,
buttonOnFocusChanged = {
if (it.isFocused) {
position = RowColumn(HEADER_ROW, 0)
@ -451,45 +456,35 @@ fun ArtistDetailsPage(
},
onLongClick = {
if (song != null) {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = true,
title = song.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
item = song,
index = index,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
}
},
onClickMore = {
if (song != null) {
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = false,
title = song.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
item = song,
index = index,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
}
},
@ -516,23 +511,18 @@ fun ArtistDetailsPage(
viewModel.navigationManager.navigateTo(album.destination())
},
onLongClickItem = { index, album ->
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = true,
title = album.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = album,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
item = album,
index = index,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
},
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
@ -588,23 +578,18 @@ fun ArtistDetailsPage(
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { index, item ->
moreDialog =
DialogParams(
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = true,
title = item.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = item,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
item = item,
index = index,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
},
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
@ -626,14 +611,12 @@ fun ArtistDetailsPage(
}
}
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
showPlaylistDialog.compose { itemId ->
@ -653,17 +636,6 @@ fun ArtistDetailsPage(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
showDeleteDialog = null
},
)
}
}
@Composable

View file

@ -18,14 +18,17 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.DeleteButton
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import kotlin.time.Duration
@Composable
fun MusicExpandableButtons(
title: String,
actions: MusicButtonActions,
favorite: Boolean,
canDelete: Boolean,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
@ -75,6 +78,15 @@ fun MusicExpandableButtons(
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
if (canDelete) {
item("delete") {
DeleteButton(
title = title,
onConfirmDelete = actions.onConfirmDelete,
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
}
item("more") {
ExpandablePlayButton(
title = R.string.more,
@ -92,4 +104,5 @@ data class MusicButtonActions(
val onClickInstantMix: () -> Unit,
val onClickFavorite: () -> Unit,
val onClickMore: () -> Unit,
val onConfirmDelete: () -> Unit,
)

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.annotation.OptIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@ -22,7 +20,6 @@ import androidx.compose.runtime.remember
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.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -35,7 +32,6 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberPreviousButtonState
import androidx.media3.ui.compose.state.rememberRepeatButtonState
import androidx.media3.ui.compose.state.rememberShuffleButtonState
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -45,11 +41,10 @@ import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import com.github.damontecres.wholphin.ui.playback.buttonSpacing
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlin.time.Duration.Companion.seconds

View file

@ -52,7 +52,7 @@ import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.SeekBar
import com.github.damontecres.wholphin.ui.playback.overlay.SeekBar
import com.github.damontecres.wholphin.ui.preferences.MoveButton
import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.tryRequestFocus

View file

@ -60,15 +60,16 @@ import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.rememberQueue
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QueueContextActions
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.nav.Backdrop
import com.github.damontecres.wholphin.ui.playback.BottomDialog
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.extensions.ticks
@ -129,18 +130,9 @@ fun NowPlayingPage(
)
}
val actions =
remember {
MusicQueueDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, _ -> viewModel.play(index) },
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
onClickRemoveFromQueue = { index, _ -> viewModel.removeFromQueue(index) },
)
}
var showViewOptionsDialog by remember { mutableStateOf(false) }
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) }
var lyricsHaveFocus by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
@ -209,6 +201,7 @@ fun NowPlayingPage(
remember {
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
}
//noinspection UnnecessaryFullyQualifiedName
androidx.compose.animation.AnimatedVisibility(
visible = musicPrefs.showAlbumArt,
enter = enter,
@ -250,6 +243,7 @@ fun NowPlayingPage(
}
}
}
//noinspection UnnecessaryFullyQualifiedName
androidx.compose.animation.AnimatedVisibility(
visible = musicPrefs.showVisualizer,
enter = enter,
@ -308,17 +302,21 @@ fun NowPlayingPage(
val showContextForItem =
remember {
{ fromLongClick: Boolean, index: Int, song: AudioItem ->
itemMoreDialog =
DialogParams(
title = song.title ?: "",
showContextMenu =
ContextMenu.ForQueue(
fromLongClick = fromLongClick,
items =
buildMoreDialogForMusicQueue(
context = context,
actions = actions,
item = song,
index = index,
canRemove = true,
item = song,
index = index,
actions =
QueueContextActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, _ -> viewModel.play(index) },
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
onClickRemoveFromQueue = { index, _ ->
viewModel.removeFromQueue(
index,
)
},
),
)
}
@ -368,14 +366,12 @@ fun NowPlayingPage(
LoadingPage(focusEnabled = false)
}
}
itemMoreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { itemMoreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
if (showViewOptionsDialog) {

View file

@ -1,20 +1,8 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import org.jellyfin.sdk.api.client.ApiClient
@ -23,193 +11,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
data class MusicMoreDialogActions(
val onNavigate: (Destination) -> Unit,
val onClickPlay: (Int, BaseItem) -> Unit,
val onClickPlayNext: (Int, BaseItem) -> Unit,
val onClickAddToQueue: (Int, BaseItem) -> Unit,
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onClickGoToAlbum: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
},
val onClickGoToArtist: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
val onClickRemoveFromQueue: (Int) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
)
fun buildMoreDialogForMusic(
context: Context,
actions: MusicMoreDialogActions,
item: BaseItem,
index: Int,
canRemove: Boolean,
canDelete: Boolean,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.onClickPlay(index, item)
},
)
add(
DialogItem(
context.getString(R.string.play_next),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.onClickPlayNext(index, item)
},
)
if (canRemove) {
add(
DialogItem(
context.getString(R.string.remove_from_queue),
Icons.Default.Delete,
) {
actions.onClickRemoveFromQueue(index)
},
)
}
add(
DialogItem(
context.getString(R.string.add_to_queue),
Icons.Default.Add,
) {
actions.onClickAddToQueue(index, item)
},
)
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (item.favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(item.id, !item.favorite)
},
)
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
add(
DialogItem(
context.getString(R.string.go_to_album),
R.string.fa_compact_disc,
) {
actions.onClickGoToAlbum.invoke(item.data.albumId!!)
},
)
}
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
add(
DialogItem(
context.getString(R.string.go_to_artist),
R.string.fa_user,
) {
actions.onClickGoToArtist.invoke(
item.data.artistItems!!
.first()
.id,
)
},
)
}
}
data class MusicQueueDialogActions(
val onNavigate: (Destination) -> Unit,
val onClickPlay: (Int, AudioItem) -> Unit,
val onClickPlayNext: (Int, AudioItem) -> Unit,
val onClickGoToAlbum: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
},
val onClickGoToArtist: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
)
fun buildMoreDialogForMusicQueue(
context: Context,
actions: MusicQueueDialogActions,
item: AudioItem,
index: Int,
canRemove: Boolean,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.onClickPlay(index, item)
},
)
add(
DialogItem(
context.getString(R.string.play_next),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.onClickPlayNext(index, item)
},
)
if (canRemove) {
add(
DialogItem(
context.getString(R.string.remove_from_queue),
Icons.Default.Delete,
) {
actions.onClickRemoveFromQueue(index, item)
},
)
}
if (item.albumId != null) {
add(
DialogItem(
context.getString(R.string.go_to_album),
Icons.Default.ArrowForward,
) {
actions.onClickGoToAlbum.invoke(item.albumId)
},
)
}
if (item.artistId != null) {
add(
DialogItem(
context.getString(R.string.go_to_artist),
Icons.Default.ArrowForward,
) {
actions.onClickGoToArtist.invoke(item.artistId)
},
)
}
}
suspend fun ViewModel.getPagerForAlbum(
api: ApiClient,
albumId: UUID,

View file

@ -26,7 +26,7 @@ fun FocusedEpisodeFooter(
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
onConfirmDelete: () -> Unit,
modifier: Modifier = Modifier,
buttonOnFocusChanged: (FocusState) -> Unit = {},
) {
@ -39,6 +39,7 @@ fun FocusedEpisodeFooter(
modifier = modifier,
) {
ExpandablePlayButtons(
title = ep.title ?: "",
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
favorite = dto.userData?.isFavorite ?: false,
@ -50,7 +51,7 @@ fun FocusedEpisodeFooter(
trailers = null,
trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
onConfirmDelete = onConfirmDelete,
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
@ -57,12 +56,13 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.DeleteButton
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
@ -71,20 +71,17 @@ import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.PersonContextActions
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.TrailerButton
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
@ -95,6 +92,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID
import kotlin.time.Duration
@ -128,10 +126,50 @@ fun SeriesDetails(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
if (itemId == destination.itemId) {
// Confirm if marking whole series
showWatchConfirmation = true
} else {
viewModel.setWatched(itemId, watched, null)
}
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite, null)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = Optional.present(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = viewModel::deleteItem,
onChooseVersion = { item, source ->
viewModel.savePlayVersion(
item,
source.id!!.toUUID(),
)
},
onChooseTracks = { result ->
viewModel.saveTrackSelection(
result.item,
result.itemPlayback,
result.trackIndex,
result.streamType,
)
},
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onClearChosenStreams = {},
)
}
LifecycleResumeEffect(destination.itemId) {
viewModel.refresh()
@ -185,29 +223,18 @@ fun SeriesDetails(
)
},
onLongClickItem = { index, season ->
scope.launchDefault {
seasonDialog =
buildDialogForSeason(
context = context,
s = season,
canDelete = viewModel.canDelete(season),
onClickItem = { viewModel.navigateTo(it.destination()) },
markPlayed = { played ->
viewModel.setSeasonWatched(season.id, played)
},
onClickPlay = { shuffle ->
viewModel.navigateTo(
Destination.PlaybackList(
itemId = season.id,
shuffle = shuffle,
),
)
},
onClickDelete = {
showDeleteDialog = it
},
)
}
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = season,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(season, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
overviewOnClick = {
overviewDialog = ItemDetailsDialogInfo(item)
@ -245,24 +272,10 @@ fun SeriesDetails(
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) },
onClickWatch = { itemId, played ->
viewModel.setWatched(itemId, played, null)
},
onClickFavorite = { itemId, played ->
viewModel.setFavorite(itemId, played, null)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
),
onShowContextMenu = {
showContextMenu = it
},
actions = contextActions,
)
if (showWatchConfirmation) {
ConfirmDialog(
@ -281,6 +294,14 @@ fun SeriesDetails(
}
}
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = viewModel.streamChoiceService::chooseSource,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
@ -288,15 +309,6 @@ fun SeriesDetails(
onDismissRequest = { overviewDialog = null },
)
}
seasonDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
waitToLoad = params.fromLongClick,
onDismissRequest = { seasonDialog = null },
)
}
showPlaylistDialog.compose { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
@ -314,19 +326,6 @@ fun SeriesDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
if (seasons?.lastOrNull()?.id == item.id) {
focusManager.moveFocus(FocusDirection.Previous)
}
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0
@ -359,7 +358,8 @@ fun SeriesDetailsContent(
favoriteOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions,
onShowContextMenu: (ContextMenu) -> Unit,
actions: ContextMenuActions,
onClickDiscover: (Int, DiscoverItem) -> Unit,
discoverSeries: DiscoverItem?,
onClickDiscoverSeries: () -> Unit,
@ -373,7 +373,6 @@ fun SeriesDetailsContent(
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() }
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
Box(
modifier = modifier,
@ -486,9 +485,10 @@ fun SeriesDetailsContent(
)
if (canDelete) {
DeleteButton(
onClick = {
title = series.title ?: "",
onConfirmDelete = {
position = HEADER_ROW
moreActions.onClickDelete.invoke(series)
actions.onDeleteItem.invoke(series)
},
modifier =
Modifier
@ -561,18 +561,17 @@ fun SeriesDetailsContent(
},
onLongClick = { index, person ->
position = PEOPLE_ROW
val items =
buildMoreDialogItemsForPerson(
context = context,
person = person,
actions = moreActions,
)
moreDialog =
DialogParams(
onShowContextMenu.invoke(
ContextMenu.ForPerson(
fromLongClick = true,
title = person.name ?: "",
items = items,
)
person = person,
actions =
PersonContextActions(
navigateTo = actions.navigateTo,
onClickFavorite = actions.onClickFavorite,
),
),
)
},
modifier =
Modifier
@ -608,23 +607,19 @@ fun SeriesDetailsContent(
},
onLongClickItem = { index, item ->
position = SIMILAR_ROW
val items =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = null,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
actions = moreActions,
canDelete = false,
)
moreDialog =
DialogParams(
onShowContextMenu.invoke(
ContextMenu.ForBaseItem(
fromLongClick = true,
title = item.name ?: "",
items = items,
)
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = false,
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = actions,
),
)
},
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
@ -666,16 +661,6 @@ fun SeriesDetailsContent(
}
}
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}
@Composable

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -20,41 +21,30 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.map
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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.chooseStream
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
@ -98,16 +88,19 @@ fun SeriesOverview(
val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() }
val extrasRowFocusRequester = remember { FocusRequester() }
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(listOf())
val seasons by viewModel.seasons.observeAsState(emptyList())
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
val seasonExtras by viewModel.extras.observeAsState(emptyList())
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList())
val episodeList = (episodes as? EpisodeList.Success)?.episodes
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
val currentPosition by rememberUpdatedState(position)
LaunchedEffect(Unit) {
if (seasons.isNotEmpty()) {
seasons.getOrNull(position.seasonTabIndex)?.let {
@ -117,13 +110,54 @@ fun SeriesOverview(
}
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt()
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched, currentPosition.episodeRowIndex)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = itemId
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = viewModel::deleteItem,
onChooseVersion = { item, source ->
viewModel.savePlayVersion(
item,
source.id!!.toUUID(),
)
},
onChooseTracks = { result ->
viewModel.saveTrackSelection(
result.item,
result.itemPlayback,
result.trackIndex,
result.streamType,
)
},
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onClearChosenStreams = {
val focusedEpisode =
(episodes as? EpisodeList.Success)
?.episodes
?.getOrNull(currentPosition.episodeRowIndex)
if (focusedEpisode != null) {
viewModel.clearChosenStreams(focusedEpisode, it)
}
},
)
}
LaunchedEffect(position, episodes) {
val focusedEpisode =
@ -164,6 +198,7 @@ fun SeriesOverview(
EPISODE_ROW -> episodeRowFocusRequester
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
GUEST_STAR_ROW -> guestStarRowFocusRequester
EXTRAS_ROW -> extrasRowFocusRequester
else -> episodeRowFocusRequester
},
"series_overview",
@ -176,103 +211,6 @@ fun SeriesOverview(
}
}
suspend fun buildMoreForEpisode(
ep: BaseItem,
chosenStreams: ChosenStreams?,
fromLongClick: Boolean,
): DialogParams =
DialogParams(
fromLongClick = fromLongClick,
title = series.name + " - " + ep.data.seasonEpisode,
items =
buildMoreDialogItems(
context = context,
item = ep,
watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false,
seriesId = series.id,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete(ep),
actions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(
itemId,
watched,
position.episodeRowIndex,
)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(
itemId,
favorite,
position.episodeRowIndex,
)
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = it
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
),
onChooseVersion = {
chooseVersion =
chooseVersionParams(
context,
ep.data.mediaSources!!,
chosenStreams?.source?.id?.toUUIDOrNull(),
) { idx ->
val source = ep.data.mediaSources!![idx]
viewModel.savePlayVersion(
ep,
source.id!!.toUUID(),
)
}
moreDialog = null
},
onChooseTracks = { type ->
viewModel.streamChoiceService
.chooseSource(
ep.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
type = type,
currentIndex =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
} else {
chosenStreams?.subtitleStream?.index
},
onClick = { trackIndex ->
viewModel.saveTrackSelection(
ep,
chosenStreams?.itemPlayback,
trackIndex,
type,
)
},
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
},
onShowOverview = {
overviewDialog = ItemDetailsDialogInfo(ep)
},
onClearChosenStreams = {
viewModel.clearChosenStreams(ep, chosenStreams)
},
),
)
SeriesOverviewContent(
preferences = preferences,
series = series,
@ -280,11 +218,13 @@ fun SeriesOverview(
episodes = episodes,
chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode,
seasonExtras = seasonExtras,
position = position,
firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester,
extrasRowFocusRequester = extrasRowFocusRequester,
onChangeSeason = { index ->
if (index != position.seasonTabIndex) {
seasons.getOrNull(index)?.let { season ->
@ -314,9 +254,18 @@ fun SeriesOverview(
)
},
onLongClick = { ep ->
scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
}
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = ep,
chosenStreams = chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
playOnClick = { resume ->
rowFocused = EPISODE_ROW
@ -344,9 +293,18 @@ fun SeriesOverview(
},
moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
}
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = false,
item = ep,
chosenStreams = chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
}
},
overviewOnClick = {
@ -364,14 +322,25 @@ fun SeriesOverview(
),
)
},
onClickExtra = { _, extra ->
rowFocused = EXTRAS_ROW
viewModel.navigateTo(extra.destination)
},
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
deleteOnClick = { showDeleteDialog = it },
onConfirmDelete = viewModel::deleteItem,
modifier = modifier,
)
}
}
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = viewModel.streamChoiceService::chooseSource,
contextMenu = contextMenu,
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
@ -382,26 +351,6 @@ fun SeriesOverview(
onDismissRequest = { overviewDialog = null },
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
chooseVersion?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { chooseVersion = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
showPlaylistDialog?.let { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
@ -419,19 +368,9 @@ fun SeriesOverview(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.subtitle ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
episodeRowFocusRequester.tryRequestFocus()
showDeleteDialog = null
},
)
}
}
private const val EPISODE_ROW = 0
private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1
private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1
private const val EXTRAS_ROW = GUEST_STAR_ROW + 1

View file

@ -34,7 +34,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
@ -47,11 +46,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils
@ -74,6 +75,7 @@ fun SeriesOverviewContent(
series: BaseItem,
seasons: List<BaseItem?>,
episodes: EpisodeList,
seasonExtras: List<ExtrasItem>,
chosenStreams: ChosenStreams?,
peopleInEpisode: List<Person>,
position: SeriesOverviewPosition,
@ -81,6 +83,7 @@ fun SeriesOverviewContent(
episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester,
guestStarRowFocusRequester: FocusRequester,
extrasRowFocusRequester: FocusRequester,
onChangeSeason: (Int) -> Unit,
onFocusEpisode: (Int) -> Unit,
onClick: (BaseItem) -> Unit,
@ -92,7 +95,8 @@ fun SeriesOverviewContent(
overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit,
canDelete: (BaseItem) -> Boolean,
deleteOnClick: (BaseItem) -> Unit,
onConfirmDelete: (BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@ -318,7 +322,7 @@ fun SeriesOverviewContent(
}
},
canDelete = canDelete.invoke(ep),
deleteOnClick = { deleteOnClick.invoke(ep) },
onConfirmDelete = { onConfirmDelete.invoke(ep) },
modifier =
Modifier
.padding(top = 4.dp)
@ -365,6 +369,21 @@ fun SeriesOverviewContent(
}
}
}
AnimatedVisibility(
visible = seasonExtras.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
ExtrasRow(
extras = seasonExtras,
onClickItem = onClickExtra,
onLongClickItem = { _, _ -> },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(extrasRowFocusRequester),
)
}
}
}
}

View file

@ -59,6 +59,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@ -138,7 +139,11 @@ class SeriesViewModel
Timber.v("Start")
addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId)
canDeleteSeries.update { mediaManagementService.canDelete(item) }
viewModelScope.launchDefault {
mediaManagementService.collectCanDelete(flowOf(item)) { canDelete ->
canDeleteSeries.update { canDelete }
}
}
backdropService.submit(item)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
@ -184,6 +189,10 @@ class SeriesViewModel
it.copy(seasonTabIndex = index.coerceAtLeast(0))
}
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
}
}
val remoteTrailers = trailerService.getRemoteTrailers(item)
withContext(Dispatchers.Main) {
@ -388,6 +397,7 @@ class SeriesViewModel
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
this@SeriesViewModel.episodes.value = EpisodeList.Loading
this@SeriesViewModel.extras.value = emptyList()
}
viewModelScope.launchIO(ExceptionHandler(true)) {
val episodes =
@ -401,6 +411,10 @@ class SeriesViewModel
this@SeriesViewModel.episodes.value = episodes
}
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
}
}
fun setWatched(

View file

@ -182,7 +182,7 @@ fun DiscoverRequestGrid(
gridFocusRequester = gridFocusRequester,
showJumpButtons = false,
showLetterButtons = false,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
DiscoverItemCard(
item = item,
onClick = onClick,

View file

@ -204,7 +204,7 @@ fun SeerrRequestsPage(
showJumpButtons = false,
showLetterButtons = false,
spacing = 16.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
DiscoverItemCard(
item = item?.item,
onClick = onClick,

View file

@ -31,9 +31,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -56,24 +58,23 @@ import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.StudioCard
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.RowColumnItem
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
@ -120,9 +121,10 @@ fun HomePage(
}
LoadingState.Success -> {
var dialog by remember { mutableStateOf<DialogParams?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var position by rememberPosition()
@ -144,43 +146,46 @@ fun HomePage(
row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined
val canRemoveNextUp =
row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
seriesId = item.data.seriesId,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveNextUp = canRemoveNextUp,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
canRemoveContinueWatching = canRemoveContinueWatching,
canRemoveNextUp = canRemoveNextUp,
actions =
MoreDialogActions(
ContextMenuActions(
navigateTo = viewModel.navigationManager::navigateTo,
onClickWatch = { itemId, played ->
viewModel.setWatched(itemId, played)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite)
},
onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite,
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = itemId
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = RowColumnItem(position, item)
onDeleteItem = {
viewModel.deleteItem(position, it)
},
onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = {
// Not supported on this page
},
onShowOverview = {
overviewDialog = ItemDetailsDialogInfo(it)
},
onClearChosenStreams = {},
onClickRemoveFromNextUp = viewModel::removeFromNextUp,
),
)
dialog =
DialogParams(
title = item.title ?: "",
fromLongClick = true,
items = dialogItems,
)
}
}
val onClickPlay =
@ -203,10 +208,19 @@ fun HomePage(
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier = modifier,
)
dialog?.let { params ->
DialogPopup(
params = params,
onDismissRequest = { dialog = null },
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath = false,
onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
)
}
showPlaylistDialog?.let { itemId ->
@ -226,19 +240,7 @@ fun HomePage(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
else -> {}
}
}
@ -306,7 +308,15 @@ fun HomePageContent(
focusedItem?.let { onUpdateBackdrop.invoke(it) }
}
Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier =
Modifier
.focusProperties {
onEnter = {
rowFocusRequesters.getOrNull(currentPosition.row)?.tryRequestFocus()
}
}.fillMaxSize(),
) {
headerComposable.invoke(focusedItem)
val density = LocalDensity.current
@ -406,7 +416,7 @@ fun HomePageContent(
}
val onKey =
remember(item) {
{ event: androidx.compose.ui.input.key.KeyEvent ->
{ event: KeyEvent ->
if (isPlayKeyUp(event) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
currentOnClickPlay(

View file

@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
@ -87,26 +88,30 @@ class HomeViewModel
// Refreshing if a load has already occurred and the rows haven't significantly changed
val refresh =
state.loadingState == LoadingState.Success && state.settings == settings
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
Timber.v(
"refresh=%s, state.loadingState=%s, %s rows",
refresh,
state.loadingState,
settings.rows.size,
)
_state.update {
it.copy(
loadingState = if (refresh) LoadingState.Success else LoadingState.Loading,
refreshState = LoadingState.Loading,
settings = settings,
homeRows =
if (refresh) {
it.homeRows
} else {
List(settings.rows.size) { HomeRowLoadingState.Pending("") }
},
)
}
val semaphore = Semaphore(4)
val watchingRowIndexes =
settings.rows
.mapIndexedNotNull { index, row ->
if (isWatchingRow(row.config)) index else null
}
val deferred =
settings.rows
// Load the watching rows first
.sortedByDescending { isWatchingRow(it.config) }
.map { row ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
@ -123,53 +128,63 @@ class HomeViewModel
)
} catch (ex: Exception) {
Timber.e(ex, "Error on row %s", row)
HomeRowLoadingState.Error(row.title, exception = ex)
HomeRowLoadingState.Error(
row.title,
exception = ex,
)
}
}
}
}
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
// Replace watching rows first
Timber.v("Refreshing rows: %s", watchingRowIndexes)
val rows =
deferred
.filterIndexed { index, _ -> index in watchingRowIndexes }
.awaitAll()
_state.update {
val newRows =
it.homeRows.toMutableList().apply {
rows.forEachIndexed { index, row ->
set(watchingRowIndexes[index], row)
}
if (refresh) {
// Replace rows as they complete
val remaining = deferred.withIndex().toMutableList()
while (remaining.isNotEmpty()) {
val (rowIndex, rowData) =
select {
// "Return" the first remaining that is completed
remaining
.forEach { (rowIndex, deferred) ->
deferred.onAwait { rowIndex to it }
}
}
Timber.v("Got row data index=%s", rowIndex)
remaining.removeIf { it.index == rowIndex }
_state.update { state ->
val newRows =
state.homeRows.toMutableList().apply {
set(rowIndex, rowData)
}
state.copy(
homeRows = newRows,
)
}
}
_state.update {
it.copy(
loadingState = LoadingState.Success,
homeRows = newRows,
refreshState = LoadingState.Success,
)
}
} else {
val rows = deferred.awaitAll()
Timber.v("Got all rows")
_state.update {
it.copy(
loadingState = LoadingState.Success,
refreshState = LoadingState.Success,
homeRows = rows,
)
}
}
val rows =
deferred
.awaitAll()
.filter {
// Include only errors & non-empty successes
it is HomeRowLoadingState.Error ||
(it is HomeRowLoadingState.Success && it.items.isNotEmpty())
}
Timber.v("Got all rows")
_state.update {
it.copy(
loadingState = LoadingState.Success,
refreshState = LoadingState.Success,
homeRows = rows,
)
}
Timber.d("Home page load complete")
}
} catch (ex: Exception) {
Timber.e(ex, "Exception during home page loading")
if (state.value.loadingState == LoadingState.Success) {
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
_state.update { it.copy(refreshState = LoadingState.Error(ex)) }
} else {
_state.update {
it.copy(loadingState = LoadingState.Error(ex))

View file

@ -8,7 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -78,8 +78,8 @@ sealed class Destination(
data class Playback(
val itemId: UUID,
val positionMs: Long,
val itemPlayback: ItemPlayback? = null,
val forceTranscoding: Boolean = false,
val backend: PlayerBackend? = null,
) : Destination(true) {
constructor(item: BaseItem) : this(item.id, item.resumeMs)
}

View file

@ -76,9 +76,25 @@ fun DestinationContent(
HomeSettingsPage(preferences, modifier)
}
is Destination.PlaybackList,
is Destination.Playback,
-> {
is Destination.Playback -> {
val backend =
destination.backend ?: preferences.appPreferences.playbackPreferences.playerBackend
if (backend == PlayerBackend.EXTERNAL_PLAYER) {
PlayExternalPage(
preferences = preferences,
destination = destination,
modifier = modifier,
)
} else {
PlaybackPage(
preferences = preferences,
destination = destination,
modifier = modifier,
)
}
}
is Destination.PlaybackList -> {
if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
PlayExternalPage(
preferences = preferences,

View file

@ -8,14 +8,13 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
@ -25,6 +24,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
@ -40,9 +41,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
@ -54,17 +52,22 @@ import org.jellyfin.sdk.api.client.extensions.subtitleApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class)
@HiltViewModel
class PlayExternalViewModel
@AssistedInject
@Inject
constructor(
private val savedStateHandle: SavedStateHandle,
@param:ApplicationContext private val context: Context,
@ -75,16 +78,13 @@ class PlayExternalViewModel
private val streamChoiceService: StreamChoiceService,
private val navigationManager: NavigationManager,
private val userPreferencesService: UserPreferencesService,
@Assisted val destination: Destination,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(destination: Destination): PlayExternalViewModel
}
val launched = savedStateHandle.getMutableStateFlow("launched", false)
val state = MutableStateFlow(PlayExternalState())
fun init() {
fun init(destination: Destination) {
Timber.v("init called: %s", destination)
state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault {
val prefs = userPreferencesService.getCurrent()
val positionMs: Long
@ -106,9 +106,9 @@ class PlayExternalViewModel
}
try {
val queriedItem = api.userLibraryApi.getItem(itemId).content
val base =
val playlistItem =
if (queriedItem.type.playable) {
queriedItem
PlaylistItem.Media(BaseItem(queriedItem))
} else if (destination is Destination.PlaybackList) {
val playlistResult =
playlistCreator.createFrom(
@ -135,18 +135,15 @@ class PlayExternalViewModel
navigationManager.goBack()
return@launchDefault
}
r.playlist.items
.first()
.data
r.playlist.items.first()
}
}
} else {
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
}
val item = BaseItem(base, false)
val playbackConfig =
serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
@ -155,20 +152,25 @@ class PlayExternalViewModel
}
}
}
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
val item =
when (playlistItem) {
is PlaylistItem.Intro -> playlistItem.item
is PlaylistItem.Media -> playlistItem.item
}
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
if (mediaSource == null) {
Timber.w("Media source is null")
return@launchDefault
}
savedStateHandle[KEY_ID] = base.id
savedStateHandle[KEY_ID] = playlistItem.id
savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
val subtitleIndex =
streamChoiceService
.chooseSubtitleStream(
source = mediaSource,
audioStream = null,
seriesId = base.seriesId,
seriesId = item.data.seriesId,
itemPlayback = playbackConfig,
plc = plc,
prefs = prefs,
@ -252,7 +254,17 @@ class PlayExternalViewModel
putExtra("forcedsrt", subtitleUrls[it])
}
}
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
canSeek = false,
itemId = itemId,
isPaused = false,
playMethod = PlayMethod.DIRECT_PLAY,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false,
),
)
state.update {
PlayExternalState(
loading = LoadingState.Success,
@ -278,10 +290,10 @@ class PlayExternalViewModel
return@launchDefault
}
Timber.v(
"Result: result=%s, itemId=%s action=%s",
"Result: result=%s, action=%s, itemId=%s",
result.resultCode,
itemId,
result.data?.action,
itemId,
)
if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
// Vimu return 1 for video completion
@ -295,7 +307,7 @@ class PlayExternalViewModel
position =
data
.getLongExtra("extra_position", Long.MIN_VALUE)
.takeIf { it >= 0 }
.takeIf { it > 0 }
}
// mpv-android: https://mpv-android.github.io/mpv-android/intent.html
@ -326,19 +338,27 @@ class PlayExternalViewModel
}
}
Timber.v("Result position: %s", position?.milliseconds)
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemId,
mediaSourceId = mediaSourceId,
positionTicks = position?.milliseconds?.inWholeTicks,
failed = false,
),
)
if (position != null || result.data?.action != null) {
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemId,
mediaSourceId = mediaSourceId,
positionTicks = position?.milliseconds?.inWholeTicks,
failed = false,
),
)
}
} else {
Timber.w("Activity result: %s", result.resultCode)
Timber.w(
"Activity result: %s, action=%s",
result.resultCode,
result.data?.action,
)
showToast(context, "Unknown result from external player")
}
navigationManager.goBack()
state.update { PlayExternalState() }
launched.update { false }
} catch (_: CancellationException) {
} catch (ex: Exception) {
Timber.e(ex, "Error during external playback of %s", itemId)
@ -359,7 +379,7 @@ class PlayExternalViewModel
}
data class PlayExternalState(
val loading: LoadingState = LoadingState.Loading,
val loading: LoadingState = LoadingState.Pending,
val intent: Intent = Intent(),
)
@ -369,8 +389,8 @@ fun PlayExternalPage(
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlayExternalViewModel =
hiltViewModel<PlayExternalViewModel, PlayExternalViewModel.Factory>(
creationCallback = { it.create(destination) },
hiltViewModel(
viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity,
),
) {
val launcher =
@ -380,15 +400,18 @@ fun PlayExternalPage(
)
val state by viewModel.state.collectAsState()
var launched by rememberSaveable { mutableStateOf(false) }
if (!launched) {
LaunchedEffect(Unit) {
viewModel.init()
val launched by viewModel.launched.collectAsState()
LaunchedEffect(Unit) {
if (!launched) {
viewModel.init(destination)
}
}
when (val l = state.loading) {
LoadingState.Pending,
LoadingState.Pending -> {
LoadingPage(modifier, false)
}
LoadingState.Loading,
-> {
LoadingPage(modifier)
@ -403,7 +426,7 @@ fun PlayExternalPage(
if (!launched) {
LifecycleStartEffect(Unit) {
Timber.i("Launching external playback")
launched = true
viewModel.launched.update { true }
try {
launcher.launch(state.intent)
} catch (ex: Exception) {

View file

@ -1,144 +0,0 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.TranscodingInfo
import timber.log.Timber
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackDebugOverlay(
currentPlayback: CurrentPlayback?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val display =
remember(context) {
try {
val displayManager =
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
} catch (ex: Exception) {
Timber.e(ex)
null
}
}
val displayMode by produceState<String?>(null) {
while (isActive) {
value =
display?.mode?.let {
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
}
delay(10.seconds)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(48.dp),
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
) {
ProvideTextStyle(MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)) {
SimpleTable(
buildList {
add("Backend:" to currentPlayback?.backend?.toString())
add("Play method:" to currentPlayback?.playMethod?.serialName)
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
add("Video Decoder:" to currentPlayback.videoDecoder)
add("Audio Decoder:" to currentPlayback.audioDecoder)
}
add("Display Mode: " to displayMode?.toString())
},
modifier = Modifier.weight(1f, fill = false),
)
currentPlayback?.transcodeInfo?.let {
TranscodeInfo(it, Modifier.weight(2f))
}
}
}
currentPlayback?.tracks?.letNotEmpty {
PlaybackTrackInfo(
trackSupport = it,
)
}
}
}
@Composable
fun TranscodeInfo(
info: TranscodingInfo,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
SimpleTable(
listOf(
"Reason:" to info.transcodeReasons.joinToString(", "),
"HW Accel:" to info.hardwareAccelerationType?.toString(),
"Container:" to info.container,
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
),
)
SimpleTable(
listOf(
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
"Video Direct:" to info.isVideoDirect.toString(),
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
"Audio Direct:" to info.isAudioDirect.toString(),
),
)
}
}
@Composable
fun SimpleTable(
rows: List<Pair<String, String?>>,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
rows.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = it.first,
modifier = Modifier.width(100.dp),
)
Text(
text = it.second.toString(),
modifier = Modifier,
)
}
}
}
}

View file

@ -36,11 +36,14 @@ import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration
enum class PlaybackDialogType {
MORE,
DEBUG,
CAPTIONS,
SETTINGS,
AUDIO,
@ -65,10 +68,10 @@ data class PlaybackSettings(
/**
* Centralized UI component for displaying dialogs during playback
*
* Typically, the user will click something generating a [PlaybackAction] which translates into the
* Typically, the user will click something generating a [com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction] which translates into the
* [PlaybackDialogType] determining which dialog is shown by this component.
*
* @see PlaybackAction
* @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
*/
@Composable
fun PlaybackDialog(
@ -83,28 +86,8 @@ fun PlaybackDialog(
onChangeSubtitleDelay: (Duration) -> Unit,
) {
when (type) {
PlaybackDialogType.MORE -> {
val options =
buildList {
add(
BottomDialogItem(
data = 0,
headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
supporting = null,
),
)
}
BottomDialog(
choices = options,
onDismissRequest = {
onDismissRequest.invoke()
// focusRequester.tryRequestFocus()
},
onSelectChoice = { index, choice ->
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
},
gravity = Gravity.START,
)
PlaybackDialogType.DEBUG -> {
throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG)
}
PlaybackDialogType.CAPTIONS -> {
@ -135,17 +118,8 @@ fun PlaybackDialog(
}
PlaybackDialogType.SETTINGS -> {
val currentAudio =
remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } }
val options =
buildList {
add(
BottomDialogItem(
data = PlaybackDialogType.AUDIO,
headline = stringResource(R.string.audio),
supporting = currentAudio?.displayTitle,
),
)
add(
BottomDialogItem(
data = PlaybackDialogType.PLAYBACK_SPEED,
@ -174,15 +148,26 @@ fun PlaybackDialog(
),
)
}
add(
BottomDialogItem(
data = PlaybackDialogType.DEBUG,
headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
supporting = null,
),
)
}
BottomDialog(
choices = options,
currentChoice = null,
onDismissRequest = onDismissRequest,
onSelectChoice = { _, choice ->
onClickPlaybackDialogType(choice.data)
if (choice.data == PlaybackDialogType.DEBUG) {
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
} else {
onClickPlaybackDialogType(choice.data)
}
},
gravity = Gravity.END,
gravity = Gravity.START,
)
}
@ -193,10 +178,6 @@ fun PlaybackDialog(
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
},
onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index))
@ -220,15 +201,11 @@ fun PlaybackDialog(
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
},
onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
},
gravity = Gravity.END,
gravity = Gravity.START,
)
}
@ -247,15 +224,11 @@ fun PlaybackDialog(
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
},
onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data))
},
gravity = Gravity.END,
gravity = Gravity.START,
)
}

View file

@ -1,705 +0,0 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
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 coil3.compose.AsyncImage
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.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo
import java.time.LocalTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val titleTextSize = 28.sp
private val subtitleTextSize = 18.sp
/**
* The overlay during playback showing controls, seek preview image, debug info, etc
*/
@Composable
fun PlaybackOverlay(
item: BaseItem?,
chapters: List<Chapter>,
playerControls: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
showClock: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean,
currentPlayback: CurrentPlayback?,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
onClickPlaylist: (BaseItem) -> Unit = {},
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
// Will be used for preview/trick play images
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
val density = LocalDensity.current
val titleHeight =
remember(item?.title) {
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight =
remember(item?.subtitleLong) {
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
}
// This will be calculated after composition
var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter,
) {
AnimatedVisibility(
visible = controllerViewState.controlsVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize(),
) {
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(scrimBrush),
)
}
AnimatedVisibility(
visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (seekBarFocused) {
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
val nextState =
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else if (playlist.hasNext()) {
OverlayViewState.QUEUE
} else {
null
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(bottom = 8.dp)
.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() }
},
) {
Controller(
title = item?.title,
subtitle = item?.subtitleLong,
playerControls = playerControls,
controllerViewState = controllerViewState,
showPlay = showPlay,
showClock = showClock,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekProgress = {
onSeekBarChange(it)
seekProgressMs = it
},
seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState,
onNextStateFocus = {
nextState?.let { state = it }
},
currentSegment = currentSegment,
modifier =
Modifier,
// Don't use key events because this control has vertical items so up/down is tough to manage
)
when (nextState) {
OverlayViewState.CHAPTERS -> {
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) state = nextState
}.focusable(),
)
}
OverlayViewState.QUEUE -> {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) state = nextState
}.focusable(),
)
}
else -> {
Spacer(Modifier.height(32.dp))
}
}
}
}
AnimatedVisibility(
visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (chapters.isNotEmpty()) {
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val chapterIndex =
remember {
val position = playerControls.currentPosition.milliseconds
val index =
chapters
.indexOfFirst { it.position > position }
.minus(1)
.let {
if (it < 0) {
// Didn't find a chapter, so it's either the first or last
if (position < chapters.first().position) {
0
} else {
chapters.lastIndex
}
} else {
it
}
}.coerceIn(0, chapters.lastIndex)
index
}
val listState = rememberLazyListState(chapterIndex)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) {
state = OverlayViewState.CONTROLLER
true
} else {
false
}
},
) {
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
state = listState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(chapters) { index, chapter ->
val interactionSource = chapterInteractionSources[index]
val isFocused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
ChapterCard(
name = chapter.name,
position = chapter.position,
imageUrl = chapter.imageUrl,
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
onClick = {
playerControls.seekTo(chapter.position.inWholeMilliseconds)
controllerViewState.hideControls()
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
index == chapterIndex,
Modifier
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester),
).ifElse(
index == 0,
Modifier.focusProperties {
// Prevent scrolling left on first card to prevent moving down
left = FocusRequester.Cancel
},
),
)
}
}
if (playlist.hasNext()) {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(bottom = 8.dp)
.onFocusChanged {
if (it.isFocused) state = OverlayViewState.QUEUE
}.focusable(),
)
}
}
}
}
AnimatedVisibility(
visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (playlist.hasNext()) {
val items = remember { playlist.upcomingItems() }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(8.dp)
.fillMaxWidth()
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) {
if (chapters.isNotEmpty()) {
state = OverlayViewState.CHAPTERS
} else {
state = OverlayViewState.CONTROLLER
}
true
} else if (isDown(e)) {
true
} else {
false
}
},
) {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(items) { index, item ->
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
SeasonCard(
item = item,
onClick = {
onClickPlaylist.invoke(item)
controllerViewState.hideControls()
},
onLongClick = {},
imageHeight = 140.dp,
interactionSource = interactionSource,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
}
}
// Trickplay
AnimatedVisibility(
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.fillMaxWidth(.95f),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
) {
if (trickplayInfo != null) {
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val imageUrl = remember(index) { trickplayUrlFor(index) }
if (imageUrl != null) {
SeekPreviewImage(
modifier = Modifier,
previewImageUrl = imageUrl,
seekProgressMs = seekProgressMs,
trickPlayInfo = trickplayInfo,
)
}
}
Text(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.background(
Color.Black.copy(alpha = 0.6f),
shape = RoundedCornerShape(4.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
// Top
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility(
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
AsyncImage(
model = logoImageUrl,
contentDescription = "Logo",
alignment = Alignment.TopStart,
modifier =
Modifier
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
}
AnimatedVisibility(
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopEnd),
) {
TimeDisplay()
}
AnimatedVisibility(
visible = showDebugInfo && controllerViewState.controlsVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
PlaybackDebugOverlay(
currentPlayback = currentPlayback,
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.background(AppColors.TransparentBlack50),
)
}
}
}
/**
* The view state of the overlay
*/
enum class OverlayViewState {
CONTROLLER,
CHAPTERS,
QUEUE,
}
/**
* A wrapper for the playback controls to show title and other information, plus the actual controls
*
* @see PlaybackControls
*/
@Composable
fun Controller(
title: String?,
playerControls: Player,
controllerViewState: ControllerViewState,
showClock: Boolean,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekProgress: (Long) -> Unit,
nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
val verticalOffset by animateDpAsState(
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
label = "TitleBumpOffset",
animationSpec =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Dp.VisibilityThreshold,
),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.offset(y = verticalOffset),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.End),
) {
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
var endTimeStr by remember { mutableStateOf("...") }
LaunchedEffect(playerControls) {
while (isActive) {
val remaining =
(playerControls.duration - playerControls.currentPosition)
.div(playerControls.playbackParameters.speed)
.toLong()
.milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = getTimeFormatter().format(endTime)
delay(1.seconds)
}
}
Text(
text = "Ends $endTimeStr",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(end = 32.dp),
)
}
}
// TODO need to move these up a level?
val moreFocusRequester = remember { FocusRequester() }
val captionFocusRequester = remember { FocusRequester() }
val settingsFocusRequester = remember { FocusRequester() }
PlaybackControls(
modifier = Modifier.fillMaxWidth(),
playerControls = playerControls,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
onSeekProgress = {
onSeekProgress(it)
},
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment,
onClickPlaybackDialogType = onClickPlaybackDialogType,
moreFocusRequester = moreFocusRequester,
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
)
}
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
@ -28,6 +29,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -35,12 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
@ -74,8 +76,13 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay
import com.github.damontecres.wholphin.ui.playback.overlay.SkipIndicator
import com.github.damontecres.wholphin.ui.playback.overlay.SkipSegmentButton
import com.github.damontecres.wholphin.ui.playback.overlay.rememberSeekBarState
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
@ -167,6 +174,7 @@ fun PlaybackPageContent(
),
)
val currentSegment by viewModel.currentSegment.collectAsState()
val analyticsState by viewModel.analyticsState.collectAsState()
val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
@ -389,7 +397,7 @@ fun PlaybackPageContent(
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
playerControls = player,
player = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
@ -412,6 +420,7 @@ fun PlaybackPageContent(
},
currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
analyticsState = analyticsState,
)
val subtitleSettings =
@ -427,65 +436,81 @@ fun PlaybackPageContent(
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
// Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
AndroidView(
factory = { context ->
SubtitleView(context).apply {
subtitleSettings.let {
setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
playerState.assHandler?.let { assHandler ->
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
Timber.v("Adding AssSubtitleView")
addView(
AssSubtitleView(context, assHandler).apply {
layoutParams =
FrameLayout
.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
).apply { gravity = Gravity.CENTER }
},
)
}
val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
var cueCount by remember { mutableIntStateOf(0) }
val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface
AndroidView(
factory = { context ->
SubtitleView(context).apply {
subtitleSettings.let {
setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
playerState.assHandler?.let { assHandler ->
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
Timber.v("Adding AssSubtitleView")
addView(
AssSubtitleView(context, assHandler).apply {
layoutParams =
FrameLayout
.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
).apply { gravity = Gravity.CENTER }
},
)
}
}
},
update = {
it.setCues(cues)
}
},
update = { subtitleView ->
subtitleView.setCues(cues)
if (cues.size > cueCount) {
// The output creates a painter for each cue, so need to apply the changes when the number of cues increases
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
.apply(it)
it.children.firstOrNull { it is AssSubtitleView }?.let {
(it as? AssSubtitleView)?.apply {
val resized =
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
if (resized) {
Timber.v("Resizing AssSubtitleView: $playerSurfaceSize")
layoutParams =
FrameLayout
.LayoutParams(
playerSurfaceSize.width,
playerSurfaceSize.height,
).apply { gravity = Gravity.CENTER }
}
.apply(subtitleView)
cueCount = cues.size
}
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
(it as? AssSubtitleView)?.apply {
val resized =
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) {
Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize)
layoutParams =
FrameLayout
.LayoutParams(
playerSurfaceSize.width,
playerSurfaceSize.height,
).apply { gravity = Gravity.CENTER }
}
}
},
onReset = {
it.setCues(null)
},
modifier =
Modifier
.fillMaxSize(maxSize)
.align(Alignment.TopCenter)
.background(Color.Transparent)
.ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)),
)
}
}
},
onReset = {
it.setCues(null)
},
modifier =
Modifier
.fillMaxSize(subtitleMaxSize)
.align(Alignment.TopCenter)
.background(Color.Transparent)
.graphicsLayer {
alpha =
if (!subtitleVisible) {
0f
} else if (isImageSubtitles) {
subtitleImageOpacity
} else {
1f
}
},
)
}
// Ask to skip intros, etc button
@ -642,7 +667,15 @@ fun PlaybackPageContent(
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
),
onDismissRequest = {
playbackDialog = null
playbackDialog =
when (type) {
// Go back to settings dialog
PlaybackDialogType.PLAYBACK_SPEED,
PlaybackDialogType.VIDEO_SCALE,
-> PlaybackDialogType.SETTINGS
else -> null
}
if (controllerViewState.controlsVisible) {
controllerViewState.pulseControls()
}

View file

@ -1,122 +0,0 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.util.TrackSupport
/**
* Debug info about the current playback tracks
*/
@Composable
fun PlaybackTrackInfo(
trackSupport: List<TrackSupport>,
modifier: Modifier = Modifier,
) {
val selectedWeight = .5f
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
val textStyle =
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)
LazyColumn(
modifier = modifier,
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
val texts =
listOf(
"ID",
"Type",
"Codec",
"Supported",
"Labels",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
Text(
text = "Selected",
style = textStyle,
)
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
items(trackSupport) { track ->
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier.ifElse(
track.selected,
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
),
) {
val texts =
listOf(
track.id ?: "",
track.type.name,
track.codecs ?: "",
track.supported.name,
track.labels.joinToString(", "),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
if (track.selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(12.dp),
)
} else {
Text(
text = "-",
style = textStyle,
)
}
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
}
}

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import android.media.MediaCodecList
import android.os.Build
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.ui.text.intl.Locale
@ -33,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.PlayerBackend
@ -51,6 +54,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
@ -105,6 +109,7 @@ import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand
@ -118,6 +123,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.Date
import java.util.UUID
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -175,12 +181,13 @@ class PlaybackViewModel
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>()
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
val analyticsState = MutableStateFlow(AnalyticsState())
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
private lateinit var preferences: UserPreferences
internal lateinit var itemId: UUID
internal lateinit var item: BaseItem
internal lateinit var currentItem: PlaylistItem
internal var forceTranscoding: Boolean = false
private var activityListener: TrackActivityPlaybackListener? = null
private val jobs = mutableListOf<Job>()
@ -225,8 +232,11 @@ class PlaybackViewModel
) {
val softwareDecoding =
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
val requestBackend =
(destination as? Destination.Playback)?.backend
?: preferences.appPreferences.playbackPreferences.playerBackend
val playerBackend =
when (preferences.appPreferences.playbackPreferences.playerBackend) {
when (requestBackend) {
PlayerBackend.UNRECOGNIZED,
PlayerBackend.EXO_PLAYER,
-> PlayerBackend.EXO_PLAYER
@ -289,21 +299,18 @@ class PlaybackViewModel
this.forceTranscoding =
(destination as? Destination.Playback)?.forceTranscoding ?: false
val positionMs: Long
val itemPlayback: ItemPlayback?
val forceTranscoding: Boolean
val itemId =
when (val d = destination) {
is Destination.Playback -> {
positionMs = d.positionMs
itemPlayback = d.itemPlayback
forceTranscoding = d.forceTranscoding
d.itemId
}
is Destination.PlaybackList -> {
positionMs = 0
itemPlayback = null
forceTranscoding = false
d.itemId
}
@ -314,9 +321,9 @@ class PlaybackViewModel
}
this.itemId = itemId
val queriedItem = api.userLibraryApi.getItem(itemId).content
val base =
val playlistItem =
if (queriedItem.type.playable) {
queriedItem
PlaylistItem.Media(BaseItem(queriedItem, false))
} else if (destination is Destination.PlaybackList) {
val playlistResult =
playlistCreator.createFrom(
@ -344,9 +351,7 @@ class PlaybackViewModel
this@PlaybackViewModel.playlist.value = r.playlist
}
}
r.playlist.items
.first()
.data
r.playlist.items.first()
}
}
} else {
@ -355,12 +360,39 @@ class PlaybackViewModel
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
val item = BaseItem.from(base, api)
val intros =
// If not resuming playback & cinema mode is enabled, get potential intros
if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) {
api.userLibraryApi
.getIntros(
itemId = playlistItem.id,
userId = serverRepository.currentUser.value?.id,
).content.items
.map {
PlaylistItem.Intro(BaseItem(it))
}
} else {
emptyList()
}
val firstItem =
if (intros.isNotEmpty()) {
Timber.v("Got %s intros", intros.size)
val currentPlaylist =
this@PlaybackViewModel
.playlist.value
?.items
.orEmpty()
val newPlaylist = Playlist(intros + currentPlaylist)
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
intros.first()
} else {
playlistItem
}
val played =
play(
item,
firstItem,
positionMs,
itemPlayback,
forceTranscoding,
)
if (!played) {
@ -370,9 +402,13 @@ class PlaybackViewModel
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
val result = playlistCreator.createFrom(queriedItem)
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = result.playlist
}
val currentPlaylist =
this@PlaybackViewModel
.playlist.value
?.items
.orEmpty()
val newPlaylist = Playlist(currentPlaylist + result.playlist.items)
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
}
}
}
@ -380,18 +416,23 @@ class PlaybackViewModel
/**
* Play an item
*
* @param item the item to play
* @param currentItem the item to play
* @param positionMs the starting playback position in milliseconds
* @param itemPlayback the parameters for playback such chosen subtitle or audio streams
* @param forceTranscoding whether the user has requested to force playback via transcoding
*/
private suspend fun play(
item: BaseItem,
playlistItem: PlaylistItem,
positionMs: Long,
itemPlayback: ItemPlayback? = null,
forceTranscoding: Boolean = this.forceTranscoding,
): Boolean =
withContext(Dispatchers.IO) {
val item =
when (playlistItem) {
is PlaylistItem.Intro -> playlistItem.item
is PlaylistItem.Media -> playlistItem.item
}
Timber.i("Playing ${item.id}")
// New item, so we can clear the media segment tracker & subtitle cues
@ -411,26 +452,25 @@ class PlaybackViewModel
)
return@withContext false
}
this@PlaybackViewModel.item = item
this@PlaybackViewModel.currentItem = playlistItem
this@PlaybackViewModel.itemId = item.id
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
val base = item.data
// Use the provided playback parameters or else check if the database has some
val playbackConfig =
itemPlayback
?: serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
} else {
null
}
val itemPlayback =
serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
} else {
null
}
}
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
}
val mediaSource = streamChoiceService.chooseSource(base, itemPlayback)
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
if (mediaSource == null) {
@ -486,7 +526,7 @@ class PlaybackViewModel
.chooseAudioStream(
source = mediaSource,
seriesId = base.seriesId,
itemPlayback = playbackConfig,
itemPlayback = itemPlayback,
plc = plc,
prefs = preferences,
)
@ -498,7 +538,7 @@ class PlaybackViewModel
source = mediaSource,
audioStream = audioStream,
seriesId = base.seriesId,
itemPlayback = playbackConfig,
itemPlayback = itemPlayback,
plc = plc,
prefs = preferences,
)?.index
@ -506,7 +546,7 @@ class PlaybackViewModel
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
val itemPlaybackToUse =
playbackConfig ?: ItemPlayback(
itemPlayback ?: ItemPlayback(
rowId = -1,
userId = -1,
itemId = base.id,
@ -776,6 +816,7 @@ class PlaybackViewModel
subtitleIndex,
source,
)
Timber.v("onTracksChanged: %s", result)
if (result.bothSelected) {
player.trackSelectionParameters =
result.trackSelectionParameters
@ -885,7 +926,7 @@ class PlaybackViewModel
Timber.d("Changing audio track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = item,
item = currentItem.item,
itemPlayback = currentItemPlayback.value!!,
trackIndex = index,
type = MediaStreamType.AUDIO,
@ -899,7 +940,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = index,
seriesId = item.data.seriesId,
seriesId = currentItem.item.data.seriesId,
subtitleIndex = itemPlayback.subtitleIndex,
prefs = preferences,
)
@ -908,7 +949,7 @@ class PlaybackViewModel
}
changeStreams(
item,
currentItem.item,
itemPlayback,
index,
resolvedSubtitleIndex,
@ -923,7 +964,7 @@ class PlaybackViewModel
Timber.d("Changing subtitle track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = item,
item = currentItem.item,
itemPlayback = currentItemPlayback.value!!,
trackIndex = index,
type = MediaStreamType.SUBTITLE,
@ -937,7 +978,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = itemPlayback.audioIndex,
seriesId = item.data.seriesId,
seriesId = currentItem.item.data.seriesId,
subtitleIndex = index,
prefs = preferences,
)
@ -946,7 +987,7 @@ class PlaybackViewModel
}
changeStreams(
item,
currentItem.item,
itemPlayback,
itemPlayback.audioIndex,
resolvedIndex,
@ -981,7 +1022,7 @@ class PlaybackViewModel
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
): String? =
trickPlayInfo?.let {
val itemId = item.id
val itemId = currentItem.id
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
trickPlayInfo.width,
@ -992,12 +1033,28 @@ class PlaybackViewModel
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
viewModelScope.launchIO {
val nextItem = playlist.value?.peek()
Timber.v("Setting next up to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem
if (nextItem == null) {
Timber.v("Playback state is STATE_ENDED")
viewModelScope.launchDefault {
when (val nextItem = playlist.value?.peek()) {
is PlaylistItem.Intro -> {
Timber.v("Next item is intro, so playing immediately")
playNextUp()
}
is PlaylistItem.Media -> {
if (currentItem is PlaylistItem.Intro) {
Timber.v("Current item is intro, so playing next up immediately")
playNextUp()
} else {
Timber.v("Setting next up to ${nextItem.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem.item
}
}
}
null -> {
Timber.v("No next up")
navigationManager.goBack()
}
}
@ -1063,9 +1120,11 @@ class PlaybackViewModel
outroShownSegments.add(currentSegment.id)
) {
val nextItem = playlist.peek()
Timber.v("Setting next up during outro to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem
if (nextItem is PlaylistItem.Media) {
Timber.v("Setting next up during outro to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem.item
}
}
} else {
val behavior =
@ -1190,7 +1249,7 @@ class PlaybackViewModel
fun playNextUp() {
playlist.value?.let {
if (it.hasNext()) {
viewModelScope.launchIO {
viewModelScope.launchDefault {
cancelUpNextEpisode()
val item = it.getAndAdvance()
val played = play(item, 0)
@ -1205,7 +1264,7 @@ class PlaybackViewModel
fun playPrevious() {
playlist.value?.let {
if (it.hasPrevious()) {
viewModelScope.launchIO {
viewModelScope.launchDefault {
cancelUpNextEpisode()
val item = it.getPreviousAndReverse()
val played = play(item, 0)
@ -1266,7 +1325,7 @@ class PlaybackViewModel
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
changeStreams(
item,
currentItem.item,
currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex,
currentItemPlayback.value?.subtitleIndex,
@ -1358,6 +1417,35 @@ class PlaybackViewModel
}
}
private fun updateDecoder(
decoderName: String,
type: MediaType,
) {
viewModelScope.launchDefault {
val codecInfo =
MediaCodecList(MediaCodecList.ALL_CODECS)
.codecInfos
.firstOrNull { !it.isEncoder && it.name == decoderName }
val decoderString =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (codecInfo?.isHardwareAccelerated == true) {
"$decoderName (HW)"
} else {
decoderName
}
} else {
decoderName
}
currentPlayback.update {
when (type) {
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
else -> throw IllegalArgumentException("Unsupported type: $type")
}
}
}
}
override fun onVideoDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
@ -1365,7 +1453,7 @@ class PlaybackViewModel
initializationDurationMs: Long,
) {
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
currentPlayback.update { it?.copy(videoDecoder = decoderName) }
updateDecoder(decoderName, MediaType.VIDEO)
}
override fun onVideoDisabled(
@ -1384,7 +1472,7 @@ class PlaybackViewModel
decoderReuseEvaluation?.let { decoder ->
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}")
currentPlayback.update { it?.copy(videoDecoder = decoder.decoderName) }
updateDecoder(decoder.decoderName, MediaType.VIDEO)
}
}
}
@ -1396,7 +1484,7 @@ class PlaybackViewModel
initializationDurationMs: Long,
) {
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
currentPlayback.update { it?.copy(audioDecoder = decoderName) }
updateDecoder(decoderName, MediaType.AUDIO)
}
override fun onAudioInputFormatChanged(
@ -1407,7 +1495,7 @@ class PlaybackViewModel
decoderReuseEvaluation?.let { decoder ->
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}")
currentPlayback.update { it?.copy(audioDecoder = decoder.decoderName) }
updateDecoder(decoder.decoderName, MediaType.AUDIO)
}
}
}
@ -1466,6 +1554,37 @@ class PlaybackViewModel
override fun onIsPlayingChanged(isPlaying: Boolean) {
screensaverService.keepScreenOn(isPlaying)
}
override fun onBandwidthEstimate(
eventTime: AnalyticsListener.EventTime,
totalLoadTimeMs: Int,
totalBytesLoaded: Long,
bitrateEstimate: Long,
) {
Timber.v(
"onBandwidthEstimate: totalLoadTimeMs=%s, totalBytesLoaded=%s, bitrateEstimate=%s",
totalLoadTimeMs,
totalBytesLoaded,
bitrateEstimate,
)
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
analyticsState.update {
it.copy(
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
)
}
}
}
override fun onDroppedVideoFrames(
eventTime: AnalyticsListener.EventTime,
droppedFrames: Int,
elapsedMs: Long,
) {
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
}
}
data class PlayerState(
@ -1478,3 +1597,9 @@ data class MediaSegmentState(
val segment: MediaSegmentDto,
val interacted: Boolean,
)
data class AnalyticsState(
val bitrate: String = formatBitrate(0),
val bitrateEstimate: String = formatBitrate(0),
val droppedFrames: Int = 0,
)

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.text.intl.Locale
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.onMain
@ -106,12 +107,13 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts--
delay(1500)
item =
BaseItem.from(
api.userLibraryApi.getItem(itemId = it.itemId).content,
api,
)
mediaSource = streamChoiceService.chooseSource(item.data, it)
val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content)
currentItem =
when (currentItem) {
is PlaylistItem.Intro -> PlaylistItem.Intro(base)
is PlaylistItem.Media -> PlaylistItem.Media(base)
}
mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it)
if (mediaSource == null) {
// This shouldn't happen, but just in case
showToast(
@ -158,7 +160,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
)
}
this@downloadAndSwitchSubtitles.changeStreams(
item,
currentItem.item,
currentItemPlayback.value!!,
audioIndex,
newStream.index,

View file

@ -108,7 +108,7 @@ object TrackSelectionUtils {
true
}
val audioSelected =
if (audioIndex != null && supportsDirectPlay) {
if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) {
val indexToFind =
calculateIndexToFind(
audioIndex,
@ -141,7 +141,7 @@ object TrackSelectionUtils {
}
chosenTrack != null
} else {
audioIndex == null
true
}
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
}

View file

@ -0,0 +1,157 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
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.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.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun ChapterRowOverlay(
player: Player,
controllerViewState: ControllerViewState,
chapters: List<Chapter>,
playlist: Playlist,
aspectRatio: Float,
onChangeState: (OverlayViewState) -> Unit,
modifier: Modifier = Modifier,
) {
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val chapterIndex =
remember {
val position = player.currentPosition.milliseconds
val index =
chapters
.indexOfFirst { it.position > position }
.minus(1)
.let {
if (it < 0) {
// Didn't find a chapter, so it's either the first or last
if (position < chapters.first().position) {
0
} else {
chapters.lastIndex
}
} else {
it
}
}.coerceIn(0, chapters.lastIndex)
index
}
val listState = rememberLazyListState(chapterIndex)
val focusRequester = remember { FocusRequester() }
val hiddenFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
HiddenFocusBox(hiddenFocusRequester) {
onChangeState.invoke(OverlayViewState.CONTROLLER)
}
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
state = listState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
}.focusProperties {
up = hiddenFocusRequester
},
) {
itemsIndexed(chapters) { index, chapter ->
val interactionSource = chapterInteractionSources[index]
val isFocused =
interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
ChapterCard(
name = chapter.name,
position = chapter.position,
imageUrl = chapter.imageUrl,
aspectRatio = aspectRatio,
onClick = {
player.seekTo(chapter.position.inWholeMilliseconds)
controllerViewState.hideControls()
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
index == chapterIndex,
Modifier
.focusRequester(focusRequester)
.bringIntoViewRequester(
bringIntoViewRequester,
),
).ifElse(
index == 0,
Modifier.focusProperties {
// Prevent scrolling left on first card to prevent moving down
left = FocusRequester.Cancel
},
),
)
}
}
if (playlist.hasNext()) {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(bottom = 8.dp)
.onFocusChanged {
if (it.isFocused) onChangeState.invoke(OverlayViewState.QUEUE)
}.focusable(),
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility

View file

@ -0,0 +1,248 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.MediaSegmentDto
import java.time.LocalTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackController(
item: BaseItem?,
nextState: OverlayViewState?,
player: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit,
currentSegment: MediaSegmentDto?,
onChangeState: (OverlayViewState) -> Unit,
modifier: Modifier = Modifier,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Controller(
title = item?.title,
subtitle = item?.subtitleLong,
player = player,
controllerViewState = controllerViewState,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekProgress = onSeekBarChange,
seekBarInteractionSource = seekBarInteractionSource,
currentSegment = currentSegment,
modifier = Modifier,
)
when (nextState) {
OverlayViewState.CHAPTERS -> {
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) onChangeState.invoke(nextState)
}.focusable(),
)
}
OverlayViewState.QUEUE -> {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) onChangeState.invoke(nextState)
}.focusable(),
)
}
else -> {
Spacer(Modifier.height(32.dp))
}
}
}
}
internal val titleTextSize = 28.sp
internal val subtitleTextSize = 18.sp
/**
* A wrapper for the playback controls to show title and other information, plus the actual controls
*
* @see PlaybackControls
*/
@Composable
fun Controller(
title: String?,
player: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekProgress: (Long) -> Unit,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
val verticalOffset by animateDpAsState(
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
label = "TitleBumpOffset",
animationSpec =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Dp.VisibilityThreshold,
),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.offset(y = verticalOffset),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.End),
) {
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
var endTimeStr by remember { mutableStateOf("...") }
LaunchedEffect(player) {
while (isActive) {
val remaining =
(player.duration - player.currentPosition)
.div(player.playbackParameters.speed)
.toLong()
.milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = getTimeFormatter().format(endTime)
delay(1.seconds)
}
}
Text(
text = "Ends $endTimeStr",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(end = 32.dp),
)
}
}
PlaybackControls(
modifier = Modifier.fillMaxWidth(),
player = player,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
onSeekProgress = {
onSeekProgress(it)
},
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment,
onClickPlaybackDialogType = onClickPlaybackDialogType,
)
}
}

View file

@ -1,6 +1,6 @@
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import android.view.Gravity
import androidx.annotation.DrawableRes
@ -69,6 +69,8 @@ import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.skipStringRes
@ -123,13 +125,10 @@ sealed interface PlaybackAction {
@OptIn(UnstableApi::class)
@Composable
fun PlaybackControls(
playerControls: Player,
player: Player,
controllerViewState: ControllerViewState,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
moreFocusRequester: FocusRequester,
captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onSeekProgress: (Long) -> Unit,
showPlay: Boolean,
previousEnabled: Boolean,
@ -164,7 +163,7 @@ fun PlaybackControls(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
SeekBar(
player = playerControls,
player = player,
controllerViewState = controllerViewState,
onSeekProgress = onSeekProgress,
interactionSource = seekBarInteractionSource,
@ -184,13 +183,12 @@ fun PlaybackControls(
.fillMaxWidth(),
) {
LeftPlaybackButtons(
moreFocusRequester = moreFocusRequester,
onControllerInteraction = onControllerInteraction,
onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier.align(Alignment.CenterStart),
)
PlaybackButtons(
player = playerControls,
player = player,
initialFocusRequester = initialFocusRequester,
onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick,
@ -209,7 +207,7 @@ fun PlaybackControls(
TextButton(
stringRes = segment.type.skipStringRes,
onClick = {
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
},
modifier =
Modifier
@ -218,8 +216,6 @@ fun PlaybackControls(
)
}
RightPlaybackButtons(
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
onControllerInteraction = onControllerInteraction,
onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier,
@ -301,7 +297,6 @@ val buttonSpacing = 12.dp
@Composable
fun LeftPlaybackButtons(
moreFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
modifier: Modifier = Modifier,
@ -310,24 +305,21 @@ fun LeftPlaybackButtons(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) {
// More options
PlaybackButton(
iconRes = R.drawable.baseline_more_vert_96,
iconRes = R.drawable.vector_settings,
onClick = {
onControllerInteraction.invoke()
onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE)
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
},
enabled = true,
onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(moreFocusRequester),
modifier = Modifier,
)
}
}
@Composable
fun RightPlaybackButtons(
captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
modifier: Modifier = Modifier,
@ -345,18 +337,18 @@ fun RightPlaybackButtons(
onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS)
},
onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(captionFocusRequester),
modifier = Modifier,
)
// Playback speed, etc
PlaybackButton(
iconRes = R.drawable.vector_settings,
// Audio
PlaybackFaButton(
enabled = true,
iconRes = R.string.fa_volume_high,
onClick = {
onControllerInteraction.invoke()
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
onClickPlaybackDialogType.invoke(PlaybackDialogType.AUDIO)
},
enabled = true,
onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(settingsFocusRequester),
modifier = Modifier,
)
}
}
@ -623,7 +615,7 @@ private fun ButtonPreview() {
onControllerInteraction = {},
)
PlaybackFaButton(
iconRes = R.string.fa_shuffle,
iconRes = R.string.fa_volume_high,
onClick = {},
onControllerInteraction = {},
textColor = Color.Green,

View file

@ -0,0 +1,298 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.TrackSupportReason
import com.github.damontecres.wholphin.util.TrackType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.HardwareAccelerationType
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaSourceType
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodeReason
import org.jellyfin.sdk.model.api.TranscodingInfo
import timber.log.Timber
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackDebugOverlay(
analyticsState: AnalyticsState,
currentPlayback: CurrentPlayback?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val display =
remember(context) {
try {
val displayManager =
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
} catch (ex: Exception) {
Timber.e(ex)
null
}
}
val displayMode by produceState<String?>(null) {
while (isActive) {
value =
display?.mode?.let {
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
}
delay(10.seconds)
}
}
val textStyle =
MaterialTheme.typography.bodySmall.copy(
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface,
)
val memoryUsed by produceState("") {
withContext(Dispatchers.Default) {
while (isActive) {
val runtime = Runtime.getRuntime()
val total = runtime.totalMemory()
val free = runtime.freeMemory()
val used = total - free
val totalMemory = formatBytes(total)
val usedMemory = formatBytes(used)
value = "$usedMemory / $totalMemory"
delay(2.seconds)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
) {
ProvideTextStyle(textStyle) {
SimpleTable(
remember(currentPlayback, displayMode) {
buildList {
add("Backend:" to currentPlayback?.backend?.toString())
add("Play method:" to currentPlayback?.playMethod?.serialName)
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
add("Video Decoder:" to currentPlayback.videoDecoder)
add("Audio Decoder:" to (currentPlayback.audioDecoder ?: "Non-ExoPlayer"))
}
add("Display Mode: " to displayMode)
}
},
modifier = Modifier.weight(1f, fill = false),
keyWidth = 80.dp,
)
SimpleTable(
rows =
listOf(
"Bitrate: " to analyticsState.bitrate,
"Bitrate (est): " to analyticsState.bitrateEstimate,
"Dropped frames: " to analyticsState.droppedFrames,
"Memory" to memoryUsed,
),
keyWidth = 80.dp,
modifier = Modifier.weight(1f, fill = false),
)
currentPlayback?.transcodeInfo?.let { info ->
SimpleTable(
listOf(
"Reason:" to info.transcodeReasons.joinToString(", "),
"HW Accel:" to info.hardwareAccelerationType?.toString(),
"Container:" to info.container,
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
),
modifier = Modifier.weight(1f, fill = false),
keyWidth = 64.dp,
)
SimpleTable(
listOf(
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
"Video Direct:" to info.isVideoDirect.toString(),
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
"Audio Direct:" to info.isAudioDirect.toString(),
),
modifier = Modifier.weight(1f, fill = false),
keyWidth = 64.dp,
)
}
}
}
currentPlayback?.tracks?.letNotEmpty {
PlaybackTrackInfo(
trackSupport = it,
textStyle = textStyle,
)
}
}
}
@Composable
fun SimpleTable(
rows: List<Pair<String, Any?>>,
modifier: Modifier = Modifier,
keyWidth: Dp = 100.dp,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
rows.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = it.first,
modifier = Modifier.width(keyWidth),
)
Text(
text = it.second.toString(),
modifier = Modifier,
)
}
}
}
}
@OptIn(UnstableApi::class)
@PreviewTvSpec
@Composable
fun PlaybackDebugOverlayPreview() {
val currentPlayback =
CurrentPlayback(
item =
BaseItem(
data =
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
),
),
backend = PlayerBackend.EXO_PLAYER,
playMethod = PlayMethod.TRANSCODE,
playSessionId = "123",
liveStreamId = "123",
videoDecoder = "video.decoder.name",
audioDecoder = null, // "audio.decoder.name",
mediaSourceInfo =
MediaSourceInfo(
protocol = MediaProtocol.HTTP,
type = MediaSourceType.DEFAULT,
isRemote = false,
readAtNativeFramerate = true,
ignoreDts = true,
ignoreIndex = true,
genPtsInput = false,
supportsTranscoding = true,
supportsDirectStream = true,
supportsDirectPlay = true,
isInfiniteStream = false,
requiresOpening = false,
requiresClosing = false,
requiresLooping = false,
supportsProbing = true,
transcodingSubProtocol = MediaStreamProtocol.HTTP,
hasSegments = false,
),
transcodeInfo =
TranscodingInfo(
videoCodec = "h264",
audioCodec = "aac",
container = "HLS",
width = 1080,
height = 1920,
isVideoDirect = false,
isAudioDirect = false,
transcodeReasons =
listOf(
TranscodeReason.VIDEO_PROFILE_NOT_SUPPORTED,
TranscodeReason.AUDIO_CHANNELS_NOT_SUPPORTED,
),
hardwareAccelerationType = HardwareAccelerationType.NONE,
),
tracks =
listOf(
TrackSupport(
id = "0",
type = TrackType.VIDEO,
supported = TrackSupportReason.EXCEEDS_CAPABILITIES,
selected = true,
labels = listOf(),
codecs = "avc1",
format = Format.Builder().build(),
),
TrackSupport(
id = "0",
type = TrackType.AUDIO,
supported = TrackSupportReason.HANDLED,
selected = true,
labels = listOf(),
codecs = "ac3",
format = Format.Builder().build(),
),
) +
List(10) {
TrackSupport(
id = "0",
type = TrackType.TEXT,
supported = TrackSupportReason.HANDLED,
selected = false,
labels = listOf(),
codecs = "srt",
format = Format.Builder().build(),
)
},
)
WholphinTheme {
PlaybackDebugOverlay(
analyticsState = AnalyticsState(),
currentPlayback = currentPlayback,
modifier = Modifier.fillMaxSize(),
)
}
}

View file

@ -0,0 +1,399 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* The overlay during playback showing controls, seek preview image, debug info, etc
*/
@Composable
fun PlaybackOverlay(
item: BaseItem?,
chapters: List<Chapter>,
player: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
showClock: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean,
currentPlayback: CurrentPlayback?,
currentSegment: MediaSegmentDto?,
analyticsState: AnalyticsState,
modifier: Modifier = Modifier,
trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
onClickPlaylist: (BaseItem) -> Unit = {},
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
// Will be used for preview/trick play images
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(player.currentPosition) }
var seekProgressPercent = (seekProgressMs.toDouble() / player.duration).toFloat()
val density = LocalDensity.current
val titleHeight =
remember(item?.title) {
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight =
remember(item?.subtitleLong) {
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
}
// This will be calculated after composition
var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember(controllerViewState.controlsVisible) {
mutableStateOf(if (controllerViewState.controlsVisible) OverlayViewState.CONTROLLER else OverlayViewState.HIDDEN)
}
val onChangeState = { newState: OverlayViewState ->
state = newState
}
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter,
) {
AnimatedVisibility(
visible = controllerViewState.controlsVisible && !showDebugInfo,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize(),
) {
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(scrimBrush),
)
}
AnimatedContent(
targetState = state,
label = "controls transition",
transitionSpec = {
if (targetState.ordinal > initialState.ordinal) {
// Moving down, so move content up
(slideInVertically { it / 2 } + fadeIn()).togetherWith(slideOutVertically { -it / 2 } + fadeOut())
} else {
// Moving up
(slideInVertically { -it / 2 } + fadeIn()).togetherWith(slideOutVertically { it / 2 } + fadeOut())
}
},
) { targetState ->
when (targetState) {
OverlayViewState.HIDDEN -> {
// Necessary so the bounds for animation are full width
Box(Modifier.fillMaxWidth())
}
OverlayViewState.CONTROLLER -> {
if (seekBarFocused) {
LaunchedEffect(Unit) {
seekProgressPercent =
(player.currentPosition.toFloat() / player.duration)
}
}
val nextState =
remember(chapters, playlist) {
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else if (playlist.hasNext()) {
OverlayViewState.QUEUE
} else {
null
}
}
PlaybackController(
item = item,
nextState = nextState,
player = player,
controllerViewState = controllerViewState,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekBarChange = {
onSeekBarChange(it)
seekProgressMs = it
},
currentSegment = currentSegment,
onChangeState = onChangeState,
modifier =
Modifier
.padding(bottom = 8.dp)
.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() }
},
seekBarInteractionSource = seekBarInteractionSource,
)
}
OverlayViewState.CHAPTERS -> {
if (chapters.isNotEmpty()) {
ChapterRowOverlay(
player = player,
controllerViewState = controllerViewState,
chapters = chapters,
playlist = playlist,
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
onChangeState = onChangeState,
modifier =
Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(),
)
}
}
OverlayViewState.QUEUE -> {
if (playlist.hasNext()) {
QueueRowOverlay(
playlist = playlist,
controllerViewState = controllerViewState,
nextState =
remember(chapters) {
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else {
OverlayViewState.CONTROLLER
}
},
onChangeState = onChangeState,
onClickPlaylist = onClickPlaylist,
modifier =
Modifier
.padding(8.dp)
.fillMaxWidth(),
)
}
}
}
}
// Trickplay
AnimatedVisibility(
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.fillMaxWidth(.95f),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
) {
if (trickplayInfo != null) {
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val imageUrl = remember(index) { trickplayUrlFor(index) }
if (imageUrl != null) {
SeekPreviewImage(
modifier = Modifier,
previewImageUrl = imageUrl,
seekProgressMs = seekProgressMs,
trickPlayInfo = trickplayInfo,
)
}
}
Text(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.background(
Color.Black.copy(alpha = 0.6f),
shape = RoundedCornerShape(4.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
// Top
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility(
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
AsyncImage(
model = logoImageUrl,
contentDescription = "Logo",
alignment = Alignment.TopStart,
modifier =
Modifier
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
}
AnimatedVisibility(
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopEnd),
) {
TimeDisplay()
}
// Debug overlay
AnimatedVisibility(
visible = showDebugInfo && controllerViewState.controlsVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
val configuration = LocalConfiguration.current
val height =
remember(
configuration,
controllerHeight,
) { configuration.screenHeightDp.dp - controllerHeight }
PlaybackDebugOverlay(
analyticsState = analyticsState,
currentPlayback = currentPlayback,
modifier =
Modifier
.align(Alignment.TopStart)
.heightIn(max = height)
.padding(bottom = 16.dp)
.background(AppColors.TransparentBlack50)
.padding(8.dp)
.onFocusChanged {
if (it.hasFocus) {
// If Debug overlay gains focus, do not hide the controls
controllerViewState.pulseControls(Long.MAX_VALUE)
} else {
controllerViewState.pulseControls()
}
},
)
}
}
}
/**
* The view state of the overlay
*/
enum class OverlayViewState {
HIDDEN,
CONTROLLER,
CHAPTERS,
QUEUE,
}

View file

@ -0,0 +1,229 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.util.TrackSupport
/**
* Debug info about the current playback tracks
*/
@Composable
fun PlaybackTrackInfo(
trackSupport: List<TrackSupport>,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
) {
val selectedTracks = remember(trackSupport) { trackSupport.filter { it.selected } }
val selectedWeight = .5f
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
var expanded by remember { mutableStateOf(false) }
val focusRequesters =
remember(trackSupport.size) { List(trackSupport.size) { FocusRequester() } }
val showButtonFocusRequester = remember { FocusRequester() }
Column(
modifier = modifier,
verticalArrangement = Arrangement.SpaceBetween,
) {
LazyColumn(
modifier = Modifier.weight(1f, fill = false),
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
val texts =
listOf(
"ID",
"Type",
"Codec",
"Supported",
"Labels",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
Text(
text = "Selected",
style = textStyle,
)
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
val tracks = if (expanded) trackSupport else selectedTracks
itemsIndexed(tracks) { index, track ->
ProvideTextStyle(textStyle) {
TrackSupportRow(
track = track,
selectedWeight = selectedWeight,
weights = weights,
modifier =
Modifier
.focusRequester(focusRequesters[index])
.ifElse(
index == tracks.lastIndex,
Modifier.focusProperties {
down = showButtonFocusRequester
right = showButtonFocusRequester
},
),
)
}
}
}
if (trackSupport.size > selectedTracks.size) {
val density = LocalDensity.current
val height =
remember(density, textStyle) {
with(density) {
textStyle.fontSize.toDp() * 2
}
}
Box(Modifier.fillMaxWidth()) {
Button(
onClick = { expanded = !expanded },
shape =
ClickableSurfaceDefaults.shape(
shape = RoundedCornerShape(25),
),
contentHeight = height,
modifier =
Modifier
.align(Alignment.CenterEnd)
.focusRequester(showButtonFocusRequester)
.focusProperties {
up = focusRequesters[0]
},
) {
val text =
if (expanded) {
stringResource(R.string.hide)
} else {
stringResource(
R.string.show_more,
trackSupport.size - selectedTracks.size,
)
}
Text(
text = text,
fontSize = textStyle.fontSize,
)
}
}
}
}
}
@Composable
fun TrackSupportRow(
track: TrackSupport,
selectedWeight: Float,
weights: List<Float>,
modifier: Modifier = Modifier,
) {
Surface(
onClick = {},
modifier = modifier,
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
shape = ClickableSurfaceDefaults.shape(RectangleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = .25f),
),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier.ifElse(
track.selected,
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
),
) {
val texts =
listOf(
track.id ?: "",
track.type.name,
track.codecs ?: "",
track.supported.name,
track.labels.joinToString(", "),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
if (track.selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(12.dp),
)
} else {
Text(
text = "-",
)
}
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
)
}
}
}
}
}

View file

@ -0,0 +1,99 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
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.focus.onFocusChanged
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.BaseItem
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun QueueRowOverlay(
playlist: Playlist,
controllerViewState: ControllerViewState,
nextState: OverlayViewState,
onChangeState: (OverlayViewState) -> Unit,
onClickPlaylist: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
val items = remember { playlist.upcomingItems() }
val focusRequester = remember { FocusRequester() }
val hiddenFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
HiddenFocusBox(hiddenFocusRequester) {
onChangeState.invoke(nextState)
}
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
contentPadding =
PaddingValues(
horizontal = 16.dp,
vertical = 8.dp,
),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(items) { index, item ->
val interactionSource =
remember { MutableInteractionSource() }
val isFocused =
interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
SeasonCard(
item = item.item,
onClick = {
onClickPlaylist.invoke(item.item)
controllerViewState.hideControls()
},
onLongClick = {},
imageHeight = 140.dp,
interactionSource = interactionSource,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
/*
* Modified from https://github.com/android/tv-samples
@ -46,6 +46,8 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.calculateSeekAccelerationMultiplier
import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.annotation.FloatRange
import androidx.compose.foundation.Canvas

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.InfiniteRepeatableSpec

View file

@ -36,6 +36,7 @@ enum class PreferenceScreenOption {
EXO_PLAYER,
MPV,
SCREENSAVER,
SKIP_SEGMENTS,
;
companion object {

View file

@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.screensaverPreferences
@ -142,6 +143,7 @@ fun PreferencesContent(
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
}
val screenTitle =
when (preferenceScreenOption) {
@ -150,6 +152,7 @@ fun PreferencesContent(
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
PreferenceScreenOption.MPV -> R.string.mpv_options
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
}
var visible by remember { mutableStateOf(false) }
@ -707,6 +710,7 @@ fun PreferencesPage(
PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV,
PreferenceScreenOption.SCREENSAVER,
PreferenceScreenOption.SKIP_SEGMENTS,
-> {
PreferencesContent(
initialPreferences,

View file

@ -25,6 +25,7 @@ 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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
@ -72,6 +73,16 @@ fun PinEntry(
Key.DirectionRight -> "R"
Key.DirectionDown -> "D"
Key.DirectionLeft -> "L"
Key.Zero, Key.NumPad0 -> "0"
Key.One, Key.NumPad1 -> "1"
Key.Two, Key.NumPad2 -> "2"
Key.Three, Key.NumPad3 -> "3"
Key.Four, Key.NumPad4 -> "4"
Key.Five, Key.NumPad5 -> "5"
Key.Six, Key.NumPad6 -> "6"
Key.Seven, Key.NumPad7 -> "7"
Key.Eight, Key.NumPad8 -> "8"
Key.Nine, Key.NumPad9 -> "9"
else -> return@onKeyEvent false
}
onTextChange.invoke(str)
@ -122,6 +133,16 @@ fun PinEntryCreate(
Key.DirectionRight -> "R"
Key.DirectionDown -> "D"
Key.DirectionLeft -> "L"
Key.Zero, Key.NumPad0 -> "0"
Key.One, Key.NumPad1 -> "1"
Key.Two, Key.NumPad2 -> "2"
Key.Three, Key.NumPad3 -> "3"
Key.Four, Key.NumPad4 -> "4"
Key.Five, Key.NumPad5 -> "5"
Key.Six, Key.NumPad6 -> "6"
Key.Seven, Key.NumPad7 -> "7"
Key.Eight, Key.NumPad8 -> "8"
Key.Nine, Key.NumPad9 -> "9"
else -> return@onKeyEvent false
}
onTextChange.invoke(str)
@ -154,6 +175,7 @@ fun PinEntryCreate(
@Composable
fun PinArrowRow(modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
@ -167,6 +189,12 @@ fun PinArrowRow(modifier: Modifier = Modifier) {
fontFamily = FontAwesome,
)
}
Text(
text = stringResource(R.string.pin_digits_hint),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
}
}

View file

@ -9,19 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -31,12 +24,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
@ -44,9 +34,6 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.PublicSystemInfo
import java.util.UUID
@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus {
) : ServerConnectionStatus
}
/**
* Display a list of servers plus option to add a new one
*/
@Composable
fun ServerList(
servers: List<JellyfinServer>,
connectionStatus: Map<UUID, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit,
onTestServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit,
allowAdd: Boolean,
allowDelete: Boolean,
modifier: Modifier = Modifier,
) {
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
LazyColumn(modifier = modifier) {
items(servers) { server ->
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) },
supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) },
leadingContent = {
when (status) {
is ServerConnectionStatus.Success -> {}
ServerConnectionStatus.Pending -> {
CircularProgress(
Modifier.size(IconButtonDefaults.MediumIconSize),
)
}
is ServerConnectionStatus.Error -> {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = status.message,
tint = MaterialTheme.colorScheme.errorContainer,
)
}
}
},
onClick = {
when (status) {
is ServerConnectionStatus.Success -> {
onSwitchServer.invoke(server)
}
ServerConnectionStatus.Pending -> {}
is ServerConnectionStatus.Error -> {
onTestServer.invoke(server)
}
}
},
onLongClick = {
if (allowDelete) {
showDeleteDialog = server
}
},
modifier = Modifier,
)
}
if (allowAdd) {
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = stringResource(R.string.add_server)) },
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
tint = Color.Green.copy(alpha = .8f),
contentDescription = null,
)
},
onClick = { onAddServer.invoke() },
modifier = Modifier,
)
}
}
}
showDeleteDialog?.let { server ->
DialogPopup(
showDialog = allowDelete,
title = server.name ?: server.url,
dialogItems =
listOf(
DialogItem(
stringResource(R.string.switch_servers),
R.string.fa_arrow_left_arrow_right,
) {
onSwitchServer.invoke(server)
},
DialogItem(
stringResource(R.string.delete),
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
onRemoveServer.invoke(server)
},
),
onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true,
waitToLoad = true,
properties = DialogProperties(),
elevation = 5.dp,
)
}
}
/**
* Generate a consistent color for a UUID
*/

Some files were not shown because too many files have changed in this diff Show more