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: options:
- ExoPlayer (default) - ExoPlayer (default)
- MPV - MPV
- External App (please specify)
validations: validations:
required: true required: true
- type: input - type: input

View file

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

View file

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

View file

@ -11,7 +11,7 @@ defaults:
env: env:
APK_SHORT_NAME: Wholphin.apk 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 }} TAG_NAME: ${{ github.ref_name }}
jobs: 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. 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 organization
Code is split into several packages: Code is split into several packages:
@ -43,21 +51,22 @@ Code is split into several packages:
- `ui` - User interface code and ViewModels - `ui` - User interface code and ViewModels
- `util` - Utility classes and functions - `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 1. Add entry in `WholphinDataStore.proto` & build to generate classes
2. Add new `AppPreference` object in `AppPreference.kt` 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 4. Update `AppPreferencesSerializer` to set the default value for new installs
5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades 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 - 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.dsl.ProductFlavor
import com.android.build.api.variant.FilterConfiguration
import com.google.protobuf.gradle.id import com.google.protobuf.gradle.id
import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule import com.mikepenz.aboutlibraries.plugin.DuplicateRule
@ -37,7 +39,15 @@ val gitDescribe =
.standardOutput.asText .standardOutput.asText
.getOrElse("v0.0.0") .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" namespace = "com.github.damontecres.wholphin"
compileSdk = 36 compileSdk = 36
@ -98,18 +108,6 @@ android {
isDebuggable = true isDebuggable = true
applicationIdSuffix = ".debug" 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" flavorDimensions += "version"
productFlavors { productFlavors {
@ -148,20 +146,11 @@ android {
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlin {
compilerOptions {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11
javaParameters = true
}
}
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
compose = true compose = true
} }
room {
schemaDirectory("$projectDir/schemas")
}
splits { splits {
abi { 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 { protobuf {
protoc { protoc {
artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}" artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}"

View file

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

View file

@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import com.github.damontecres.wholphin.data.ServerRepository 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.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler 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.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination 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.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@ -83,6 +83,7 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val viewModel: MainActivityViewModel by viewModels() private val viewModel: MainActivityViewModel by viewModels()
private val playExternalViewModel: PlayExternalViewModel by viewModels()
@Inject @Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences> lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@ -153,12 +154,10 @@ class MainActivity : AppCompatActivity() {
if (backStackStr != null) { if (backStackStr != null) {
Timber.d("Restoring back stack") Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr) var backStack = json.decodeFromString<List<Destination>>(backStackStr)
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull() val lastDest = backStack.lastOrNull()
if (lastDest is Destination.Playback || if (lastDest.isPlayback) {
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
Timber.v("Restoring back stack with playback") Timber.v("Restoring back stack with playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) } backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
} }
@ -220,14 +219,7 @@ class MainActivity : AppCompatActivity() {
signInAuto = appPreferences.signInAutomatically signInAuto = appPreferences.signInAutomatically
} }
CoilConfig( CoilConfig(
diskCacheSizeBytes = prefs = appPreferences,
appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let {
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
} else {
it
}
},
okHttpClient = okHttpClient, okHttpClient = okHttpClient,
debugLogging = false, debugLogging = false,
enableCache = true, enableCache = true,
@ -279,6 +271,14 @@ class MainActivity : AppCompatActivity() {
super.onRestart() super.onRestart()
Timber.d("onRestart") Timber.d("onRestart")
viewModel.appStart() 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() { 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 -> { is SetupDestination.UserList -> {
SwitchUserContent( SwitchUserContent(
currentServer = key.server, server = key.server,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
) )
} }

View file

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

View file

@ -15,7 +15,11 @@ sealed interface ExtrasItem {
val parentId: UUID val parentId: UUID
val type: ExtraType val type: ExtraType
val destination: Destination 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 * Represents multiple extras of the same type
@ -24,11 +28,16 @@ sealed interface ExtrasItem {
override val parentId: UUID, override val parentId: UUID,
override val type: ExtraType, override val type: ExtraType,
val items: List<BaseItem>, val items: List<BaseItem>,
override val imageUrl: String?,
override val title: String,
override val subtitle: String,
override val isPlayed: Boolean,
) : ExtrasItem { ) : ExtrasItem {
override val destination: Destination = override val destination: Destination =
Destination.ItemGrid(null, type.stringRes, items.map { it.id }) 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 parentId: UUID,
override val type: ExtraType, override val type: ExtraType,
val item: BaseItem, val item: BaseItem,
override val imageUrl: String?,
override val title: String,
override val subtitle: String?,
) : ExtrasItem { ) : ExtrasItem {
override val destination: Destination = override val destination: Destination =
Destination.Playback( Destination.Playback(
item = item, 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( suspend fun changeUser(
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
): CurrentUser? = ): CurrentUser =
withContext(ioDispatcher) { withContext(ioDispatcher) {
if (server.id != user.serverId) { if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server") throw IllegalStateException("User is not part of the server")
@ -104,15 +104,16 @@ class ServerRepository
currentUserId = updatedUser.id.toServerString() currentUserId = updatedUser.id.toServerString()
}.build() }.build()
} }
val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser) _current.value = currentUser
_currentUserDto.value = userDto _currentUserDto.value = userDto
} }
getServerSharedPreferences(context).edit(true) { getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url) putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) 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 * This is not the same thing as a Jellyfin server playlist
*/ */
class Playlist( class Playlist(
items: List<BaseItem>, items: List<PlaylistItem>,
startIndex: Int = 0, startIndex: Int = 0,
) { ) {
val items = items.subList(startIndex, items.size) val items = items.subList(startIndex, items.size)
@ -23,15 +23,15 @@ class Playlist(
fun hasNext(): Boolean = (index + 1) < items.size 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()) { while (hasNext()) {
val potential = getAndAdvance() val potential = getAndAdvance()
if (potential.id == id) { if (potential.id == id) {
@ -52,3 +52,22 @@ data class PlaylistInfo(
val count: Int, val count: Int,
val mediaType: MediaType, 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 = val ControllerTimeout =
AppSliderPreference<AppPreferences>( AppSliderPreference<AppPreferences>(
title = R.string.hide_controller_timeout, title = R.string.hide_controller_timeout,
@ -513,6 +489,18 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled, 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 = val RememberSelectedTab =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.remember_selected_tab, title = R.string.remember_selected_tab,
@ -664,6 +652,13 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, 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 = val GlobalContentScale =
AppChoicePreference<AppPreferences, PrefContentScale>( AppChoicePreference<AppPreferences, PrefContentScale>(
title = R.string.global_content_scale, 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 = val advancedPreferences =
buildList { buildList {
add( add(
@ -1203,7 +1213,9 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.OneClickPause, AppPreference.OneClickPause,
AppPreference.CinemaMode,
AppPreference.GlobalContentScale, AppPreference.GlobalContentScale,
AppPreference.SkipSegments,
AppPreference.MaxBitrate, AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching, AppPreference.RefreshRateSwitching,
AppPreference.ResolutionSwitching, 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( add(
PreferenceGroup( PreferenceGroup(
title = R.string.player_backend, title = R.string.player_backend,

View file

@ -54,6 +54,7 @@ class AppPreferencesSerializer
refreshRateSwitching = refreshRateSwitching =
AppPreference.RefreshRateSwitching.defaultValue AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
cinemaMode = AppPreference.CinemaMode.defaultValue
overrides = overrides =
PlaybackOverrides 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.updateMusicPreferences
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides 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.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel 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 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.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.ExtraType import org.jellyfin.sdk.model.api.ExtraType
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -19,6 +24,8 @@ class ExtrasService
@Inject @Inject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val imageUrlService: ImageUrlService,
) { ) {
/** /**
* Get the [ExtrasItem]s for the given item * Get the [ExtrasItem]s for the given item
@ -39,9 +46,45 @@ class ExtrasService
extrasMap extrasMap
.mapNotNull { (type, items) -> .mapNotNull { (type, items) ->
if (items.size == 1) { 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) { } 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 { } else {
null null
} }

View file

@ -6,12 +6,15 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences 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.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemKind 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. * Delete the item.
* *

View file

@ -19,14 +19,6 @@ class PlaybackLifecycleObserver
private var wasPlaying: Boolean? = null private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) { 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 wasPlaying = null
} }

View file

@ -106,7 +106,13 @@ class PlayerFactory
.setExtensionRendererMode(rendererMode) .setExtensionRendererMode(rendererMode)
val mediaSourceFactory = val mediaSourceFactory =
if (useLibAss) { 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) val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
renderersFactory = AssRenderersFactory(assHandler, renderersFactory) renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory( 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.GetItemsFilter
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo 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.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable 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.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
@ -22,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi 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.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -247,14 +250,14 @@ class PlaylistCreator
-> { -> {
val list = val list =
buildList { buildList {
add(BaseItem(item, false)) add(PlaylistItem.Media(BaseItem(item, false)))
if (item.partCount.gt(1)) { if (item.partCount.gt(1)) {
api.videosApi api.videosApi
.getAdditionalPart(item.id) .getAdditionalPart(item.id)
.content.items .content.items
.map { .map {
BaseItem(it, false) PlaylistItem.Media(BaseItem(it, false))
}.let(::addAll) }.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 { buildList {
this@convertAndAddParts.forEach { ep -> this@convertAndAddParts.forEach { ep ->
add(BaseItem(ep, useSeriesForPrimary)) add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary)))
if (ep.partCount.gt(1)) { if (ep.partCount.gt(1)) {
val parts = val parts =
api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
BaseItem(part, useSeriesForPrimary) PlaylistItem.Media(BaseItem(part, useSeriesForPrimary))
} }
addAll(parts) addAll(parts)
} }

View file

@ -4,7 +4,7 @@ import android.content.Context
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope 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.formatDate
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
@ -154,30 +154,20 @@ class ScreensaverService
/** /**
* Create a flow of items to show on the screensaver * Create a flow of items to show on the screensaver
*/ */
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = fun createItemFlow(scope: CoroutineScope): Flow<ScreensaverItem?> =
flow { 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 = 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) Timber.v("Got %s items", pager.size)
var index = 0 var index = 0
if (pager.isEmpty()) { if (pager.isEmpty()) {
emit(null) emit(ScreensaverItem.Empty)
} else { } else {
val duration = val duration =
userPreferencesService userPreferencesService
@ -213,7 +203,14 @@ class ScreensaverService
.build(), .build(),
).job ).job
.await() .await()
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) emit(
ScreensaverItem.CurrentItem(
item,
backdropUrl,
logoUrl,
title ?: "",
),
)
delay(duration) delay(duration)
} }
} }
@ -228,6 +225,26 @@ class ScreensaverService
} }
} }
}.flowOn(Dispatchers.Default).cancellable() }.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( data class ScreensaverState(

View file

@ -340,7 +340,7 @@ class UpdateChecker
context.contentResolver.delete( context.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?", "${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") Timber.i("Deleted $deletedRows rows")
} else { } else {

View file

@ -16,10 +16,32 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.Options import coil3.request.Options
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import kotlin.time.ExperimentalTime 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 * Configure Coil image loading
*/ */

View file

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

View file

@ -1,19 +1,13 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem 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.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun ExtrasRow( fun ExtrasRow(
@ -22,52 +16,27 @@ fun ExtrasRow(
onLongClickItem: (Int, ExtrasItem) -> Unit, onLongClickItem: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val resources = LocalResources.current
ItemRow( ItemRow(
title = stringResource(R.string.extras), title = stringResource(R.string.extras),
items = extras, items = extras,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
cardContent = { index, item, mod, onClick, onLongClick -> 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( SeasonCard(
title = title = item?.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
},
name = null, name = null,
subtitle = subtitle = item?.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
},
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = mod, modifier = mod,
showImageOverlay = true, showImageOverlay = true,
imageHeight = Cards.height2x3 * .75f, imageHeight = Cards.heightEpisode,
imageWidth = Dp.Unspecified, imageWidth = Dp.Unspecified,
imageUrl = imageUrl, imageUrl = item?.imageUrl,
isFavorite = false, isFavorite = false,
isPlayed = false, isPlayed = item?.isPlayed == true,
unplayedItemCount = -1, unplayedItemCount = -1,
playedPercentage = -1.0, playedPercentage = item?.playedPercentage ?: 0.0,
numberOfVersions = -1, numberOfVersions = -1,
aspectRatio = AspectRatios.FOUR_THREE, // TODO aspectRatio = AspectRatios.FOUR_THREE, // TODO
) )

View file

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

View file

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

View file

@ -1,11 +1,15 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween 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.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.ShaderBrush
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
@ -45,6 +51,7 @@ import coil3.compose.AsyncImage
import coil3.compose.useExistingImageAsPlaceholder import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transitionFactory import coil3.request.transitionFactory
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService 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_ALPHA
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -67,12 +75,20 @@ class ScreensaverViewModel
val currentItem = screensaverService.createItemFlow(viewModelScope) val currentItem = screensaverService.createItemFlow(viewModelScope)
} }
data class CurrentItem( sealed interface ScreensaverItem {
data class Error(
val exception: Exception,
) : ScreensaverItem
data object Empty : ScreensaverItem
data class CurrentItem(
val item: BaseItem, val item: BaseItem,
val backdropUrl: String, val backdropUrl: String,
val logoUrl: String?, val logoUrl: String?,
val title: String, val title: String,
) ) : ScreensaverItem
}
@Composable @Composable
fun AppScreensaver( fun AppScreensaver(
@ -93,7 +109,7 @@ fun AppScreensaver(
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@Composable @Composable
fun AppScreensaverContent( fun AppScreensaverContent(
currentItem: CurrentItem?, currentItem: ScreensaverItem?,
showClock: Boolean, showClock: Boolean,
duration: Duration, duration: Duration,
animate: Boolean, animate: Boolean,
@ -116,6 +132,26 @@ fun AppScreensaverContent(
repeatMode = RepeatMode.Reverse, repeatMode = RepeatMode.Reverse,
), ),
) )
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( AsyncImage(
model = model =
ImageRequest ImageRequest
@ -172,6 +208,8 @@ fun AppScreensaverContent(
) )
} }
} }
}
}
val largeRadialGradient = val largeRadialGradient =
remember { remember {
@ -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.MediaReportService
import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem 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.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel 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.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid 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.ItemViewModel
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState 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.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -133,7 +135,7 @@ class CollectionFolderViewModel
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
api: ApiClient, api: ApiClient,
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val serverRepository: ServerRepository, val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService, private val backdropService: BackdropService,
@ -142,6 +144,7 @@ class CollectionFolderViewModel
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService, private val mediaManagementService: MediaManagementService,
private val musicService: MusicService, private val musicService: MusicService,
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
@Assisted itemId: String, @Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?, @Assisted initialSortAndDirection: SortAndDirection?,
@ -570,7 +573,44 @@ fun CollectionFolderGrid(
itemId.toServerString(), itemId.toServerString(),
initialFilter, initialFilter,
recursive, 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, sortOptions,
playEnabled, playEnabled,
viewModelKey = viewModelKey, viewModelKey = viewModelKey,
@ -590,7 +630,7 @@ fun CollectionFolderGrid(
itemId: String, itemId: String,
initialFilter: CollectionFolderFilter, initialFilter: CollectionFolderFilter,
recursive: Boolean, recursive: Boolean,
onClickItem: (Int, BaseItem) -> Unit, actions: GridClickActions,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
playEnabled: Boolean, playEnabled: Boolean,
defaultViewOptions: ViewOptions, defaultViewOptions: ViewOptions,
@ -625,11 +665,106 @@ fun CollectionFolderGrid(
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions) 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 showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) 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) { when (val state = loading) {
DataLoadingState.Loading, DataLoadingState.Loading,
DataLoadingState.Pending, DataLoadingState.Pending,
@ -662,10 +797,8 @@ fun CollectionFolderGrid(
sortAndDirection = sortAndDirection!!, sortAndDirection = sortAndDirection!!,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty, focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = onClickItem, onClickItem = gridActions.onClickItem,
onLongClickItem = { position, item -> onLongClickItem = gridActions.onLongClickItem!!,
moreDialog.makePresent(PositionItem(position, item))
},
onSortChange = { onSortChange = {
viewModel.onSortChange(it, recursive, filter) viewModel.onSortChange(it, recursive, filter)
}, },
@ -689,47 +822,8 @@ fun CollectionFolderGrid(
onSaveViewOptions = { viewModel.saveViewOptions(it) }, onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop, onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled, playEnabled = playEnabled,
onClickPlay = { index, item -> onClickPlay = gridActions.onClickPlayRemoteButton!!,
val destination = onClickPlayAll = gridActions.onClickPlayAll!!,
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)
}
},
) )
AnimatedVisibility( AnimatedVisibility(
@ -751,47 +845,22 @@ fun CollectionFolderGrid(
} }
} }
} }
moreDialog.compose { (position, item) -> overviewDialog?.let { info ->
DialogPopup( ItemDetailsDialog(
showDialog = true, info = info,
title = item.title ?: "", showFilePath =
dialogItems = viewModel.serverRepository.currentUserDto.value
buildMoreDialogItemsForHome( ?.policy
context = context, ?.isAdministrator == true,
item = item, onDismissRequest = { overviewDialog = null },
seriesId = null, )
playbackPosition = item.playbackPosition, }
watched = item.played, showContextMenu?.let { contextMenu ->
favorite = item.favorite, ContextMenuDialog(
canDelete = viewModel.canDelete(item, preferences.appPreferences), onDismissRequest = { showContextMenu = null },
actions = getMediaSource = null,
MoreDialogActions( contextMenu = contextMenu,
navigateTo = { viewModel.navigateTo(it) }, preferredSubtitleLanguage = null,
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,
) )
} }
showPlaylistDialog.compose { itemId -> showPlaylistDialog.compose { itemId ->
@ -811,16 +880,6 @@ fun CollectionFolderGrid(
elevation = 3.dp, 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) @OptIn(ExperimentalFoundationApi::class)
@ -1002,7 +1061,7 @@ fun CollectionFolderGridContent(
position = newPosition position = newPosition
positionCallback?.invoke(columns, newPosition) positionCallback?.invoke(columns, newPosition)
}, },
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
@ -1011,6 +1070,7 @@ fun CollectionFolderGridContent(
imageAspectRatio = viewOptions.aspectRatio.ratio, imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType, imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles, showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1051,17 +1111,13 @@ data class PositionItem(
data class CollectionFolderGridParameters( data class CollectionFolderGridParameters(
val columns: Int = 6, val columns: Int = 6,
val spacing: Dp = 16.dp, val spacing: Dp = 16.dp,
val cardContent: @Composable ( val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1071,13 +1127,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters( CollectionFolderGridParameters(
columns = 6, columns = 6,
spacing = 16.dp, spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.TALL, imageAspectRatio = AspectRatios.TALL,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1086,13 +1143,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters( CollectionFolderGridParameters(
columns = 4, columns = 4,
spacing = 24.dp, spacing = 24.dp,
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.Crop, imageContentScale = ContentScale.Crop,
imageAspectRatio = AspectRatios.WIDE, imageAspectRatio = AspectRatios.WIDE,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1101,13 +1159,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters( CollectionFolderGridParameters(
columns = 6, columns = 6,
spacing = 16.dp, spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.SQUARE, imageAspectRatio = AspectRatios.SQUARE,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1165,3 +1224,10 @@ fun GridTitle(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier.fillMaxWidth(), 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 trailingContent: @Composable (() -> Unit)? = null,
val enabled: Boolean = true, val enabled: Boolean = true,
val selected: Boolean = false, val selected: Boolean = false,
val dismissOnClick: Boolean = false,
) : DialogItemEntry { ) : DialogItemEntry {
constructor( constructor(
@StringRes text: Int, @StringRes text: Int,
@StringRes iconStringRes: Int, @StringRes iconStringRes: Int,
iconColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified,
dismissOnClick: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
) : this( ) : this(
headlineContent = { headlineContent = {
@ -124,11 +126,13 @@ data class DialogItem(
) )
}, },
onClick = onClick, onClick = onClick,
dismissOnClick = dismissOnClick,
) )
constructor( constructor(
text: String, text: String,
@StringRes iconStringRes: Int, @StringRes iconStringRes: Int,
dismissOnClick: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
) : this( ) : this(
headlineContent = { headlineContent = {
@ -145,12 +149,14 @@ data class DialogItem(
) )
}, },
onClick = onClick, onClick = onClick,
dismissOnClick = dismissOnClick,
) )
constructor( constructor(
text: String, text: String,
icon: ImageVector, icon: ImageVector,
iconColor: Color? = null, iconColor: Color? = null,
dismissOnClick: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
) : this( ) : this(
headlineContent = { headlineContent = {
@ -167,10 +173,12 @@ data class DialogItem(
) )
}, },
onClick = onClick, onClick = onClick,
dismissOnClick = dismissOnClick,
) )
constructor( constructor(
text: String, text: String,
dismissOnClick: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
) : this( ) : this(
headlineContent = { headlineContent = {
@ -180,6 +188,7 @@ data class DialogItem(
) )
}, },
onClick = onClick, onClick = onClick,
dismissOnClick = dismissOnClick,
) )
companion object { companion object {
@ -201,7 +210,7 @@ fun DialogPopup(
dismissOnClick: Boolean = true, dismissOnClick: Boolean = true,
waitToLoad: Boolean = true, waitToLoad: Boolean = true,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp, elevation: Dp = 1.dp,
) { ) {
var waiting by remember { mutableStateOf(waitToLoad) } var waiting by remember { mutableStateOf(waitToLoad) }
if (showDialog) { if (showDialog) {
@ -296,7 +305,7 @@ fun DialogPopupContent(
selected = item.selected, selected = item.selected,
enabled = !waiting && item.enabled, enabled = !waiting && item.enabled,
onClick = { onClick = {
if (dismissOnClick) { if (dismissOnClick || item.dismissOnClick) {
onDismissRequest.invoke() onDismissRequest.invoke()
} }
item.onClick.invoke() item.onClick.invoke()
@ -349,7 +358,7 @@ fun DialogPopup(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
dismissOnClick: Boolean = true, dismissOnClick: Boolean = true,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp, elevation: Dp = 1.dp,
) = DialogPopup( ) = DialogPopup(
showDialog = true, showDialog = true,
waitToLoad = params.fromLongClick, waitToLoad = params.fromLongClick,
@ -427,7 +436,7 @@ fun ScrollableDialog(
fun BasicDialog( fun BasicDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp, elevation: Dp = 1.dp,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Dialog( Dialog(
@ -458,7 +467,7 @@ fun ConfirmDialog(
onCancel: () -> Unit, onCancel: () -> Unit,
onConfirm: () -> Unit, onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp, elevation: Dp = 1.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface, bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) = BasicDialog( ) = BasicDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
@ -527,10 +536,12 @@ fun ConfirmDeleteDialog(
itemTitle: String, itemTitle: String,
onCancel: () -> Unit, onCancel: () -> Unit,
onConfirm: () -> Unit, onConfirm: () -> Unit,
elevation: Dp = 3.dp,
) { ) {
BasicDialog( BasicDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = elevation,
) { ) {
LazyColumn( LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,

View file

@ -309,7 +309,7 @@ fun GenreCardGrid(
}, },
columns = columns, columns = columns,
spacing = spacing, spacing = spacing,
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GenreCard( GenreCard(
genre = item, genre = item,
onClick = onClick, 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, showJumpButtons = false,
showLetterButtons = false, showLetterButtons = false,
spacing = 24.dp, spacing = 24.dp,
cardContent = @Composable { item, onClick, onLongClick, mod -> cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
fillWidth = widthPx,
modifier = mod, modifier = mod,
imageAspectRatio = AspectRatios.WIDE, // TODO imageAspectRatio = AspectRatios.WIDE, // TODO
) )

View file

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

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -18,6 +17,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences 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.services.deleteItem
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel 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.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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState 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.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -60,6 +60,7 @@ import java.util.UUID
abstract class RecommendedViewModel( abstract class RecommendedViewModel(
@param:ApplicationContext val context: Context, @param:ApplicationContext val context: Context,
val api: ApiClient, val api: ApiClient,
val serverRepository: ServerRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager, val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
@ -167,10 +168,9 @@ fun RecommendedContent(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
) { ) {
val context = LocalContext.current var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
@ -192,6 +192,35 @@ fun RecommendedContent(
LoadingState.Success -> { LoadingState.Success -> {
var position by rememberPosition() 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( HomePageContent(
homeRows = rows, homeRows = rows,
position = position, position = position,
@ -199,7 +228,18 @@ fun RecommendedContent(
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
onLongClickItem = { position, item -> 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 -> onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item)) viewModel.navigationManager.navigateTo(Destination.Playback(item))
@ -226,42 +266,22 @@ fun RecommendedContent(
) )
} }
} }
moreDialog.compose { (position, item) -> overviewDialog?.let { info ->
DialogPopup( ItemDetailsDialog(
showDialog = true, info = info,
title = item.title ?: "", showFilePath =
dialogItems = viewModel.serverRepository.currentUserDto.value
buildMoreDialogItemsForHome( ?.policy
context = context, ?.isAdministrator == true,
item = item, onDismissRequest = { overviewDialog = null },
seriesId = null, )
playbackPosition = item.playbackPosition, }
watched = item.played, showContextMenu?.let { contextMenu ->
favorite = item.favorite, ContextMenuDialog(
canDelete = viewModel.canDelete(item, preferences.appPreferences), onDismissRequest = { showContextMenu = null },
actions = getMediaSource = null,
MoreDialogActions( contextMenu = contextMenu,
navigateTo = { viewModel.navigationManager.navigateTo(it) }, preferredSubtitleLanguage = null,
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,
) )
} }
showPlaylistDialog.compose { itemId -> showPlaylistDialog.compose { itemId ->
@ -281,16 +301,6 @@ fun RecommendedContent(
elevation = 3.dp, 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( data class RowColumnItem(

View file

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

View file

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

View file

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

View file

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

View file

@ -23,14 +23,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.MusicService 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.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid 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.RecommendedMusic
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare 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.launchDefault
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination 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 dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind 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 class CollectionFolderMusicViewModel
@Inject @AssistedInject
constructor( constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val musicService: MusicService, 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) { fun play(item: BaseItem) {
if (item.type == BaseItemKind.AUDIO) { if (item.type == BaseItemKind.AUDIO) {
viewModelScope.launchDefault { 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 @Composable
@ -65,11 +126,13 @@ fun CollectionFolderMusic(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CollectionFolderMusicViewModel = hiltViewModel(), viewModel: CollectionFolderMusicViewModel =
preferencesViewModel: PreferencesViewModel = hiltViewModel(), hiltViewModel<CollectionFolderMusicViewModel, CollectionFolderMusicViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
) { ) {
val rememberedTabIndex = val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
val tabs = val tabs =
listOf( listOf(
@ -88,12 +151,22 @@ fun CollectionFolderMusic(
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("music", selectedTabIndex) logTab("music", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop() viewModel.backdropService.clearBackdrop()
} }
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
val actions =
remember {
GridClickActions(
onClickItem = viewModel::onClick,
onLongClickItem = null,
onClickPlayAll = viewModel::onClickPlayAll,
onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton,
)
}
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {
@ -133,9 +206,7 @@ fun CollectionFolderMusic(
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> actions = actions,
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId, itemId = destination.itemId,
viewModelKey = "${destination.itemId}_albums", viewModelKey = "${destination.itemId}_albums",
initialFilter = initialFilter =
@ -165,9 +236,7 @@ fun CollectionFolderMusic(
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> actions = actions,
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId, itemId = destination.itemId,
viewModelKey = "${destination.itemId}_artists", viewModelKey = "${destination.itemId}_artists",
initialFilter = initialFilter =
@ -209,9 +278,7 @@ fun CollectionFolderMusic(
4 -> { 4 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> actions = actions,
viewModel.play(item)
},
itemId = destination.itemId, itemId = destination.itemId,
viewModelKey = "${destination.itemId}_songs", viewModelKey = "${destination.itemId}_songs",
initialFilter = 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel 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.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
@ -51,14 +52,22 @@ fun CollectionFolderPhotoAlbum(
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
actions =
remember {
GridClickActions(
onClickItem = { index, item -> onClickItem = { index, item ->
val destination = val destination =
if (item.type == BaseItemKind.PHOTO) { if (item.type == BaseItemKind.PHOTO) {
Destination.Slideshow( Destination.Slideshow(
parentId = itemId, parentId = itemId,
index = index, index = index,
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), filter =
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, CollectionFolderFilter(
filter = viewModel.filter.value ?: GetItemsFilter(),
),
sortAndDirection =
viewModel.sortAndDirection.value
?: SortAndDirection.DEFAULT,
recursive = recursive, recursive = recursive,
startSlideshow = false, startSlideshow = false,
) )
@ -67,6 +76,8 @@ fun CollectionFolderPhotoAlbum(
} }
viewModel.navigateTo(destination) viewModel.navigateTo(destination)
}, },
)
},
itemId = itemId.toServerString(), itemId = itemId.toServerString(),
initialFilter = filter, initialFilter = filter,
showTitle = showHeader, 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.Add
import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete 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.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.components.DialogItem 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.ui.nav.Destination
import com.github.damontecres.wholphin.util.supportedPlayableTypes import com.github.damontecres.wholphin.util.supportedPlayableTypes
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind 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
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -36,234 +31,6 @@ data class MoreDialogActions(
val onClickAddToQueue: (BaseItem) -> Unit = {}, 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( fun buildMoreDialogItemsForHome(
context: Context, context: Context,
item: BaseItem, 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
@ -109,13 +110,20 @@ fun FavoritesPage(
focusRequesters = tabFocusRequesters, focusRequesters = tabFocusRequesters,
) )
} }
val actions =
remember {
GridClickActions(
onClickItem = { _, item -> onClickItem.invoke(item) },
)
}
// TODO playEnabled = true for movies & episodes // TODO playEnabled = true for movies & episodes
when (selectedTabIndex) { when (selectedTabIndex) {
// Movies // Movies
0 -> { 0 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) }, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_movies", itemId = "${NavDrawerItem.Favorites.id}_movies",
initialFilter = initialFilter =
CollectionFolderFilter( CollectionFolderFilter(
@ -147,7 +155,7 @@ fun FavoritesPage(
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) }, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_series", itemId = "${NavDrawerItem.Favorites.id}_series",
initialFilter = initialFilter =
CollectionFolderFilter( CollectionFolderFilter(
@ -179,7 +187,7 @@ fun FavoritesPage(
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) }, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_episodes", itemId = "${NavDrawerItem.Favorites.id}_episodes",
initialFilter = initialFilter =
CollectionFolderFilter( CollectionFolderFilter(
@ -212,7 +220,7 @@ fun FavoritesPage(
3 -> { 3 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) }, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_videos", itemId = "${NavDrawerItem.Favorites.id}_videos",
initialFilter = initialFilter =
CollectionFolderFilter( CollectionFolderFilter(
@ -244,7 +252,7 @@ fun FavoritesPage(
4 -> { 4 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) }, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_playlists", itemId = "${NavDrawerItem.Favorites.id}_playlists",
initialFilter = initialFilter =
CollectionFolderFilter( CollectionFolderFilter(
@ -276,7 +284,7 @@ fun FavoritesPage(
5 -> { 5 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) }, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_people", itemId = "${NavDrawerItem.Favorites.id}_people",
initialFilter = initialFilter =
CollectionFolderFilter( CollectionFolderFilter(

View file

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

View file

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

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail.collection package com.github.damontecres.wholphin.ui.detail.collection
import android.content.Context
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -16,9 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils 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.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel 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.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.SortAndDirection 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState 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.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -85,33 +77,15 @@ fun CollectionDetails(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
// Dialogs // Dialogs
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<Pair<RowColumn?, BaseItem>?>(null) }
var showViewOptionsDialog by remember { mutableStateOf(false) } var showViewOptionsDialog by remember { mutableStateOf(false) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
// Actions fun contextActionsFor(position: RowColumn?) =
val onClickItem = ContextMenuActions(
remember { navigateTo = viewModel::navigateTo,
{ _: RowColumn, item: BaseItem -> viewModel.navigate(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 -> onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched, position) viewModel.setWatched(itemId, watched, position)
}, },
@ -123,15 +97,38 @@ fun CollectionDetails(
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { item -> showDeleteDialog = Pair(position, item) }, onDeleteItem = { viewModel.deleteItem(it, position) },
onClickAddToQueue = { viewModel.addToQueue(it, 0) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
), onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = { result ->
// Not supported on this page
},
onClearChosenStreams = {
// Not supported on this page
},
) )
moreDialog =
DialogParams( // Actions
val onClickItem =
remember {
{ _: RowColumn, item: BaseItem -> viewModel.navigateTo(item.destination()) }
}
val onLongClickItem =
remember {
{ position: RowColumn, item: BaseItem ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true, fromLongClick = true,
title = item.title ?: "", item = item,
items = dialogItems, 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) } { filter: GetItemsFilter -> viewModel.changeFilter(filter) }
} }
val onClickPlay = { _: RowColumn, item: BaseItem -> val onClickPlay = { _: RowColumn, item: BaseItem ->
viewModel.navigate(Destination.Playback(item = item)) viewModel.navigateTo(Destination.Playback(item = item))
} }
val onClickPlayAll = val onClickPlayAll =
remember { remember {
@ -158,7 +155,7 @@ fun CollectionDetails(
sortAndDirection = state.sortAndDirection, sortAndDirection = state.sortAndDirection,
filter = state.itemFilter, filter = state.itemFilter,
) )
viewModel.navigate(dest) viewModel.navigateTo(dest)
} }
} }
val onClickViewOptions = remember { { showViewOptionsDialog = true } } val onClickViewOptions = remember { { showViewOptionsDialog = true } }
@ -201,7 +198,7 @@ fun CollectionDetails(
} }
} }
}, },
deleteOnClick = onConfirmDelete =
remember { remember {
{ {
state.collection?.let { state.collection?.let {
@ -210,43 +207,30 @@ fun CollectionDetails(
} }
}, },
canDelete = canDelete =
remember(state.collection) { remember(
state.collection,
preferences.appPreferences.interfacePreferences.enableMediaManagement,
) {
state.collection?.let { state.collection?.let {
viewModel.canDelete(it, preferences.appPreferences) viewModel.canDelete(it, preferences.appPreferences)
} ?: false } ?: false
}, },
moreOnClick = { moreOnClick = {
val collection = state.collection!! showContextMenu =
val items = ContextMenu.ForBaseItem(
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(
fromLongClick = false, fromLongClick = false,
title = collection.title ?: "", item = state.collection!!,
items = items, 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, onViewOptionsChange = viewModel::changeViewOptions,
) )
} }
moreDialog?.let { params -> overviewDialog?.let { info ->
DialogPopup( ItemDetailsDialog(
showDialog = true, info = info,
title = params.title, showFilePath =
dialogItems = params.items, viewModel.serverRepository.currentUserDto.value
onDismissRequest = { moreDialog = null }, ?.policy
dismissOnClick = true, ?.isAdministrator == true,
waitToLoad = params.fromLongClick, onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = null,
) )
} }
showPlaylistDialog.compose { itemId -> showPlaylistDialog.compose { itemId ->
@ -286,26 +278,6 @@ fun CollectionDetails(
elevation = 3.dp, 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 @Composable
@ -324,7 +296,7 @@ fun CollectionDetailsContent(
onClickViewOptions: () -> Unit, onClickViewOptions: () -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
deleteOnClick: () -> Unit, onConfirmDelete: () -> Unit,
canDelete: Boolean, canDelete: Boolean,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
modifier: Modifier, modifier: Modifier,
@ -363,15 +335,9 @@ fun CollectionDetailsContent(
) { ) {
// This box exists so that there is something focusable above the item content // This box exists so that there is something focusable above the item content
// allowing focus to move up to restore the collection's header // allowing focus to move up to restore the collection's header
Box( HiddenFocusBox {
modifier = itemsContentHasFocus = false
Modifier }
.fillMaxWidth()
.height(0.dp)
.onFocusChanged {
if (it.isFocused) itemsContentHasFocus = false
}.focusable(),
)
if (state.viewOptions.cardViewOptions.showDetails) { if (state.viewOptions.cardViewOptions.showDetails) {
HomePageHeader( HomePageHeader(
item = focusedItem, item = focusedItem,
@ -429,7 +395,7 @@ fun CollectionDetailsContent(
getPossibleFilterValues = getPossibleFilterValues, getPossibleFilterValues = getPossibleFilterValues,
onClickViewOptions = onClickViewOptions, onClickViewOptions = onClickViewOptions,
favoriteOnClick = favoriteOnClick, favoriteOnClick = favoriteOnClick,
deleteOnClick = deleteOnClick, onConfirmDelete = onConfirmDelete,
canDelete = canDelete, canDelete = canDelete,
moreOnClick = moreOnClick, moreOnClick = moreOnClick,
modifier = 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 -> positionCallback = { _, newPosition ->
onFocusPosition.invoke(RowColumn(0, newPosition)) onFocusPosition.invoke(RowColumn(0, newPosition))
}, },
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
@ -69,6 +69,7 @@ fun CollectionMixedGrid(
imageAspectRatio = cardViewOptions.aspectRatio.ratio, imageAspectRatio = cardViewOptions.aspectRatio.ratio,
imageType = cardViewOptions.imageType, imageType = cardViewOptions.imageType,
showTitle = cardViewOptions.showTitles, showTitle = cardViewOptions.showTitles,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },

View file

@ -382,7 +382,7 @@ class CollectionViewModel
result.totalRecordCount result.totalRecordCount
} }
fun navigate(destination: Destination) { fun navigateTo(destination: Destination) {
release() release()
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
@ -393,7 +393,9 @@ class CollectionViewModel
position: RowColumn?, position: RowColumn?,
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
favoriteWatchManager.setWatched(itemId, played) favoriteWatchManager.setWatched(itemId, played)
if (position != null) { if (itemId == state.value.collection?.id) {
refreshCollection()
} else if (position != null) {
refreshItem(itemId, position, false) refreshItem(itemId, position, false)
} }
} }
@ -404,11 +406,24 @@ class CollectionViewModel
position: RowColumn?, position: RowColumn?,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite) favoriteWatchManager.setFavorite(itemId, favorite)
if (position != null) { if (itemId == state.value.collection?.id) {
refreshCollection()
} else if (position != null) {
refreshItem(itemId, position, false) 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( fun canDelete(
item: BaseItem, item: BaseItem,
appPreferences: AppPreferences, appPreferences: AppPreferences,

View file

@ -142,7 +142,7 @@ fun DiscoverPersonPage(
showJumpButtons = false, showJumpButtons = false,
showLetterButtons = false, showLetterButtons = false,
spacing = 16.dp, spacing = 16.dp,
cardContent = @Composable { item, onClick, onLongClick, mod -> cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
DiscoverItemCard( DiscoverItemCard(
item = item, item = item,
onClick = onClick, 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.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional 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.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -82,10 +76,8 @@ fun EpisodeDetails(
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams by viewModel.chosenStreams.observeAsState(null)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } 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 playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val canDelete by viewModel.canDelete.collectAsState() val canDelete by viewModel.canDelete.collectAsState()
@ -96,22 +88,36 @@ fun EpisodeDetails(
?.configuration ?.configuration
?.subtitleLanguagePreference ?.subtitleLanguagePreference
val moreActions = val contextActions =
MoreDialogActions( remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched -> onClickWatch = viewModel::setWatched,
viewModel.setWatched(itemId, watched) onClickFavorite = viewModel::setFavorite,
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite)
},
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it }, 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) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
@ -152,75 +158,17 @@ fun EpisodeDetails(
ItemDetailsDialogInfo(ep) ItemDetailsDialogInfo(ep)
}, },
moreOnClick = { moreOnClick = {
moreDialog = showContextMenu =
DialogParams( ContextMenu.ForBaseItem(
fromLongClick = false, fromLongClick = false,
title = ep.name + " (${ep.data.productionYear ?: ""})",
items =
buildMoreDialogItems(
context = context,
item = ep, item = ep,
watched = ep.data.userData?.played ?: false, chosenStreams = chosenStreams,
favorite = ep.data.userData?.isFavorite ?: false, showGoTo = false,
seriesId = ep.data.seriesId, showStreamChoices = true,
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, canDelete = canDelete,
), canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
) )
}, },
watchOnClick = { watchOnClick = {
@ -230,7 +178,7 @@ fun EpisodeDetails(
viewModel.setFavorite(ep.id, !ep.favorite) viewModel.setFavorite(ep.id, !ep.favorite)
}, },
canDelete = canDelete, canDelete = canDelete,
deleteOnClick = { showDeleteDialog = ep }, onConfirmDelete = { viewModel.deleteItem(ep) },
modifier = modifier, modifier = modifier,
) )
} }
@ -246,24 +194,12 @@ fun EpisodeDetails(
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },
) )
} }
moreDialog?.let { params -> showContextMenu?.let { contextMenu ->
DialogPopup( ContextMenuDialog(
showDialog = true, onDismissRequest = { showContextMenu = null },
title = params.title, getMediaSource = viewModel.streamChoiceService::chooseSource,
dialogItems = params.items, contextMenu = contextMenu,
onDismissRequest = { moreDialog = null }, preferredSubtitleLanguage = preferredSubtitleLanguage,
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 -> showPlaylistDialog.compose { itemId ->
@ -283,16 +219,6 @@ fun EpisodeDetails(
elevation = 3.dp, 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 private const val HEADER_ROW = 0
@ -308,7 +234,7 @@ fun EpisodeDetailsContent(
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
canDelete: Boolean, canDelete: Boolean,
deleteOnClick: () -> Unit, onConfirmDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -346,6 +272,7 @@ fun EpisodeDetailsContent(
.padding(top = HeaderUtils.topPadding, bottom = 16.dp), .padding(top = HeaderUtils.topPadding, bottom = 16.dp),
) )
ExpandablePlayButtons( ExpandablePlayButtons(
title = ep.title ?: "",
resumePosition = resumePosition, resumePosition = resumePosition,
watched = dto.userData?.played ?: false, watched = dto.userData?.played ?: false,
favorite = dto.userData?.isFavorite ?: false, favorite = dto.userData?.isFavorite ?: false,
@ -367,7 +294,7 @@ fun EpisodeDetailsContent(
trailers = null, trailers = null,
trailerOnClick = {}, trailerOnClick = {},
canDelete = canDelete, canDelete = canDelete,
deleteOnClick = deleteOnClick, onConfirmDelete = onConfirmDelete,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem 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.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -38,9 +37,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -81,12 +77,8 @@ class EpisodeViewModel
init { init {
init() init()
viewModelScope.launchDefault { viewModelScope.launchDefault {
item mediaManagementService.collectCanDelete(item.asFlow()) { canDelete ->
.asFlow() this@EpisodeViewModel.canDelete.update { canDelete }
.filterNotNull()
.combinePair(userPreferencesService.flow.map { it.appPreferences })
.collectLatest { (item, preferences) ->
canDelete.update { mediaManagementService.canDelete(item, preferences) }
} }
} }
} }

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

View file

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

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

View file

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

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.music package com.github.damontecres.wholphin.ui.detail.music
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -22,7 +20,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.rememberPreviousButtonState
import androidx.media3.ui.compose.state.rememberRepeatButtonState import androidx.media3.ui.compose.state.rememberRepeatButtonState
import androidx.media3.ui.compose.state.rememberShuffleButtonState import androidx.media3.ui.compose.state.rememberShuffleButtonState
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.PlaybackButton import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing
import com.github.damontecres.wholphin.ui.playback.buttonSpacing
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlin.time.Duration.Companion.seconds 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.ifElse
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.playback.ControllerViewState 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.preferences.MoveButton
import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.services.rememberQueue
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.findActivity
import com.github.damontecres.wholphin.ui.nav.Backdrop 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.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.playback.isUp 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.extensions.ticks 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 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) } var lyricsHaveFocus by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -209,6 +201,7 @@ fun NowPlayingPage(
remember { remember {
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
} }
//noinspection UnnecessaryFullyQualifiedName
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = musicPrefs.showAlbumArt, visible = musicPrefs.showAlbumArt,
enter = enter, enter = enter,
@ -250,6 +243,7 @@ fun NowPlayingPage(
} }
} }
} }
//noinspection UnnecessaryFullyQualifiedName
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = musicPrefs.showVisualizer, visible = musicPrefs.showVisualizer,
enter = enter, enter = enter,
@ -308,17 +302,21 @@ fun NowPlayingPage(
val showContextForItem = val showContextForItem =
remember { remember {
{ fromLongClick: Boolean, index: Int, song: AudioItem -> { fromLongClick: Boolean, index: Int, song: AudioItem ->
itemMoreDialog = showContextMenu =
DialogParams( ContextMenu.ForQueue(
title = song.title ?: "",
fromLongClick = fromLongClick, fromLongClick = fromLongClick,
items =
buildMoreDialogForMusicQueue(
context = context,
actions = actions,
item = song, item = song,
index = index, index = index,
canRemove = true, 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) LoadingPage(focusEnabled = false)
} }
} }
itemMoreDialog?.let { params -> showContextMenu?.let { contextMenu ->
DialogPopup( ContextMenuDialog(
showDialog = true, onDismissRequest = { showContextMenu = null },
title = params.title, getMediaSource = null,
dialogItems = params.items, contextMenu = contextMenu,
onDismissRequest = { itemMoreDialog = null }, preferredSubtitleLanguage = null,
dismissOnClick = true,
waitToLoad = params.fromLongClick,
) )
} }
if (showViewOptionsDialog) { if (showViewOptionsDialog) {

View file

@ -1,20 +1,8 @@
package com.github.damontecres.wholphin.ui.detail.music 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.ViewModel
import androidx.lifecycle.viewModelScope 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.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.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import org.jellyfin.sdk.api.client.ApiClient 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 org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID 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( suspend fun ViewModel.getPagerForAlbum(
api: ApiClient, api: ApiClient,
albumId: UUID, albumId: UUID,

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -20,41 +21,30 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.map import androidx.lifecycle.map
import com.github.damontecres.wholphin.R 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.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.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt 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 com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind 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.MediaType
import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -98,16 +88,19 @@ fun SeriesOverview(
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() }
val extrasRowFocusRequester = remember { FocusRequester() }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null) 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 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 episodeList = (episodes as? EpisodeList.Success)?.episodes
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
val currentPosition by rememberUpdatedState(position)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (seasons.isNotEmpty()) { if (seasons.isNotEmpty()) {
seasons.getOrNull(position.seasonTabIndex)?.let { seasons.getOrNull(position.seasonTabIndex)?.let {
@ -117,13 +110,54 @@ fun SeriesOverview(
} }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt() 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) { LaunchedEffect(position, episodes) {
val focusedEpisode = val focusedEpisode =
@ -164,6 +198,7 @@ fun SeriesOverview(
EPISODE_ROW -> episodeRowFocusRequester EPISODE_ROW -> episodeRowFocusRequester
CAST_AND_CREW_ROW -> castCrewRowFocusRequester CAST_AND_CREW_ROW -> castCrewRowFocusRequester
GUEST_STAR_ROW -> guestStarRowFocusRequester GUEST_STAR_ROW -> guestStarRowFocusRequester
EXTRAS_ROW -> extrasRowFocusRequester
else -> episodeRowFocusRequester else -> episodeRowFocusRequester
}, },
"series_overview", "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( SeriesOverviewContent(
preferences = preferences, preferences = preferences,
series = series, series = series,
@ -280,11 +218,13 @@ fun SeriesOverview(
episodes = episodes, episodes = episodes,
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode, peopleInEpisode = peopleInEpisode,
seasonExtras = seasonExtras,
position = position, position = position,
firstItemFocusRequester = firstItemFocusRequester, firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester, guestStarRowFocusRequester = guestStarRowFocusRequester,
extrasRowFocusRequester = extrasRowFocusRequester,
onChangeSeason = { index -> onChangeSeason = { index ->
if (index != position.seasonTabIndex) { if (index != position.seasonTabIndex) {
seasons.getOrNull(index)?.let { season -> seasons.getOrNull(index)?.let { season ->
@ -314,9 +254,18 @@ fun SeriesOverview(
) )
}, },
onLongClick = { ep -> onLongClick = { ep ->
scope.launchDefault { showContextMenu =
moreDialog = buildMoreForEpisode(ep, chosenStreams, true) 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 -> playOnClick = { resume ->
rowFocused = EPISODE_ROW rowFocused = EPISODE_ROW
@ -344,9 +293,18 @@ fun SeriesOverview(
}, },
moreOnClick = { moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
scope.launchDefault { showContextMenu =
moreDialog = buildMoreForEpisode(ep, chosenStreams, false) 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 = { overviewOnClick = {
@ -364,14 +322,25 @@ fun SeriesOverview(
), ),
) )
}, },
onClickExtra = { _, extra ->
rowFocused = EXTRAS_ROW
viewModel.navigateTo(extra.destination)
},
canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
deleteOnClick = { showDeleteDialog = it }, onConfirmDelete = viewModel::deleteItem,
modifier = modifier, modifier = modifier,
) )
} }
} }
} }
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = viewModel.streamChoiceService::chooseSource,
contextMenu = contextMenu,
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
overviewDialog?.let { info -> overviewDialog?.let { info ->
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
@ -382,26 +351,6 @@ fun SeriesOverview(
onDismissRequest = { overviewDialog = null }, 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 -> showPlaylistDialog?.let { itemId ->
PlaylistDialog( PlaylistDialog(
title = stringResource(R.string.add_to_playlist), title = stringResource(R.string.add_to_playlist),
@ -419,19 +368,9 @@ fun SeriesOverview(
elevation = 3.dp, 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 EPISODE_ROW = 0
private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1 private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1
private const val GUEST_STAR_ROW = CAST_AND_CREW_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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
@ -47,11 +46,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.cards.BannerCard 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.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.HeaderUtils
@ -74,6 +75,7 @@ fun SeriesOverviewContent(
series: BaseItem, series: BaseItem,
seasons: List<BaseItem?>, seasons: List<BaseItem?>,
episodes: EpisodeList, episodes: EpisodeList,
seasonExtras: List<ExtrasItem>,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
peopleInEpisode: List<Person>, peopleInEpisode: List<Person>,
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
@ -81,6 +83,7 @@ fun SeriesOverviewContent(
episodeRowFocusRequester: FocusRequester, episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester,
guestStarRowFocusRequester: FocusRequester, guestStarRowFocusRequester: FocusRequester,
extrasRowFocusRequester: FocusRequester,
onChangeSeason: (Int) -> Unit, onChangeSeason: (Int) -> Unit,
onFocusEpisode: (Int) -> Unit, onFocusEpisode: (Int) -> Unit,
onClick: (BaseItem) -> Unit, onClick: (BaseItem) -> Unit,
@ -92,7 +95,8 @@ fun SeriesOverviewContent(
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit, personOnClick: (Person) -> Unit,
canDelete: (BaseItem) -> Boolean, canDelete: (BaseItem) -> Boolean,
deleteOnClick: (BaseItem) -> Unit, onConfirmDelete: (BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -318,7 +322,7 @@ fun SeriesOverviewContent(
} }
}, },
canDelete = canDelete.invoke(ep), canDelete = canDelete.invoke(ep),
deleteOnClick = { deleteOnClick.invoke(ep) }, onConfirmDelete = { onConfirmDelete.invoke(ep) },
modifier = modifier =
Modifier Modifier
.padding(top = 4.dp) .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.MutableStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -138,7 +139,11 @@ class SeriesViewModel
Timber.v("Start") Timber.v("Start")
addCloseable { themeSongPlayer.stop() } addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId) val item = fetchItem(seriesId)
canDeleteSeries.update { mediaManagementService.canDelete(item) } viewModelScope.launchDefault {
mediaManagementService.collectCanDelete(flowOf(item)) { canDelete ->
canDeleteSeries.update { canDelete }
}
}
backdropService.submit(item) backdropService.submit(item)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
@ -184,6 +189,10 @@ class SeriesViewModel
it.copy(seasonTabIndex = index.coerceAtLeast(0)) it.copy(seasonTabIndex = index.coerceAtLeast(0))
} }
} }
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
}
} }
val remoteTrailers = trailerService.getRemoteTrailers(item) val remoteTrailers = trailerService.getRemoteTrailers(item)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -388,6 +397,7 @@ class SeriesViewModel
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem() this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
this@SeriesViewModel.episodes.value = EpisodeList.Loading this@SeriesViewModel.episodes.value = EpisodeList.Loading
this@SeriesViewModel.extras.value = emptyList()
} }
viewModelScope.launchIO(ExceptionHandler(true)) { viewModelScope.launchIO(ExceptionHandler(true)) {
val episodes = val episodes =
@ -401,6 +411,10 @@ class SeriesViewModel
this@SeriesViewModel.episodes.value = episodes this@SeriesViewModel.episodes.value = episodes
} }
} }
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
}
} }
fun setWatched( fun setWatched(

View file

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

View file

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

View file

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

View file

@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -87,26 +88,30 @@ class HomeViewModel
// Refreshing if a load has already occurred and the rows haven't significantly changed // Refreshing if a load has already occurred and the rows haven't significantly changed
val refresh = val refresh =
state.loadingState == LoadingState.Success && state.settings == settings 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 { _state.update {
it.copy( it.copy(
loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, loadingState = if (refresh) LoadingState.Success else LoadingState.Loading,
refreshState = LoadingState.Loading, refreshState = LoadingState.Loading,
settings = settings, settings = settings,
homeRows =
if (refresh) {
it.homeRows
} else {
List(settings.rows.size) { HomeRowLoadingState.Pending("") }
},
) )
} }
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
val watchingRowIndexes =
settings.rows
.mapIndexedNotNull { index, row ->
if (isWatchingRow(row.config)) index else null
}
val deferred = val deferred =
settings.rows settings.rows
// Load the watching rows first
.sortedByDescending { isWatchingRow(it.config) }
.map { row -> .map { row ->
viewModelScope.async(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit { semaphore.withPermit {
@ -123,40 +128,47 @@ class HomeViewModel
) )
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error on row %s", row) 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()) { if (refresh) {
// Replace watching rows first // Replace rows as they complete
Timber.v("Refreshing rows: %s", watchingRowIndexes) val remaining = deferred.withIndex().toMutableList()
val rows = while (remaining.isNotEmpty()) {
deferred val (rowIndex, rowData) =
.filterIndexed { index, _ -> index in watchingRowIndexes } select {
.awaitAll() // "Return" the first remaining that is completed
_state.update { 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 = val newRows =
it.homeRows.toMutableList().apply { state.homeRows.toMutableList().apply {
rows.forEachIndexed { index, row -> set(rowIndex, rowData)
set(watchingRowIndexes[index], row)
} }
} state.copy(
it.copy(
loadingState = LoadingState.Success,
homeRows = newRows, homeRows = newRows,
) )
} }
} }
val rows = _state.update {
deferred it.copy(
.awaitAll() loadingState = LoadingState.Success,
.filter { refreshState = LoadingState.Success,
// Include only errors & non-empty successes )
it is HomeRowLoadingState.Error ||
(it is HomeRowLoadingState.Success && it.items.isNotEmpty())
} }
} else {
val rows = deferred.awaitAll()
Timber.v("Got all rows") Timber.v("Got all rows")
_state.update { _state.update {
it.copy( it.copy(
@ -166,10 +178,13 @@ class HomeViewModel
) )
} }
} }
Timber.d("Home page load complete")
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception during home page loading") Timber.e(ex, "Exception during home page loading")
if (state.value.loadingState == LoadingState.Success) { if (state.value.loadingState == LoadingState.Success) {
showToast(context, "Error refreshing home: ${ex.localizedMessage}") showToast(context, "Error refreshing home: ${ex.localizedMessage}")
_state.update { it.copy(refreshState = LoadingState.Error(ex)) }
} else { } else {
_state.update { _state.update {
it.copy(loadingState = LoadingState.Error(ex)) 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.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -78,8 +78,8 @@ sealed class Destination(
data class Playback( data class Playback(
val itemId: UUID, val itemId: UUID,
val positionMs: Long, val positionMs: Long,
val itemPlayback: ItemPlayback? = null,
val forceTranscoding: Boolean = false, val forceTranscoding: Boolean = false,
val backend: PlayerBackend? = null,
) : Destination(true) { ) : Destination(true) {
constructor(item: BaseItem) : this(item.id, item.resumeMs) constructor(item: BaseItem) : this(item.id, item.resumeMs)
} }

View file

@ -76,9 +76,25 @@ fun DestinationContent(
HomeSettingsPage(preferences, modifier) 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) { if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
PlayExternalPage( PlayExternalPage(
preferences = preferences, preferences = preferences,

View file

@ -8,14 +8,13 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle 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.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult 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.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault 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.preferences.getExternalPlayers
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException 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.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.MediaStream 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.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class) @HiltViewModel
class PlayExternalViewModel class PlayExternalViewModel
@AssistedInject @Inject
constructor( constructor(
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@ -75,16 +78,13 @@ class PlayExternalViewModel
private val streamChoiceService: StreamChoiceService, private val streamChoiceService: StreamChoiceService,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
@Assisted val destination: Destination,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory val launched = savedStateHandle.getMutableStateFlow("launched", false)
interface Factory {
fun create(destination: Destination): PlayExternalViewModel
}
val state = MutableStateFlow(PlayExternalState()) 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 { viewModelScope.launchDefault {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val positionMs: Long val positionMs: Long
@ -106,9 +106,9 @@ class PlayExternalViewModel
} }
try { try {
val queriedItem = api.userLibraryApi.getItem(itemId).content val queriedItem = api.userLibraryApi.getItem(itemId).content
val base = val playlistItem =
if (queriedItem.type.playable) { if (queriedItem.type.playable) {
queriedItem PlaylistItem.Media(BaseItem(queriedItem))
} else if (destination is Destination.PlaybackList) { } else if (destination is Destination.PlaybackList) {
val playlistResult = val playlistResult =
playlistCreator.createFrom( playlistCreator.createFrom(
@ -135,18 +135,15 @@ class PlayExternalViewModel
navigationManager.goBack() navigationManager.goBack()
return@launchDefault return@launchDefault
} }
r.playlist.items r.playlist.items.first()
.first()
.data
} }
} }
} else { } else {
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
} }
val item = BaseItem(base, false)
val playbackConfig = val playbackConfig =
serverRepository.currentUser.value?.let { user -> 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) Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) { if (it.sourceId != null) {
it it
@ -155,20 +152,25 @@ class PlayExternalViewModel
} }
} }
} }
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig) val item =
val plc = streamChoiceService.getPlaybackLanguageChoice(base) 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) { if (mediaSource == null) {
Timber.w("Media source is null") Timber.w("Media source is null")
return@launchDefault return@launchDefault
} }
savedStateHandle[KEY_ID] = base.id savedStateHandle[KEY_ID] = playlistItem.id
savedStateHandle[KEY_MEDIA_ID] = mediaSource.id savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
val subtitleIndex = val subtitleIndex =
streamChoiceService streamChoiceService
.chooseSubtitleStream( .chooseSubtitleStream(
source = mediaSource, source = mediaSource,
audioStream = null, audioStream = null,
seriesId = base.seriesId, seriesId = item.data.seriesId,
itemPlayback = playbackConfig, itemPlayback = playbackConfig,
plc = plc, plc = plc,
prefs = prefs, prefs = prefs,
@ -252,7 +254,17 @@ class PlayExternalViewModel
putExtra("forcedsrt", subtitleUrls[it]) 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 { state.update {
PlayExternalState( PlayExternalState(
loading = LoadingState.Success, loading = LoadingState.Success,
@ -278,10 +290,10 @@ class PlayExternalViewModel
return@launchDefault return@launchDefault
} }
Timber.v( Timber.v(
"Result: result=%s, itemId=%s action=%s", "Result: result=%s, action=%s, itemId=%s",
result.resultCode, result.resultCode,
itemId,
result.data?.action, result.data?.action,
itemId,
) )
if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED || if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
// Vimu return 1 for video completion // Vimu return 1 for video completion
@ -295,7 +307,7 @@ class PlayExternalViewModel
position = position =
data data
.getLongExtra("extra_position", Long.MIN_VALUE) .getLongExtra("extra_position", Long.MIN_VALUE)
.takeIf { it >= 0 } .takeIf { it > 0 }
} }
// mpv-android: https://mpv-android.github.io/mpv-android/intent.html // mpv-android: https://mpv-android.github.io/mpv-android/intent.html
@ -326,6 +338,7 @@ class PlayExternalViewModel
} }
} }
Timber.v("Result position: %s", position?.milliseconds) Timber.v("Result position: %s", position?.milliseconds)
if (position != null || result.data?.action != null) {
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
itemId = itemId, itemId = itemId,
@ -334,11 +347,18 @@ class PlayExternalViewModel
failed = false, failed = false,
), ),
) )
}
} else { } 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") showToast(context, "Unknown result from external player")
} }
navigationManager.goBack() navigationManager.goBack()
state.update { PlayExternalState() }
launched.update { false }
} catch (_: CancellationException) { } catch (_: CancellationException) {
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during external playback of %s", itemId) Timber.e(ex, "Error during external playback of %s", itemId)
@ -359,7 +379,7 @@ class PlayExternalViewModel
} }
data class PlayExternalState( data class PlayExternalState(
val loading: LoadingState = LoadingState.Loading, val loading: LoadingState = LoadingState.Pending,
val intent: Intent = Intent(), val intent: Intent = Intent(),
) )
@ -369,8 +389,8 @@ fun PlayExternalPage(
destination: Destination, destination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlayExternalViewModel = viewModel: PlayExternalViewModel =
hiltViewModel<PlayExternalViewModel, PlayExternalViewModel.Factory>( hiltViewModel(
creationCallback = { it.create(destination) }, viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity,
), ),
) { ) {
val launcher = val launcher =
@ -380,15 +400,18 @@ fun PlayExternalPage(
) )
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var launched by rememberSaveable { mutableStateOf(false) } val launched by viewModel.launched.collectAsState()
if (!launched) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init() if (!launched) {
viewModel.init(destination)
} }
} }
when (val l = state.loading) { when (val l = state.loading) {
LoadingState.Pending, LoadingState.Pending -> {
LoadingPage(modifier, false)
}
LoadingState.Loading, LoadingState.Loading,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
@ -403,7 +426,7 @@ fun PlayExternalPage(
if (!launched) { if (!launched) {
LifecycleStartEffect(Unit) { LifecycleStartEffect(Unit) {
Timber.i("Launching external playback") Timber.i("Launching external playback")
launched = true viewModel.launched.update { true }
try { try {
launcher.launch(state.intent) launcher.launch(state.intent)
} catch (ex: Exception) { } 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.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull 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 com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration import kotlin.time.Duration
enum class PlaybackDialogType { enum class PlaybackDialogType {
MORE, DEBUG,
CAPTIONS, CAPTIONS,
SETTINGS, SETTINGS,
AUDIO, AUDIO,
@ -65,10 +68,10 @@ data class PlaybackSettings(
/** /**
* Centralized UI component for displaying dialogs during playback * 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. * [PlaybackDialogType] determining which dialog is shown by this component.
* *
* @see PlaybackAction * @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
*/ */
@Composable @Composable
fun PlaybackDialog( fun PlaybackDialog(
@ -83,28 +86,8 @@ fun PlaybackDialog(
onChangeSubtitleDelay: (Duration) -> Unit, onChangeSubtitleDelay: (Duration) -> Unit,
) { ) {
when (type) { when (type) {
PlaybackDialogType.MORE -> { PlaybackDialogType.DEBUG -> {
val options = throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG)
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.CAPTIONS -> { PlaybackDialogType.CAPTIONS -> {
@ -135,17 +118,8 @@ fun PlaybackDialog(
} }
PlaybackDialogType.SETTINGS -> { PlaybackDialogType.SETTINGS -> {
val currentAudio =
remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } }
val options = val options =
buildList { buildList {
add(
BottomDialogItem(
data = PlaybackDialogType.AUDIO,
headline = stringResource(R.string.audio),
supporting = currentAudio?.displayTitle,
),
)
add( add(
BottomDialogItem( BottomDialogItem(
data = PlaybackDialogType.PLAYBACK_SPEED, 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( BottomDialog(
choices = options, choices = options,
currentChoice = null, currentChoice = null,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onSelectChoice = { _, choice -> onSelectChoice = { _, choice ->
if (choice.data == PlaybackDialogType.DEBUG) {
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
} else {
onClickPlaybackDialogType(choice.data) onClickPlaybackDialogType(choice.data)
}
}, },
gravity = Gravity.END, gravity = Gravity.START,
) )
} }
@ -193,10 +178,6 @@ fun PlaybackDialog(
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onDismissRequest.invoke() onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
}, },
onSelectChoice = { _, choice -> onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index))
@ -220,15 +201,11 @@ fun PlaybackDialog(
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onDismissRequest.invoke() onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
}, },
onSelectChoice = { _, value -> onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
}, },
gravity = Gravity.END, gravity = Gravity.START,
) )
} }
@ -247,15 +224,11 @@ fun PlaybackDialog(
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onDismissRequest.invoke() onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
}, },
onSelectChoice = { _, choice -> onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) 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 package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity import android.view.Gravity
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
@ -28,6 +29,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -35,12 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged 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.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination 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.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
@ -167,6 +174,7 @@ fun PlaybackPageContent(
), ),
) )
val currentSegment by viewModel.currentSegment.collectAsState() val currentSegment by viewModel.currentSegment.collectAsState()
val analyticsState by viewModel.analyticsState.collectAsState()
val cues by viewModel.subtitleCues.observeAsState(listOf()) val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
@ -389,7 +397,7 @@ fun PlaybackPageContent(
.fillMaxSize() .fillMaxSize()
.background(Color.Transparent), .background(Color.Transparent),
item = currentPlayback?.item, item = currentPlayback?.item,
playerControls = player, player = player,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay, showPlay = playPauseState.showPlay,
previousEnabled = true, previousEnabled = true,
@ -412,6 +420,7 @@ fun PlaybackPageContent(
}, },
currentSegment = currentSegment?.segment, currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock, showClock = preferences.appPreferences.interfacePreferences.showClock,
analyticsState = analyticsState,
) )
val subtitleSettings = val subtitleSettings =
@ -427,9 +436,12 @@ fun PlaybackPageContent(
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
// Subtitles // Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) { val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
var cueCount by remember { mutableIntStateOf(0) }
val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface
AndroidView( AndroidView(
factory = { context -> factory = { context ->
SubtitleView(context).apply { SubtitleView(context).apply {
@ -455,16 +467,21 @@ fun PlaybackPageContent(
} }
} }
}, },
update = { update = { subtitleView ->
it.setCues(cues) 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)) Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
.apply(it) .apply(subtitleView)
it.children.firstOrNull { it is AssSubtitleView }?.let { cueCount = cues.size
}
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
(it as? AssSubtitleView)?.apply { (it as? AssSubtitleView)?.apply {
val resized = val resized =
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
if (resized) {
Timber.v("Resizing AssSubtitleView: $playerSurfaceSize") if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) {
Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize)
layoutParams = layoutParams =
FrameLayout FrameLayout
.LayoutParams( .LayoutParams(
@ -480,12 +497,20 @@ fun PlaybackPageContent(
}, },
modifier = modifier =
Modifier Modifier
.fillMaxSize(maxSize) .fillMaxSize(subtitleMaxSize)
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.background(Color.Transparent) .background(Color.Transparent)
.ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)), .graphicsLayer {
) alpha =
if (!subtitleVisible) {
0f
} else if (isImageSubtitles) {
subtitleImageOpacity
} else {
1f
} }
},
)
} }
// Ask to skip intros, etc button // Ask to skip intros, etc button
@ -642,7 +667,15 @@ fun PlaybackPageContent(
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null, playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
), ),
onDismissRequest = { onDismissRequest = {
playbackDialog = null playbackDialog =
when (type) {
// Go back to settings dialog
PlaybackDialogType.PLAYBACK_SPEED,
PlaybackDialogType.VIDEO_SCALE,
-> PlaybackDialogType.SETTINGS
else -> null
}
if (controllerViewState.controlsVisible) { if (controllerViewState.controlsVisible) {
controllerViewState.pulseControls() 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 package com.github.damontecres.wholphin.ui.playback
import android.content.Context import android.content.Context
import android.media.MediaCodecList
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.ui.text.intl.Locale 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.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.PlayerBackend 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.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService 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.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault 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.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStreamType 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.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateCommand
@ -118,6 +123,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
import kotlin.math.roundToInt
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -175,12 +181,13 @@ class PlaybackViewModel
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null) val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>() val currentItemPlayback = MutableLiveData<ItemPlayback>()
val currentSegment = MutableStateFlow<MediaSegmentState?>(null) val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
val analyticsState = MutableStateFlow(AnalyticsState())
val subtitleCues = MutableLiveData<List<Cue>>(listOf()) val subtitleCues = MutableLiveData<List<Cue>>(listOf())
private lateinit var preferences: UserPreferences private lateinit var preferences: UserPreferences
internal lateinit var itemId: UUID internal lateinit var itemId: UUID
internal lateinit var item: BaseItem internal lateinit var currentItem: PlaylistItem
internal var forceTranscoding: Boolean = false internal var forceTranscoding: Boolean = false
private var activityListener: TrackActivityPlaybackListener? = null private var activityListener: TrackActivityPlaybackListener? = null
private val jobs = mutableListOf<Job>() private val jobs = mutableListOf<Job>()
@ -225,8 +232,11 @@ class PlaybackViewModel
) { ) {
val softwareDecoding = val softwareDecoding =
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
val requestBackend =
(destination as? Destination.Playback)?.backend
?: preferences.appPreferences.playbackPreferences.playerBackend
val playerBackend = val playerBackend =
when (preferences.appPreferences.playbackPreferences.playerBackend) { when (requestBackend) {
PlayerBackend.UNRECOGNIZED, PlayerBackend.UNRECOGNIZED,
PlayerBackend.EXO_PLAYER, PlayerBackend.EXO_PLAYER,
-> PlayerBackend.EXO_PLAYER -> PlayerBackend.EXO_PLAYER
@ -289,21 +299,18 @@ class PlaybackViewModel
this.forceTranscoding = this.forceTranscoding =
(destination as? Destination.Playback)?.forceTranscoding ?: false (destination as? Destination.Playback)?.forceTranscoding ?: false
val positionMs: Long val positionMs: Long
val itemPlayback: ItemPlayback?
val forceTranscoding: Boolean val forceTranscoding: Boolean
val itemId = val itemId =
when (val d = destination) { when (val d = destination) {
is Destination.Playback -> { is Destination.Playback -> {
positionMs = d.positionMs positionMs = d.positionMs
itemPlayback = d.itemPlayback
forceTranscoding = d.forceTranscoding forceTranscoding = d.forceTranscoding
d.itemId d.itemId
} }
is Destination.PlaybackList -> { is Destination.PlaybackList -> {
positionMs = 0 positionMs = 0
itemPlayback = null
forceTranscoding = false forceTranscoding = false
d.itemId d.itemId
} }
@ -314,9 +321,9 @@ class PlaybackViewModel
} }
this.itemId = itemId this.itemId = itemId
val queriedItem = api.userLibraryApi.getItem(itemId).content val queriedItem = api.userLibraryApi.getItem(itemId).content
val base = val playlistItem =
if (queriedItem.type.playable) { if (queriedItem.type.playable) {
queriedItem PlaylistItem.Media(BaseItem(queriedItem, false))
} else if (destination is Destination.PlaybackList) { } else if (destination is Destination.PlaybackList) {
val playlistResult = val playlistResult =
playlistCreator.createFrom( playlistCreator.createFrom(
@ -344,9 +351,7 @@ class PlaybackViewModel
this@PlaybackViewModel.playlist.value = r.playlist this@PlaybackViewModel.playlist.value = r.playlist
} }
} }
r.playlist.items r.playlist.items.first()
.first()
.data
} }
} }
} else { } else {
@ -355,12 +360,39 @@ class PlaybackViewModel
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } 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 = val played =
play( play(
item, firstItem,
positionMs, positionMs,
itemPlayback,
forceTranscoding, forceTranscoding,
) )
if (!played) { if (!played) {
@ -370,9 +402,13 @@ class PlaybackViewModel
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
val result = playlistCreator.createFrom(queriedItem) val result = playlistCreator.createFrom(queriedItem)
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
withContext(Dispatchers.Main) { val currentPlaylist =
this@PlaybackViewModel.playlist.value = result.playlist 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 * Play an item
* *
* @param item the item to play * @param currentItem the item to play
* @param positionMs the starting playback position in milliseconds * @param positionMs the starting playback position in milliseconds
* @param itemPlayback the parameters for playback such chosen subtitle or audio streams * @param itemPlayback the parameters for playback such chosen subtitle or audio streams
* @param forceTranscoding whether the user has requested to force playback via transcoding * @param forceTranscoding whether the user has requested to force playback via transcoding
*/ */
private suspend fun play( private suspend fun play(
item: BaseItem, playlistItem: PlaylistItem,
positionMs: Long, positionMs: Long,
itemPlayback: ItemPlayback? = null,
forceTranscoding: Boolean = this.forceTranscoding, forceTranscoding: Boolean = this.forceTranscoding,
): Boolean = ): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val item =
when (playlistItem) {
is PlaylistItem.Intro -> playlistItem.item
is PlaylistItem.Media -> playlistItem.item
}
Timber.i("Playing ${item.id}") Timber.i("Playing ${item.id}")
// New item, so we can clear the media segment tracker & subtitle cues // New item, so we can clear the media segment tracker & subtitle cues
@ -411,16 +452,15 @@ class PlaybackViewModel
) )
return@withContext false return@withContext false
} }
this@PlaybackViewModel.item = item this@PlaybackViewModel.currentItem = playlistItem
this@PlaybackViewModel.itemId = item.id this@PlaybackViewModel.itemId = item.id
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
val base = item.data val base = item.data
// Use the provided playback parameters or else check if the database has some // Use the provided playback parameters or else check if the database has some
val playbackConfig = val itemPlayback =
itemPlayback serverRepository.currentUser.value?.let { user ->
?: serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let { itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it) Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) { if (it.sourceId != null) {
@ -430,7 +470,7 @@ class PlaybackViewModel
} }
} }
} }
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig) val mediaSource = streamChoiceService.chooseSource(base, itemPlayback)
val plc = streamChoiceService.getPlaybackLanguageChoice(base) val plc = streamChoiceService.getPlaybackLanguageChoice(base)
if (mediaSource == null) { if (mediaSource == null) {
@ -486,7 +526,7 @@ class PlaybackViewModel
.chooseAudioStream( .chooseAudioStream(
source = mediaSource, source = mediaSource,
seriesId = base.seriesId, seriesId = base.seriesId,
itemPlayback = playbackConfig, itemPlayback = itemPlayback,
plc = plc, plc = plc,
prefs = preferences, prefs = preferences,
) )
@ -498,7 +538,7 @@ class PlaybackViewModel
source = mediaSource, source = mediaSource,
audioStream = audioStream, audioStream = audioStream,
seriesId = base.seriesId, seriesId = base.seriesId,
itemPlayback = playbackConfig, itemPlayback = itemPlayback,
plc = plc, plc = plc,
prefs = preferences, prefs = preferences,
)?.index )?.index
@ -506,7 +546,7 @@ class PlaybackViewModel
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
val itemPlaybackToUse = val itemPlaybackToUse =
playbackConfig ?: ItemPlayback( itemPlayback ?: ItemPlayback(
rowId = -1, rowId = -1,
userId = -1, userId = -1,
itemId = base.id, itemId = base.id,
@ -776,6 +816,7 @@ class PlaybackViewModel
subtitleIndex, subtitleIndex,
source, source,
) )
Timber.v("onTracksChanged: %s", result)
if (result.bothSelected) { if (result.bothSelected) {
player.trackSelectionParameters = player.trackSelectionParameters =
result.trackSelectionParameters result.trackSelectionParameters
@ -885,7 +926,7 @@ class PlaybackViewModel
Timber.d("Changing audio track to %s", index) Timber.d("Changing audio track to %s", index)
val itemPlayback = val itemPlayback =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = currentItem.item,
itemPlayback = currentItemPlayback.value!!, itemPlayback = currentItemPlayback.value!!,
trackIndex = index, trackIndex = index,
type = MediaStreamType.AUDIO, type = MediaStreamType.AUDIO,
@ -899,7 +940,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex( streamChoiceService.resolveSubtitleIndex(
source = source, source = source,
audioStreamIndex = index, audioStreamIndex = index,
seriesId = item.data.seriesId, seriesId = currentItem.item.data.seriesId,
subtitleIndex = itemPlayback.subtitleIndex, subtitleIndex = itemPlayback.subtitleIndex,
prefs = preferences, prefs = preferences,
) )
@ -908,7 +949,7 @@ class PlaybackViewModel
} }
changeStreams( changeStreams(
item, currentItem.item,
itemPlayback, itemPlayback,
index, index,
resolvedSubtitleIndex, resolvedSubtitleIndex,
@ -923,7 +964,7 @@ class PlaybackViewModel
Timber.d("Changing subtitle track to %s", index) Timber.d("Changing subtitle track to %s", index)
val itemPlayback = val itemPlayback =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = currentItem.item,
itemPlayback = currentItemPlayback.value!!, itemPlayback = currentItemPlayback.value!!,
trackIndex = index, trackIndex = index,
type = MediaStreamType.SUBTITLE, type = MediaStreamType.SUBTITLE,
@ -937,7 +978,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex( streamChoiceService.resolveSubtitleIndex(
source = source, source = source,
audioStreamIndex = itemPlayback.audioIndex, audioStreamIndex = itemPlayback.audioIndex,
seriesId = item.data.seriesId, seriesId = currentItem.item.data.seriesId,
subtitleIndex = index, subtitleIndex = index,
prefs = preferences, prefs = preferences,
) )
@ -946,7 +987,7 @@ class PlaybackViewModel
} }
changeStreams( changeStreams(
item, currentItem.item,
itemPlayback, itemPlayback,
itemPlayback.audioIndex, itemPlayback.audioIndex,
resolvedIndex, resolvedIndex,
@ -981,7 +1022,7 @@ class PlaybackViewModel
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId, mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
): String? = ): String? =
trickPlayInfo?.let { trickPlayInfo?.let {
val itemId = item.id val itemId = currentItem.id
return api.trickplayApi.getTrickplayTileImageUrl( return api.trickplayApi.getTrickplayTileImageUrl(
itemId, itemId,
trickPlayInfo.width, trickPlayInfo.width,
@ -992,12 +1033,28 @@ class PlaybackViewModel
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) { if (playbackState == Player.STATE_ENDED) {
viewModelScope.launchIO { Timber.v("Playback state is STATE_ENDED")
val nextItem = playlist.value?.peek() viewModelScope.launchDefault {
Timber.v("Setting next up to ${nextItem?.id}") 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) { withContext(Dispatchers.Main) {
nextUp.value = nextItem nextUp.value = nextItem.item
if (nextItem == null) { }
}
}
null -> {
Timber.v("No next up")
navigationManager.goBack() navigationManager.goBack()
} }
} }
@ -1063,9 +1120,11 @@ class PlaybackViewModel
outroShownSegments.add(currentSegment.id) outroShownSegments.add(currentSegment.id)
) { ) {
val nextItem = playlist.peek() val nextItem = playlist.peek()
if (nextItem is PlaylistItem.Media) {
Timber.v("Setting next up during outro to ${nextItem?.id}") Timber.v("Setting next up during outro to ${nextItem?.id}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
nextUp.value = nextItem nextUp.value = nextItem.item
}
} }
} else { } else {
val behavior = val behavior =
@ -1190,7 +1249,7 @@ class PlaybackViewModel
fun playNextUp() { fun playNextUp() {
playlist.value?.let { playlist.value?.let {
if (it.hasNext()) { if (it.hasNext()) {
viewModelScope.launchIO { viewModelScope.launchDefault {
cancelUpNextEpisode() cancelUpNextEpisode()
val item = it.getAndAdvance() val item = it.getAndAdvance()
val played = play(item, 0) val played = play(item, 0)
@ -1205,7 +1264,7 @@ class PlaybackViewModel
fun playPrevious() { fun playPrevious() {
playlist.value?.let { playlist.value?.let {
if (it.hasPrevious()) { if (it.hasPrevious()) {
viewModelScope.launchIO { viewModelScope.launchDefault {
cancelUpNextEpisode() cancelUpNextEpisode()
val item = it.getPreviousAndReverse() val item = it.getPreviousAndReverse()
val played = play(item, 0) val played = play(item, 0)
@ -1266,7 +1325,7 @@ class PlaybackViewModel
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
changeStreams( changeStreams(
item, currentItem.item,
currentItemPlayback.value!!, currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex, currentItemPlayback.value?.audioIndex,
currentItemPlayback.value?.subtitleIndex, 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( override fun onVideoDecoderInitialized(
eventTime: AnalyticsListener.EventTime, eventTime: AnalyticsListener.EventTime,
decoderName: String, decoderName: String,
@ -1365,7 +1453,7 @@ class PlaybackViewModel
initializationDurationMs: Long, initializationDurationMs: Long,
) { ) {
Timber.v("onVideoDecoderInitialized: decoder=$decoderName") Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
currentPlayback.update { it?.copy(videoDecoder = decoderName) } updateDecoder(decoderName, MediaType.VIDEO)
} }
override fun onVideoDisabled( override fun onVideoDisabled(
@ -1384,7 +1472,7 @@ class PlaybackViewModel
decoderReuseEvaluation?.let { decoder -> decoderReuseEvaluation?.let { decoder ->
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}") 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, initializationDurationMs: Long,
) { ) {
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName") Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
currentPlayback.update { it?.copy(audioDecoder = decoderName) } updateDecoder(decoderName, MediaType.AUDIO)
} }
override fun onAudioInputFormatChanged( override fun onAudioInputFormatChanged(
@ -1407,7 +1495,7 @@ class PlaybackViewModel
decoderReuseEvaluation?.let { decoder -> decoderReuseEvaluation?.let { decoder ->
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}") 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) { override fun onIsPlayingChanged(isPlaying: Boolean) {
screensaverService.keepScreenOn(isPlaying) 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( data class PlayerState(
@ -1478,3 +1597,9 @@ data class MediaSegmentState(
val segment: MediaSegmentDto, val segment: MediaSegmentDto,
val interacted: Boolean, 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 androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
@ -106,12 +107,13 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
while (maxAttempts > 0 && subtitleCount == newCount) { while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts-- maxAttempts--
delay(1500) delay(1500)
item = val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content)
BaseItem.from( currentItem =
api.userLibraryApi.getItem(itemId = it.itemId).content, when (currentItem) {
api, is PlaylistItem.Intro -> PlaylistItem.Intro(base)
) is PlaylistItem.Media -> PlaylistItem.Media(base)
mediaSource = streamChoiceService.chooseSource(item.data, it) }
mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it)
if (mediaSource == null) { if (mediaSource == null) {
// This shouldn't happen, but just in case // This shouldn't happen, but just in case
showToast( showToast(
@ -158,7 +160,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
) )
} }
this@downloadAndSwitchSubtitles.changeStreams( this@downloadAndSwitchSubtitles.changeStreams(
item, currentItem.item,
currentItemPlayback.value!!, currentItemPlayback.value!!,
audioIndex, audioIndex,
newStream.index, newStream.index,

View file

@ -108,7 +108,7 @@ object TrackSelectionUtils {
true true
} }
val audioSelected = val audioSelected =
if (audioIndex != null && supportsDirectPlay) { if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) {
val indexToFind = val indexToFind =
calculateIndexToFind( calculateIndexToFind(
audioIndex, audioIndex,
@ -141,7 +141,7 @@ object TrackSelectionUtils {
} }
chosenTrack != null chosenTrack != null
} else { } else {
audioIndex == null true
} }
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected) 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.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility 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) @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 android.view.Gravity
import androidx.annotation.DrawableRes 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.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull 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.seekBack
import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.skipStringRes import com.github.damontecres.wholphin.ui.skipStringRes
@ -123,13 +125,10 @@ sealed interface PlaybackAction {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
fun PlaybackControls( fun PlaybackControls(
playerControls: Player, player: Player,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
moreFocusRequester: FocusRequester,
captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onSeekProgress: (Long) -> Unit, onSeekProgress: (Long) -> Unit,
showPlay: Boolean, showPlay: Boolean,
previousEnabled: Boolean, previousEnabled: Boolean,
@ -164,7 +163,7 @@ fun PlaybackControls(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
SeekBar( SeekBar(
player = playerControls, player = player,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
onSeekProgress = onSeekProgress, onSeekProgress = onSeekProgress,
interactionSource = seekBarInteractionSource, interactionSource = seekBarInteractionSource,
@ -184,13 +183,12 @@ fun PlaybackControls(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
LeftPlaybackButtons( LeftPlaybackButtons(
moreFocusRequester = moreFocusRequester,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
onClickPlaybackDialogType = onClickPlaybackDialogType, onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier.align(Alignment.CenterStart), modifier = Modifier.align(Alignment.CenterStart),
) )
PlaybackButtons( PlaybackButtons(
player = playerControls, player = player,
initialFocusRequester = initialFocusRequester, initialFocusRequester = initialFocusRequester,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick, onPlaybackActionClick = onPlaybackActionClick,
@ -209,7 +207,7 @@ fun PlaybackControls(
TextButton( TextButton(
stringRes = segment.type.skipStringRes, stringRes = segment.type.skipStringRes,
onClick = { onClick = {
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds) player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
}, },
modifier = modifier =
Modifier Modifier
@ -218,8 +216,6 @@ fun PlaybackControls(
) )
} }
RightPlaybackButtons( RightPlaybackButtons(
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
onClickPlaybackDialogType = onClickPlaybackDialogType, onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier, modifier = Modifier,
@ -301,7 +297,6 @@ val buttonSpacing = 12.dp
@Composable @Composable
fun LeftPlaybackButtons( fun LeftPlaybackButtons(
moreFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit, onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -310,24 +305,21 @@ fun LeftPlaybackButtons(
modifier = modifier.focusGroup(), modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing), horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) { ) {
// More options
PlaybackButton( PlaybackButton(
iconRes = R.drawable.baseline_more_vert_96, iconRes = R.drawable.vector_settings,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE) onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
}, },
enabled = true, enabled = true,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(moreFocusRequester), modifier = Modifier,
) )
} }
} }
@Composable @Composable
fun RightPlaybackButtons( fun RightPlaybackButtons(
captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit, onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -345,18 +337,18 @@ fun RightPlaybackButtons(
onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS) onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS)
}, },
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(captionFocusRequester), modifier = Modifier,
) )
// Playback speed, etc // Audio
PlaybackButton( PlaybackFaButton(
iconRes = R.drawable.vector_settings, enabled = true,
iconRes = R.string.fa_volume_high,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS) onClickPlaybackDialogType.invoke(PlaybackDialogType.AUDIO)
}, },
enabled = true,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(settingsFocusRequester), modifier = Modifier,
) )
} }
} }
@ -623,7 +615,7 @@ private fun ButtonPreview() {
onControllerInteraction = {}, onControllerInteraction = {},
) )
PlaybackFaButton( PlaybackFaButton(
iconRes = R.string.fa_shuffle, iconRes = R.string.fa_volume_high,
onClick = {}, onClick = {},
onControllerInteraction = {}, onControllerInteraction = {},
textColor = Color.Green, 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 * 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.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme 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 kotlinx.coroutines.FlowPreview
import kotlin.time.Duration 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.Composable
import androidx.compose.runtime.LaunchedEffect 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.annotation.FloatRange
import androidx.compose.foundation.Canvas 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.Animatable
import androidx.compose.animation.core.tween 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.animateColor
import androidx.compose.animation.core.InfiniteRepeatableSpec import androidx.compose.animation.core.InfiniteRepeatableSpec

View file

@ -36,6 +36,7 @@ enum class PreferenceScreenOption {
EXO_PLAYER, EXO_PLAYER,
MPV, MPV,
SCREENSAVER, SCREENSAVER,
SKIP_SEGMENTS,
; ;
companion object { 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.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ScreensaverPreference 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.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.screensaverPreferences
@ -142,6 +143,7 @@ fun PreferencesContent(
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.MPV -> MpvPreferences
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
} }
val screenTitle = val screenTitle =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -150,6 +152,7 @@ fun PreferencesContent(
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.MPV -> R.string.mpv_options
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
} }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
@ -707,6 +710,7 @@ fun PreferencesPage(
PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV, PreferenceScreenOption.MPV,
PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SCREENSAVER,
PreferenceScreenOption.SKIP_SEGMENTS,
-> { -> {
PreferencesContent( PreferencesContent(
initialPreferences, 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.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -72,6 +73,16 @@ fun PinEntry(
Key.DirectionRight -> "R" Key.DirectionRight -> "R"
Key.DirectionDown -> "D" Key.DirectionDown -> "D"
Key.DirectionLeft -> "L" 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 else -> return@onKeyEvent false
} }
onTextChange.invoke(str) onTextChange.invoke(str)
@ -122,6 +133,16 @@ fun PinEntryCreate(
Key.DirectionRight -> "R" Key.DirectionRight -> "R"
Key.DirectionDown -> "D" Key.DirectionDown -> "D"
Key.DirectionLeft -> "L" 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 else -> return@onKeyEvent false
} }
onTextChange.invoke(str) onTextChange.invoke(str)
@ -154,6 +175,7 @@ fun PinEntryCreate(
@Composable @Composable
fun PinArrowRow(modifier: Modifier = Modifier) { fun PinArrowRow(modifier: Modifier = Modifier) {
Row( Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
) { ) {
@ -167,6 +189,12 @@ fun PinArrowRow(modifier: Modifier = Modifier) {
fontFamily = FontAwesome, 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text 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.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress 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 org.jellyfin.sdk.model.api.PublicSystemInfo
import java.util.UUID import java.util.UUID
@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus {
) : 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 * Generate a consistent color for a UUID
*/ */

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