mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into develop/ac3-test
This commit is contained in:
commit
2d193f4aee
134 changed files with 6151 additions and 4180 deletions
1
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
1
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
|
|
@ -29,6 +29,7 @@ body:
|
|||
options:
|
||||
- ExoPlayer (default)
|
||||
- MPV
|
||||
- External App (please specify)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
|
|||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -16,7 +16,7 @@ concurrency:
|
|||
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.ref_name }}
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
|||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -9,7 +9,7 @@ defaults:
|
|||
|
||||
env:
|
||||
BUILD_DIRS_ARTIFACT: build-dirs
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -11,7 +11,7 @@ defaults:
|
|||
|
||||
env:
|
||||
APK_SHORT_NAME: Wholphin.apk
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: ht
|
|||
|
||||
Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
|
||||
|
||||
#### Extensions
|
||||
|
||||
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
|
||||
|
||||
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
|
||||
|
||||
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
|
||||
|
||||
## Code organization
|
||||
|
||||
Code is split into several packages:
|
||||
|
|
@ -43,21 +51,22 @@ Code is split into several packages:
|
|||
- `ui` - User interface code and ViewModels
|
||||
- `util` - Utility classes and functions
|
||||
|
||||
## Extensions
|
||||
### Settings
|
||||
|
||||
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
|
||||
There are a few different ways user settings are stored:
|
||||
1. `AppPreferences` via DataStore
|
||||
2. Room database
|
||||
3. Key-Value DataStore
|
||||
|
||||
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
|
||||
#### App preferences
|
||||
|
||||
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
|
||||
These are generally settings that apply across the whole app regardless of the currently active user.
|
||||
|
||||
### App settings
|
||||
The `AppPreferences` object can be retrieved from the `UserPreferencesService` or directly via injecting `DataStore<AppPreferences>`.
|
||||
|
||||
App settings are available with the `AppPreferences` object and defined by different `AppPreference` objects (note the `s` differences).
|
||||
The `AppPreference` (note the `s` differences) objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`.
|
||||
|
||||
The `AppPreference` objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`.
|
||||
|
||||
#### How to add a new app setting
|
||||
##### How to add a new app setting
|
||||
|
||||
1. Add entry in `WholphinDataStore.proto` & build to generate classes
|
||||
2. Add new `AppPreference` object in `AppPreference.kt`
|
||||
|
|
@ -65,3 +74,24 @@ The `AppPreference` objects are used to create the UI for configuring settings u
|
|||
4. Update `AppPreferencesSerializer` to set the default value for new installs
|
||||
5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades
|
||||
- Since preferences use proto3, the [default values](https://protobuf.dev/programming-guides/proto3/#default) are zero, false, or the first enum, so only need this step if the default value is different
|
||||
|
||||
#### Room settings
|
||||
|
||||
These are settings were generally are applied per user. They are stored in several different tables. The entities are defined in `com.github.damontecres.wholphin.data.model`.
|
||||
|
||||
Additionally, all server and user info for both Jellyfin and Seerr are stored in Room tables.
|
||||
|
||||
Some examples are:
|
||||
- `JellyfinServer`
|
||||
- `JellyfinUser`
|
||||
- `SeerrServer`
|
||||
- `LibraryDisplayInfo`
|
||||
- `ItemPlayback`
|
||||
|
||||
Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSON in a column instead of defined entities. This should be used sparingly.
|
||||
|
||||
#### Key-value settings
|
||||
|
||||
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
|
||||
|
||||
This should be used sparingly.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.android.build.api.dsl.ProductFlavor
|
||||
import com.android.build.api.variant.FilterConfiguration
|
||||
import com.google.protobuf.gradle.id
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
||||
|
|
@ -37,7 +39,15 @@ val gitDescribe =
|
|||
.standardOutput.asText
|
||||
.getOrElse("v0.0.0")
|
||||
|
||||
android {
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
javaParameters = true
|
||||
}
|
||||
}
|
||||
|
||||
configure<ApplicationExtension> {
|
||||
namespace = "com.github.damontecres.wholphin"
|
||||
compileSdk = 36
|
||||
|
||||
|
|
@ -98,18 +108,6 @@ android {
|
|||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" }
|
||||
val outputFileName =
|
||||
"Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
flavorDimensions += "version"
|
||||
productFlavors {
|
||||
|
|
@ -148,20 +146,11 @@ android {
|
|||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
javaParameters = true
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
|
|
@ -195,6 +184,26 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().all()) { variant ->
|
||||
variant.outputs
|
||||
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi =
|
||||
output
|
||||
.getFilter(FilterConfiguration.FilterType.ABI)
|
||||
.let { if (it != null) "-${it.identifier}" else "" }
|
||||
val outputFileName =
|
||||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@
|
|||
android:theme="@style/Theme.Wholphin"
|
||||
android:name=".WholphinApplication"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:largeHeap="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
|
|
@ -55,6 +54,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
|||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
|
|
@ -83,6 +83,7 @@ import javax.inject.Inject
|
|||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val viewModel: MainActivityViewModel by viewModels()
|
||||
private val playExternalViewModel: PlayExternalViewModel by viewModels()
|
||||
|
||||
@Inject
|
||||
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
||||
|
|
@ -153,12 +154,10 @@ class MainActivity : AppCompatActivity() {
|
|||
if (backStackStr != null) {
|
||||
Timber.d("Restoring back stack")
|
||||
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
||||
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
|
||||
|
||||
if (!playExternalViewModel.launched.value) {
|
||||
val lastDest = backStack.lastOrNull()
|
||||
if (lastDest is Destination.Playback ||
|
||||
lastDest is Destination.PlaybackList ||
|
||||
lastDest is Destination.Slideshow
|
||||
) {
|
||||
if (lastDest.isPlayback) {
|
||||
Timber.v("Restoring back stack with playback")
|
||||
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||
}
|
||||
|
|
@ -220,14 +219,7 @@ class MainActivity : AppCompatActivity() {
|
|||
signInAuto = appPreferences.signInAutomatically
|
||||
}
|
||||
CoilConfig(
|
||||
diskCacheSizeBytes =
|
||||
appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let {
|
||||
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
|
||||
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
prefs = appPreferences,
|
||||
okHttpClient = okHttpClient,
|
||||
debugLogging = false,
|
||||
enableCache = true,
|
||||
|
|
@ -279,6 +271,14 @@ class MainActivity : AppCompatActivity() {
|
|||
super.onRestart()
|
||||
Timber.d("onRestart")
|
||||
viewModel.appStart()
|
||||
if (!playExternalViewModel.launched.value) {
|
||||
// If restarting during playback that is not external, go back a page
|
||||
val lastDest = navigationManager.backStack.lastOrNull()
|
||||
if (lastDest.isPlayback) {
|
||||
Timber.v("onRestart: go back from playback")
|
||||
navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
|
@ -479,3 +479,9 @@ class MainActivityViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val Destination?.isPlayback: Boolean
|
||||
get() =
|
||||
this is Destination.Playback ||
|
||||
this is Destination.PlaybackList ||
|
||||
this is Destination.Slideshow
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ fun MainContent(
|
|||
|
||||
is SetupDestination.UserList -> {
|
||||
SwitchUserContent(
|
||||
currentServer = key.server,
|
||||
server = key.server,
|
||||
Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
|
|
@ -30,6 +32,7 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -48,6 +51,10 @@ class WholphinDreamService :
|
|||
@Inject
|
||||
lateinit var preferencesDataStore: DataStore<AppPreferences>
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
|
||||
private val savedStateRegistryController =
|
||||
|
|
@ -88,6 +95,12 @@ class WholphinDreamService :
|
|||
preferencesDataStore.data.collectLatest { prefs = it }
|
||||
}
|
||||
prefs?.let { prefs ->
|
||||
CoilConfig(
|
||||
prefs = prefs,
|
||||
okHttpClient = okHttpClient,
|
||||
debugLogging = false,
|
||||
enableCache = true,
|
||||
)
|
||||
WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) {
|
||||
ProvideLocalClock {
|
||||
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ sealed interface ExtrasItem {
|
|||
val parentId: UUID
|
||||
val type: ExtraType
|
||||
val destination: Destination
|
||||
val title: String?
|
||||
val imageUrl: String?
|
||||
val title: String
|
||||
val subtitle: String?
|
||||
val isPlayed: Boolean
|
||||
val playedPercentage: Double
|
||||
|
||||
/**
|
||||
* Represents multiple extras of the same type
|
||||
|
|
@ -24,11 +28,16 @@ sealed interface ExtrasItem {
|
|||
override val parentId: UUID,
|
||||
override val type: ExtraType,
|
||||
val items: List<BaseItem>,
|
||||
override val imageUrl: String?,
|
||||
override val title: String,
|
||||
override val subtitle: String,
|
||||
override val isPlayed: Boolean,
|
||||
) : ExtrasItem {
|
||||
override val destination: Destination =
|
||||
Destination.ItemGrid(null, type.stringRes, items.map { it.id })
|
||||
|
||||
override val title: String? = null
|
||||
override val playedPercentage
|
||||
get() = -1.0
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,12 +47,18 @@ sealed interface ExtrasItem {
|
|||
override val parentId: UUID,
|
||||
override val type: ExtraType,
|
||||
val item: BaseItem,
|
||||
override val imageUrl: String?,
|
||||
override val title: String,
|
||||
override val subtitle: String?,
|
||||
) : ExtrasItem {
|
||||
override val destination: Destination =
|
||||
Destination.Playback(
|
||||
item = item,
|
||||
)
|
||||
override val title: String? get() = item.title
|
||||
override val isPlayed: Boolean
|
||||
get() = item.played
|
||||
override val playedPercentage
|
||||
get() = item.data.userData?.playedPercentage ?: 0.0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class ServerRepository
|
|||
suspend fun changeUser(
|
||||
server: JellyfinServer,
|
||||
user: JellyfinUser,
|
||||
): CurrentUser? =
|
||||
): CurrentUser =
|
||||
withContext(ioDispatcher) {
|
||||
if (server.id != user.serverId) {
|
||||
throw IllegalStateException("User is not part of the server")
|
||||
|
|
@ -104,15 +104,16 @@ class ServerRepository
|
|||
currentUserId = updatedUser.id.toServerString()
|
||||
}.build()
|
||||
}
|
||||
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||
withContext(Dispatchers.Main) {
|
||||
_current.value = CurrentUser(updatedServer, updatedUser)
|
||||
_current.value = currentUser
|
||||
_currentUserDto.value = userDto
|
||||
}
|
||||
getServerSharedPreferences(context).edit(true) {
|
||||
putString(SERVER_URL_KEY, updatedServer.url)
|
||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||
}
|
||||
return@withContext _current.value
|
||||
return@withContext currentUser
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import java.util.UUID
|
|||
* This is not the same thing as a Jellyfin server playlist
|
||||
*/
|
||||
class Playlist(
|
||||
items: List<BaseItem>,
|
||||
items: List<PlaylistItem>,
|
||||
startIndex: Int = 0,
|
||||
) {
|
||||
val items = items.subList(startIndex, items.size)
|
||||
|
|
@ -23,15 +23,15 @@ class Playlist(
|
|||
|
||||
fun hasNext(): Boolean = (index + 1) < items.size
|
||||
|
||||
fun getPreviousAndReverse(): BaseItem = items[--index]
|
||||
fun getPreviousAndReverse(): PlaylistItem = items[--index]
|
||||
|
||||
fun getAndAdvance(): BaseItem = items[++index]
|
||||
fun getAndAdvance(): PlaylistItem = items[++index]
|
||||
|
||||
fun peek(): BaseItem? = items.getOrNull(index + 1)
|
||||
fun peek(): PlaylistItem? = items.getOrNull(index + 1)
|
||||
|
||||
fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size)
|
||||
fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size)
|
||||
|
||||
fun advanceTo(id: UUID): BaseItem? {
|
||||
fun advanceTo(id: UUID): PlaylistItem? {
|
||||
while (hasNext()) {
|
||||
val potential = getAndAdvance()
|
||||
if (potential.id == id) {
|
||||
|
|
@ -52,3 +52,22 @@ data class PlaylistInfo(
|
|||
val count: Int,
|
||||
val mediaType: MediaType,
|
||||
)
|
||||
|
||||
sealed interface PlaylistItem {
|
||||
val id: UUID
|
||||
val item: BaseItem
|
||||
|
||||
data class Media(
|
||||
override val item: BaseItem,
|
||||
) : PlaylistItem {
|
||||
override val id: UUID
|
||||
get() = item.id
|
||||
}
|
||||
|
||||
data class Intro(
|
||||
override val item: BaseItem,
|
||||
) : PlaylistItem {
|
||||
override val id: UUID
|
||||
get() = item.id
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,30 +117,6 @@ sealed interface AppPreference<Pref, T> {
|
|||
},
|
||||
)
|
||||
|
||||
// val GridJumpButtons =
|
||||
// AppSwitchPreference<AppPreferences>(
|
||||
// title = R.string.show_grid_jump_buttons,
|
||||
// defaultValue = true,
|
||||
// getter = { it.interfacePreferences.showGridJumpButtons },
|
||||
// setter = { prefs, value ->
|
||||
// prefs.updateInterfacePreferences { showGridJumpButtons = value }
|
||||
// },
|
||||
// summaryOn = R.string.enabled,
|
||||
// summaryOff = R.string.disabled,
|
||||
// )
|
||||
|
||||
// val ShowGridFooter =
|
||||
// AppSwitchPreference<AppPreferences>(
|
||||
// title = R.string.grid_position_footer,
|
||||
// defaultValue = true,
|
||||
// getter = { it.interfacePreferences.showPositionFooter },
|
||||
// setter = { prefs, value ->
|
||||
// prefs.updateInterfacePreferences { showPositionFooter = value }
|
||||
// },
|
||||
// summaryOn = R.string.show,
|
||||
// summaryOff = R.string.hide,
|
||||
// )
|
||||
|
||||
val ControllerTimeout =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.hide_controller_timeout,
|
||||
|
|
@ -513,6 +489,18 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val CinemaMode =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.cinema_mode,
|
||||
defaultValue = true,
|
||||
getter = { it.playbackPreferences.cinemaMode },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackPreferences { cinemaMode = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val RememberSelectedTab =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.remember_selected_tab,
|
||||
|
|
@ -664,6 +652,13 @@ sealed interface AppPreference<Pref, T> {
|
|||
valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
|
||||
)
|
||||
|
||||
val SkipSegments =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.skip_behavior,
|
||||
summary = R.string.skip_behavior_summary,
|
||||
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
|
||||
)
|
||||
|
||||
val GlobalContentScale =
|
||||
AppChoicePreference<AppPreferences, PrefContentScale>(
|
||||
title = R.string.global_content_scale,
|
||||
|
|
@ -1177,6 +1172,21 @@ val MpvPreferences =
|
|||
),
|
||||
)
|
||||
|
||||
val SkipSegmentPreferences =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.skip,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.SkipIntros,
|
||||
AppPreference.SkipOutros,
|
||||
AppPreference.SkipCommercials,
|
||||
AppPreference.SkipPreviews,
|
||||
AppPreference.SkipRecaps,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val advancedPreferences =
|
||||
buildList {
|
||||
add(
|
||||
|
|
@ -1203,7 +1213,9 @@ val advancedPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.OneClickPause,
|
||||
AppPreference.CinemaMode,
|
||||
AppPreference.GlobalContentScale,
|
||||
AppPreference.SkipSegments,
|
||||
AppPreference.MaxBitrate,
|
||||
AppPreference.RefreshRateSwitching,
|
||||
AppPreference.ResolutionSwitching,
|
||||
|
|
@ -1211,19 +1223,6 @@ val advancedPreferences =
|
|||
),
|
||||
),
|
||||
)
|
||||
add(
|
||||
PreferenceGroup(
|
||||
title = R.string.skip,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.SkipIntros,
|
||||
AppPreference.SkipOutros,
|
||||
AppPreference.SkipCommercials,
|
||||
AppPreference.SkipPreviews,
|
||||
AppPreference.SkipRecaps,
|
||||
),
|
||||
),
|
||||
)
|
||||
add(
|
||||
PreferenceGroup(
|
||||
title = R.string.player_backend,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class AppPreferencesSerializer
|
|||
refreshRateSwitching =
|
||||
AppPreference.RefreshRateSwitching.defaultValue
|
||||
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||
cinemaMode = AppPreference.CinemaMode.defaultValue
|
||||
|
||||
overrides =
|
||||
PlaybackOverrides
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
|||
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
|
|
@ -327,5 +328,11 @@ class AppUpgradeHandler
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.6.2-18-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.pluralRes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.ExtraType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -19,6 +24,8 @@ class ExtrasService
|
|||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
) {
|
||||
/**
|
||||
* Get the [ExtrasItem]s for the given item
|
||||
|
|
@ -39,9 +46,45 @@ class ExtrasService
|
|||
extrasMap
|
||||
.mapNotNull { (type, items) ->
|
||||
if (items.size == 1) {
|
||||
ExtrasItem.Single(itemId, type, items.first())
|
||||
val item = items.first()
|
||||
val title =
|
||||
item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
|
||||
val subtitle =
|
||||
if (item.title == null) {
|
||||
context.resources.getQuantityString(type.pluralRes, 1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
ExtrasItem.Single(
|
||||
parentId = itemId,
|
||||
type = type,
|
||||
item = item,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
|
||||
)
|
||||
} else if (items.size > 1) {
|
||||
ExtrasItem.Group(itemId, type, items)
|
||||
val title =
|
||||
context.resources.getQuantityString(type.pluralRes, items.size)
|
||||
val subtitle =
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.items,
|
||||
items.size,
|
||||
items.size,
|
||||
)
|
||||
ExtrasItem.Group(
|
||||
parentId = itemId,
|
||||
type = type,
|
||||
items = items,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
isPlayed = items.all { it.played },
|
||||
imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
items.random(),
|
||||
ImageType.PRIMARY,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.ui.combinePair
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -68,6 +71,16 @@ class MediaManagementService
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun collectCanDelete(
|
||||
itemFlow: Flow<BaseItem?>,
|
||||
update: (Boolean) -> Unit,
|
||||
) {
|
||||
itemFlow.combinePair(userPreferencesService.flow).collectLatest { (item, prefs) ->
|
||||
val canDelete = item?.let { canDelete(item, prefs.appPreferences) } ?: false
|
||||
update.invoke(canDelete)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the item.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -19,14 +19,6 @@ class PlaybackLifecycleObserver
|
|||
private var wasPlaying: Boolean? = null
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
// TODO
|
||||
// val lastDest = navigationManager.backStack.lastOrNull()
|
||||
// if (lastDest is Destination.Playback ||
|
||||
// lastDest is Destination.PlaybackList ||
|
||||
// lastDest is Destination.Slideshow
|
||||
// ) {
|
||||
// navigationManager.goBack()
|
||||
// }
|
||||
wasPlaying = null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,13 @@ class PlayerFactory
|
|||
.setExtensionRendererMode(rendererMode)
|
||||
val mediaSourceFactory =
|
||||
if (useLibAss) {
|
||||
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
|
||||
val renderType =
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
AssRenderType.OVERLAY_CANVAS
|
||||
} else {
|
||||
AssRenderType.OVERLAY_OPEN_GL
|
||||
}
|
||||
assHandler = AssHandler(renderType)
|
||||
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
|
||||
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
||||
DefaultMediaSourceFactory(
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.components.baseItemKinds
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.gt
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||
|
|
@ -22,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -247,14 +250,14 @@ class PlaylistCreator
|
|||
-> {
|
||||
val list =
|
||||
buildList {
|
||||
add(BaseItem(item, false))
|
||||
add(PlaylistItem.Media(BaseItem(item, false)))
|
||||
|
||||
if (item.partCount.gt(1)) {
|
||||
api.videosApi
|
||||
.getAdditionalPart(item.id)
|
||||
.content.items
|
||||
.map {
|
||||
BaseItem(it, false)
|
||||
PlaylistItem.Media(BaseItem(it, false))
|
||||
}.let(::addAll)
|
||||
}
|
||||
}
|
||||
|
|
@ -274,14 +277,14 @@ class PlaylistCreator
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<BaseItem> =
|
||||
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<PlaylistItem> =
|
||||
buildList {
|
||||
this@convertAndAddParts.forEach { ep ->
|
||||
add(BaseItem(ep, useSeriesForPrimary))
|
||||
add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary)))
|
||||
if (ep.partCount.gt(1)) {
|
||||
val parts =
|
||||
api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
|
||||
BaseItem(part, useSeriesForPrimary)
|
||||
PlaylistItem.Media(BaseItem(part, useSeriesForPrimary))
|
||||
}
|
||||
addAll(parts)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
|||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.components.CurrentItem
|
||||
import com.github.damontecres.wholphin.ui.components.ScreensaverItem
|
||||
import com.github.damontecres.wholphin.ui.formatDate
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
|
|
@ -154,30 +154,20 @@ class ScreensaverService
|
|||
/**
|
||||
* Create a flow of items to show on the screensaver
|
||||
*/
|
||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
||||
fun createItemFlow(scope: CoroutineScope): Flow<ScreensaverItem?> =
|
||||
flow {
|
||||
val prefs =
|
||||
userPreferencesService.flow
|
||||
.first()
|
||||
.appPreferences
|
||||
.interfacePreferences.screensaverPreference
|
||||
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
|
||||
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
recursive = true,
|
||||
includeItemTypes = itemTypes,
|
||||
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
maxOfficialRating = maxAge?.toString(),
|
||||
hasParentalRating = maxAge?.let { true },
|
||||
)
|
||||
val pager =
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||
try {
|
||||
createPager()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error creating pager for screensaver")
|
||||
emit(ScreensaverItem.Error(ex))
|
||||
return@flow
|
||||
}
|
||||
Timber.v("Got %s items", pager.size)
|
||||
var index = 0
|
||||
if (pager.isEmpty()) {
|
||||
emit(null)
|
||||
emit(ScreensaverItem.Empty)
|
||||
} else {
|
||||
val duration =
|
||||
userPreferencesService
|
||||
|
|
@ -213,7 +203,14 @@ class ScreensaverService
|
|||
.build(),
|
||||
).job
|
||||
.await()
|
||||
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
|
||||
emit(
|
||||
ScreensaverItem.CurrentItem(
|
||||
item,
|
||||
backdropUrl,
|
||||
logoUrl,
|
||||
title ?: "",
|
||||
),
|
||||
)
|
||||
delay(duration)
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +225,26 @@ class ScreensaverService
|
|||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).cancellable()
|
||||
|
||||
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
|
||||
val prefs =
|
||||
userPreferencesService.flow
|
||||
.first()
|
||||
.appPreferences
|
||||
.interfacePreferences.screensaverPreference
|
||||
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
|
||||
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
recursive = true,
|
||||
includeItemTypes = itemTypes,
|
||||
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
maxOfficialRating = maxAge?.toString(),
|
||||
hasParentalRating = maxAge?.let { true },
|
||||
)
|
||||
return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||
}
|
||||
}
|
||||
|
||||
data class ScreensaverState(
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ class UpdateChecker
|
|||
context.contentResolver.delete(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
|
||||
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
|
||||
arrayOf("$ASSET_NAME%", APK_MIME_TYPE),
|
||||
)
|
||||
Timber.i("Deleted $deletedRows rows")
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,32 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
|||
import coil3.request.Options
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Composable
|
||||
fun CoilConfig(
|
||||
prefs: AppPreferences,
|
||||
okHttpClient: OkHttpClient,
|
||||
debugLogging: Boolean,
|
||||
enableCache: Boolean = true,
|
||||
) = CoilConfig(
|
||||
diskCacheSizeBytes =
|
||||
prefs.advancedPreferences.imageDiskCacheSizeBytes.let {
|
||||
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
|
||||
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
okHttpClient = okHttpClient,
|
||||
debugLogging = debugLogging,
|
||||
enableCache = enableCache,
|
||||
)
|
||||
|
||||
/**
|
||||
* Configure Coil image loading
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
|
@ -70,6 +71,8 @@ fun EpisodeCard(
|
|||
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
|
||||
val width = imageHeight * aspectRatio
|
||||
val height = imageWidth * (1f / aspectRatio)
|
||||
val density = LocalDensity.current
|
||||
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.size(width, height),
|
||||
|
|
@ -102,6 +105,7 @@ fun EpisodeCard(
|
|||
watchedPercent = dto?.userData?.playedPercentage,
|
||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||
useFallbackText = false,
|
||||
fillWidth = imageWidthPx,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
import com.github.damontecres.wholphin.data.pluralRes
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@Composable
|
||||
fun ExtrasRow(
|
||||
|
|
@ -22,52 +16,27 @@ fun ExtrasRow(
|
|||
onLongClickItem: (Int, ExtrasItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
ItemRow(
|
||||
title = stringResource(R.string.extras),
|
||||
items = extras,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val imageUrl =
|
||||
remember {
|
||||
val item =
|
||||
when (item) {
|
||||
is ExtrasItem.Group -> item.items.random()
|
||||
is ExtrasItem.Single -> item.item
|
||||
null -> null
|
||||
}
|
||||
imageUrlService.getItemImageUrl(item, ImageType.PRIMARY)
|
||||
}
|
||||
SeasonCard(
|
||||
title =
|
||||
when (item) {
|
||||
is ExtrasItem.Group -> item.type.pluralRes.let { resources.getQuantityString(it, item.items.size) }
|
||||
is ExtrasItem.Single -> item.title ?: item.type.pluralRes.let { resources.getQuantityString(it, 1) }
|
||||
null -> null
|
||||
},
|
||||
title = item?.title,
|
||||
name = null,
|
||||
subtitle =
|
||||
if (item is ExtrasItem.Single && item.title != null) {
|
||||
item.type.pluralRes.let { resources.getQuantityString(it, 1) }
|
||||
} else if (item is ExtrasItem.Group) {
|
||||
resources.getQuantityString(R.plurals.items, item.items.size, item.items.size)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
subtitle = item?.subtitle,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = mod,
|
||||
showImageOverlay = true,
|
||||
imageHeight = Cards.height2x3 * .75f,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
imageWidth = Dp.Unspecified,
|
||||
imageUrl = imageUrl,
|
||||
imageUrl = item?.imageUrl,
|
||||
isFavorite = false,
|
||||
isPlayed = false,
|
||||
isPlayed = item?.isPlayed == true,
|
||||
unplayedItemCount = -1,
|
||||
playedPercentage = -1.0,
|
||||
playedPercentage = item?.playedPercentage ?: 0.0,
|
||||
numberOfVersions = -1,
|
||||
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ fun GridCard(
|
|||
imageContentScale: ContentScale = ContentScale.Fit,
|
||||
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||
showTitle: Boolean = true,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
) {
|
||||
val dto = item?.data
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
|
|
@ -95,6 +97,8 @@ fun GridCard(
|
|||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||
useFallbackText = false,
|
||||
contentScale = imageContentScale,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -24,11 +24,9 @@ import androidx.compose.ui.graphics.BlendMode
|
|||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onLayoutRectChanged
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -63,20 +61,19 @@ fun ItemCardImage(
|
|||
imageType: ImageType = ImageType.PRIMARY,
|
||||
useFallbackText: Boolean = true,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val imageUrl =
|
||||
remember(size, item) {
|
||||
if (size != IntSize.Zero && item != null) {
|
||||
remember(item) {
|
||||
item?.let {
|
||||
imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
imageType,
|
||||
fillWidth = size.width,
|
||||
fillHeight = size.height,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ItemCardImage(
|
||||
|
|
@ -88,13 +85,7 @@ fun ItemCardImage(
|
|||
unwatchedCount = unwatchedCount,
|
||||
watchedPercent = watchedPercent,
|
||||
numberOfVersions = numberOfVersions,
|
||||
modifier =
|
||||
modifier.onLayoutRectChanged(
|
||||
throttleMillis = 100,
|
||||
debounceMillis = 25,
|
||||
) {
|
||||
size = IntSize(width = it.width, height = it.height)
|
||||
},
|
||||
modifier = modifier,
|
||||
useFallbackText = useFallbackText,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -32,6 +37,7 @@ import androidx.compose.ui.graphics.Shader
|
|||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.core.DataStore
|
||||
|
|
@ -45,6 +51,7 @@ import coil3.compose.AsyncImage
|
|||
import coil3.compose.useExistingImageAsPlaceholder
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transitionFactory
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
|
|
@ -53,6 +60,7 @@ import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
|||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
|
||||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -67,12 +75,20 @@ class ScreensaverViewModel
|
|||
val currentItem = screensaverService.createItemFlow(viewModelScope)
|
||||
}
|
||||
|
||||
sealed interface ScreensaverItem {
|
||||
data class Error(
|
||||
val exception: Exception,
|
||||
) : ScreensaverItem
|
||||
|
||||
data object Empty : ScreensaverItem
|
||||
|
||||
data class CurrentItem(
|
||||
val item: BaseItem,
|
||||
val backdropUrl: String,
|
||||
val logoUrl: String?,
|
||||
val title: String,
|
||||
)
|
||||
) : ScreensaverItem
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScreensaver(
|
||||
|
|
@ -93,7 +109,7 @@ fun AppScreensaver(
|
|||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun AppScreensaverContent(
|
||||
currentItem: CurrentItem?,
|
||||
currentItem: ScreensaverItem?,
|
||||
showClock: Boolean,
|
||||
duration: Duration,
|
||||
animate: Boolean,
|
||||
|
|
@ -116,6 +132,26 @@ fun AppScreensaverContent(
|
|||
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(
|
||||
model =
|
||||
ImageRequest
|
||||
|
|
@ -172,6 +208,8 @@ fun AppScreensaverContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val largeRadialGradient =
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService
|
|||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
|
|
@ -74,13 +75,14 @@ import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
|||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -133,7 +135,7 @@ class CollectionFolderViewModel
|
|||
private val savedStateHandle: SavedStateHandle,
|
||||
api: ApiClient,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
val serverRepository: ServerRepository,
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
|
|
@ -142,6 +144,7 @@ class CollectionFolderViewModel
|
|||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
private val musicService: MusicService,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
|
|
@ -570,7 +573,44 @@ fun CollectionFolderGrid(
|
|||
itemId.toServerString(),
|
||||
initialFilter,
|
||||
recursive,
|
||||
onClickItem,
|
||||
GridClickActions(onClickItem = onClickItem),
|
||||
sortOptions,
|
||||
playEnabled,
|
||||
viewModelKey = viewModelKey,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
modifier = modifier,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
showTitle = showTitle,
|
||||
positionCallback = positionCallback,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
filterOptions = filterOptions,
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderGrid(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
initialFilter: CollectionFolderFilter,
|
||||
recursive: Boolean,
|
||||
actions: GridClickActions,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModelKey: String? = itemId.toServerString(),
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
) = CollectionFolderGrid(
|
||||
preferences,
|
||||
itemId.toServerString(),
|
||||
initialFilter,
|
||||
recursive,
|
||||
actions,
|
||||
sortOptions,
|
||||
playEnabled,
|
||||
viewModelKey = viewModelKey,
|
||||
|
|
@ -590,7 +630,7 @@ fun CollectionFolderGrid(
|
|||
itemId: String,
|
||||
initialFilter: CollectionFolderFilter,
|
||||
recursive: Boolean,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
actions: GridClickActions,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
|
|
@ -625,11 +665,106 @@ fun CollectionFolderGrid(
|
|||
val item by viewModel.item.observeAsState()
|
||||
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
|
||||
|
||||
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(viewModel.position, itemId, watched)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(viewModel.position, itemId, favorite)
|
||||
},
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onDeleteItem = { viewModel.deleteItem(viewModel.position, it) },
|
||||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||
onChooseVersion = { _, _ ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onChooseTracks = { result ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
// Not supported on this page
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val gridActions =
|
||||
remember(actions) {
|
||||
GridClickActions(
|
||||
onClickItem = actions.onClickItem,
|
||||
onLongClickItem =
|
||||
actions.onLongClickItem ?: { position, item ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = item,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
onClickPlayAll =
|
||||
actions.onClickPlayAll ?: { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
val destination =
|
||||
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = it,
|
||||
index = 0,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
}
|
||||
Unit
|
||||
},
|
||||
onClickPlayRemoteButton =
|
||||
actions.onClickPlayRemoteButton ?: { index, item ->
|
||||
val destination =
|
||||
if (item.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = item.id,
|
||||
index = index,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.Playback(item)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (val state = loading) {
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
|
|
@ -662,10 +797,8 @@ fun CollectionFolderGrid(
|
|||
sortAndDirection = sortAndDirection!!,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = { position, item ->
|
||||
moreDialog.makePresent(PositionItem(position, item))
|
||||
},
|
||||
onClickItem = gridActions.onClickItem,
|
||||
onLongClickItem = gridActions.onLongClickItem!!,
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, filter)
|
||||
},
|
||||
|
|
@ -689,47 +822,8 @@ fun CollectionFolderGrid(
|
|||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = { index, item ->
|
||||
val destination =
|
||||
if (item.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = item.id,
|
||||
index = index,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.Playback(item)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
onClickPlayAll = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
val destination =
|
||||
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = it,
|
||||
index = 0,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
}
|
||||
},
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
onClickPlayAll = gridActions.onClickPlayAll!!,
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
|
|
@ -751,47 +845,22 @@ fun CollectionFolderGrid(
|
|||
}
|
||||
}
|
||||
}
|
||||
moreDialog.compose { (position, item) ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = item.title ?: "",
|
||||
dialogItems =
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
item = item,
|
||||
seriesId = null,
|
||||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigateTo(it) },
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(position, itemId, watched)
|
||||
},
|
||||
onClickFavorite = { itemId, watched ->
|
||||
viewModel.setFavorite(position, itemId, watched)
|
||||
},
|
||||
onClickAddPlaylist = {
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(it)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = PositionItem(position, item)
|
||||
},
|
||||
onClickGoTo = {
|
||||
onClickItem.invoke(position, it)
|
||||
},
|
||||
onClickAddToQueue = {
|
||||
viewModel.addToQueue(it, 0)
|
||||
},
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = true,
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
|
|
@ -811,16 +880,6 @@ fun CollectionFolderGrid(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
|
@ -1002,7 +1061,7 @@ fun CollectionFolderGridContent(
|
|||
position = newPosition
|
||||
positionCallback?.invoke(columns, newPosition)
|
||||
},
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
|
|
@ -1011,6 +1070,7 @@ fun CollectionFolderGridContent(
|
|||
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
||||
imageType = viewOptions.imageType,
|
||||
showTitle = viewOptions.showTitles,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
@ -1051,17 +1111,13 @@ data class PositionItem(
|
|||
data class CollectionFolderGridParameters(
|
||||
val columns: Int = 6,
|
||||
val spacing: Dp = 16.dp,
|
||||
val cardContent: @Composable (
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
mod: Modifier,
|
||||
) -> Unit = { item, onClick, onLongClick, mod ->
|
||||
val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = ContentScale.FillBounds,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
@ -1071,13 +1127,14 @@ data class CollectionFolderGridParameters(
|
|||
CollectionFolderGridParameters(
|
||||
columns = 6,
|
||||
spacing = 16.dp,
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = ContentScale.FillBounds,
|
||||
imageAspectRatio = AspectRatios.TALL,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
@ -1086,13 +1143,14 @@ data class CollectionFolderGridParameters(
|
|||
CollectionFolderGridParameters(
|
||||
columns = 4,
|
||||
spacing = 24.dp,
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = ContentScale.Crop,
|
||||
imageAspectRatio = AspectRatios.WIDE,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
@ -1101,13 +1159,14 @@ data class CollectionFolderGridParameters(
|
|||
CollectionFolderGridParameters(
|
||||
columns = 6,
|
||||
spacing = 16.dp,
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = ContentScale.FillBounds,
|
||||
imageAspectRatio = AspectRatios.SQUARE,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
@ -1165,3 +1224,10 @@ fun GridTitle(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
data class GridClickActions(
|
||||
val onClickItem: (Int, BaseItem) -> Unit,
|
||||
val onLongClickItem: ((Int, BaseItem) -> Unit)? = null,
|
||||
val onClickPlayAll: ((shuffle: Boolean) -> Unit)? = null,
|
||||
val onClickPlayRemoteButton: ((Int, BaseItem) -> Unit)? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -104,11 +104,13 @@ data class DialogItem(
|
|||
val trailingContent: @Composable (() -> Unit)? = null,
|
||||
val enabled: Boolean = true,
|
||||
val selected: Boolean = false,
|
||||
val dismissOnClick: Boolean = false,
|
||||
) : DialogItemEntry {
|
||||
constructor(
|
||||
@StringRes text: Int,
|
||||
@StringRes iconStringRes: Int,
|
||||
iconColor: Color = Color.Unspecified,
|
||||
dismissOnClick: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) : this(
|
||||
headlineContent = {
|
||||
|
|
@ -124,11 +126,13 @@ data class DialogItem(
|
|||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
dismissOnClick = dismissOnClick,
|
||||
)
|
||||
|
||||
constructor(
|
||||
text: String,
|
||||
@StringRes iconStringRes: Int,
|
||||
dismissOnClick: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) : this(
|
||||
headlineContent = {
|
||||
|
|
@ -145,12 +149,14 @@ data class DialogItem(
|
|||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
dismissOnClick = dismissOnClick,
|
||||
)
|
||||
|
||||
constructor(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
iconColor: Color? = null,
|
||||
dismissOnClick: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) : this(
|
||||
headlineContent = {
|
||||
|
|
@ -167,10 +173,12 @@ data class DialogItem(
|
|||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
dismissOnClick = dismissOnClick,
|
||||
)
|
||||
|
||||
constructor(
|
||||
text: String,
|
||||
dismissOnClick: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) : this(
|
||||
headlineContent = {
|
||||
|
|
@ -180,6 +188,7 @@ data class DialogItem(
|
|||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
dismissOnClick = dismissOnClick,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
|
@ -201,7 +210,7 @@ fun DialogPopup(
|
|||
dismissOnClick: Boolean = true,
|
||||
waitToLoad: Boolean = true,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 8.dp,
|
||||
elevation: Dp = 1.dp,
|
||||
) {
|
||||
var waiting by remember { mutableStateOf(waitToLoad) }
|
||||
if (showDialog) {
|
||||
|
|
@ -296,7 +305,7 @@ fun DialogPopupContent(
|
|||
selected = item.selected,
|
||||
enabled = !waiting && item.enabled,
|
||||
onClick = {
|
||||
if (dismissOnClick) {
|
||||
if (dismissOnClick || item.dismissOnClick) {
|
||||
onDismissRequest.invoke()
|
||||
}
|
||||
item.onClick.invoke()
|
||||
|
|
@ -349,7 +358,7 @@ fun DialogPopup(
|
|||
onDismissRequest: () -> Unit,
|
||||
dismissOnClick: Boolean = true,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 8.dp,
|
||||
elevation: Dp = 1.dp,
|
||||
) = DialogPopup(
|
||||
showDialog = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
|
|
@ -427,7 +436,7 @@ fun ScrollableDialog(
|
|||
fun BasicDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 8.dp,
|
||||
elevation: Dp = 1.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
|
|
@ -458,7 +467,7 @@ fun ConfirmDialog(
|
|||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 8.dp,
|
||||
elevation: Dp = 1.dp,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) = BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
|
|
@ -527,10 +536,12 @@ fun ConfirmDeleteDialog(
|
|||
itemTitle: String,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
elevation: Dp = 3.dp,
|
||||
) {
|
||||
BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
elevation = elevation,
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ fun GenreCardGrid(
|
|||
},
|
||||
columns = columns,
|
||||
spacing = spacing,
|
||||
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GenreCard(
|
||||
genre = item,
|
||||
onClick = onClick,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -133,11 +133,12 @@ fun ItemGrid(
|
|||
showJumpButtons = false,
|
||||
showLetterButtons = false,
|
||||
spacing = 24.dp,
|
||||
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
||||
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
imageAspectRatio = AspectRatios.WIDE, // TODO
|
||||
)
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
*/
|
||||
@Composable
|
||||
fun ExpandablePlayButtons(
|
||||
title: String,
|
||||
resumePosition: Duration,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
|
|
@ -79,7 +80,7 @@ fun ExpandablePlayButtons(
|
|||
favoriteOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
deleteOnClick: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -163,7 +164,8 @@ fun ExpandablePlayButtons(
|
|||
if (canDelete) {
|
||||
item("delete") {
|
||||
DeleteButton(
|
||||
onClick = deleteOnClick,
|
||||
title = title,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged),
|
||||
|
|
@ -381,10 +383,12 @@ fun TrailerButton(
|
|||
|
||||
@Composable
|
||||
fun DeleteButton(
|
||||
onClick: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val iconTint by
|
||||
animateColorAsState(
|
||||
|
|
@ -415,10 +419,20 @@ fun DeleteButton(
|
|||
.size(24.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onClick.invoke() },
|
||||
onClick = { showDeleteDialog = true },
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
if (showDeleteDialog) {
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = title,
|
||||
onCancel = { showDeleteDialog = false },
|
||||
onConfirm = {
|
||||
onConfirmDelete.invoke()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
|
|
@ -426,6 +440,7 @@ fun DeleteButton(
|
|||
private fun ExpandablePlayButtonsPreview() {
|
||||
WholphinTheme(true) {
|
||||
ExpandablePlayButtons(
|
||||
title = "Movie",
|
||||
resumePosition = 10.seconds,
|
||||
watched = false,
|
||||
favorite = false,
|
||||
|
|
@ -437,7 +452,7 @@ private fun ExpandablePlayButtonsPreview() {
|
|||
trailers = listOf(),
|
||||
trailerOnClick = {},
|
||||
canDelete = true,
|
||||
deleteOnClick = {},
|
||||
onConfirmDelete = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -482,7 +497,8 @@ private fun ViewOptionsPreview() {
|
|||
modifier = Modifier,
|
||||
)
|
||||
DeleteButton(
|
||||
onClick = {},
|
||||
onConfirmDelete = {},
|
||||
title = "Movie",
|
||||
)
|
||||
SortByButton(
|
||||
sortOptions = listOf(),
|
||||
|
|
@ -491,7 +507,8 @@ private fun ViewOptionsPreview() {
|
|||
)
|
||||
}
|
||||
DeleteButton(
|
||||
onClick = {},
|
||||
onConfirmDelete = {},
|
||||
title = "Movie",
|
||||
interactionSource = source,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
|
|
@ -18,6 +17,7 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -31,11 +31,11 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
|||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -60,6 +60,7 @@ import java.util.UUID
|
|||
abstract class RecommendedViewModel(
|
||||
@param:ApplicationContext val context: Context,
|
||||
val api: ApiClient,
|
||||
val serverRepository: ServerRepository,
|
||||
val navigationManager: NavigationManager,
|
||||
val favoriteWatchManager: FavoriteWatchManager,
|
||||
val mediaReportService: MediaReportService,
|
||||
|
|
@ -167,10 +168,9 @@ fun RecommendedContent(
|
|||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
OneTimeLaunchedEffect {
|
||||
|
|
@ -192,6 +192,35 @@ fun RecommendedContent(
|
|||
|
||||
LoadingState.Success -> {
|
||||
var position by rememberPosition()
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel.navigationManager::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(position, itemId, watched)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(position, itemId, favorite)
|
||||
},
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onDeleteItem = { viewModel.deleteItem(position, it) },
|
||||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||
onChooseVersion = { _, _ ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onChooseTracks = { result ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
// Not supported on this page
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HomePageContent(
|
||||
homeRows = rows,
|
||||
position = position,
|
||||
|
|
@ -199,7 +228,18 @@ fun RecommendedContent(
|
|||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { position, item ->
|
||||
moreDialog.makePresent(RowColumnItem(position, item))
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = item,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
|
|
@ -226,42 +266,22 @@ fun RecommendedContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
moreDialog.compose { (position, item) ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = item.title ?: "",
|
||||
dialogItems =
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
item = item,
|
||||
seriesId = null,
|
||||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(position, itemId, watched)
|
||||
},
|
||||
onClickFavorite = { itemId, watched ->
|
||||
viewModel.setFavorite(position, itemId, watched)
|
||||
},
|
||||
onClickAddPlaylist = {
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(it)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
|
||||
onClickAddToQueue = {
|
||||
viewModel.addToQueue(it, 0)
|
||||
},
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = true,
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
|
|
@ -281,16 +301,6 @@ fun RecommendedContent(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class RowColumnItem(
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class RecommendedMovieViewModel
|
|||
@ApplicationContext context: Context,
|
||||
api: ApiClient,
|
||||
musicService: MusicService,
|
||||
private val serverRepository: ServerRepository,
|
||||
serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val suggestionService: SuggestionService,
|
||||
@Assisted val parentId: UUID,
|
||||
|
|
@ -70,6 +70,7 @@ class RecommendedMovieViewModel
|
|||
) : RecommendedViewModel(
|
||||
context,
|
||||
api,
|
||||
serverRepository,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class RecommendedMusicViewModel
|
|||
@ApplicationContext context: Context,
|
||||
api: ApiClient,
|
||||
musicService: MusicService,
|
||||
private val serverRepository: ServerRepository,
|
||||
serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val suggestionService: SuggestionService,
|
||||
@Assisted val parentId: UUID,
|
||||
|
|
@ -69,6 +69,7 @@ class RecommendedMusicViewModel
|
|||
) : RecommendedViewModel(
|
||||
context,
|
||||
api,
|
||||
serverRepository,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class RecommendedTvShowViewModel
|
|||
@ApplicationContext context: Context,
|
||||
api: ApiClient,
|
||||
musicService: MusicService,
|
||||
private val serverRepository: ServerRepository,
|
||||
serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val lastestNextUpService: LatestNextUpService,
|
||||
private val suggestionService: SuggestionService,
|
||||
|
|
@ -74,6 +74,7 @@ class RecommendedTvShowViewModel
|
|||
) : RecommendedViewModel(
|
||||
context,
|
||||
api,
|
||||
serverRepository,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ fun StudioCardGrid(
|
|||
},
|
||||
columns = columns,
|
||||
spacing = spacing,
|
||||
cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
StudioCard(
|
||||
studio = item,
|
||||
onClick = onClick,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -34,6 +35,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -51,7 +53,8 @@ import androidx.compose.ui.input.key.KeyEventType
|
|||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onLayoutRectChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
|
@ -62,10 +65,8 @@ import androidx.tv.material3.LocalContentColor
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
|
||||
import com.github.damontecres.wholphin.ui.playback.isForwardButton
|
||||
|
|
@ -76,6 +77,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.math.ceil
|
||||
|
||||
private const val DEBUG = false
|
||||
|
||||
|
|
@ -85,6 +87,15 @@ interface CardGridItem {
|
|||
val sortName: String
|
||||
}
|
||||
|
||||
data class GridItemDetails<T : CardGridItem>(
|
||||
val item: T?,
|
||||
val index: Int,
|
||||
val onClick: () -> Unit,
|
||||
val onLongClick: () -> Unit,
|
||||
val widthPx: Int,
|
||||
val mod: Modifier,
|
||||
)
|
||||
|
||||
/**
|
||||
* Shows a vertical grid of [CardGridItem]s
|
||||
*/
|
||||
|
|
@ -102,31 +113,21 @@ fun <T : CardGridItem> CardGrid(
|
|||
modifier: Modifier = Modifier,
|
||||
initialPosition: Int = 0,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
cardContent: @Composable (
|
||||
item: T?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
mod: Modifier,
|
||||
) -> Unit = { item, onClick, onLongClick, mod ->
|
||||
GridCard(
|
||||
item = item as BaseItem?,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = ContentScale.FillBounds,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
cardContent: @Composable (GridItemDetails<T>) -> Unit,
|
||||
columns: Int = 6,
|
||||
spacing: Dp = 16.dp,
|
||||
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
|
||||
) {
|
||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||
val startPosition =
|
||||
remember(initialPosition, pager.size) {
|
||||
initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
|
||||
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
||||
val currentFocusedIndex by rememberUpdatedState(focusedIndex)
|
||||
val gridState =
|
||||
rememberLazyGridState(
|
||||
cacheWindow = fractionCacheWindow,
|
||||
cacheWindow = LazyLayoutCacheWindow(aheadFraction = 2f, behindFraction = 0.5f),
|
||||
initialFirstVisibleItemIndex = focusedIndex,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -135,13 +136,16 @@ fun <T : CardGridItem> CardGrid(
|
|||
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
var alphabetFocus by remember { mutableStateOf(false) }
|
||||
val focusOn = { index: Int ->
|
||||
if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index")
|
||||
if (index != focusedIndex) {
|
||||
val focusOn =
|
||||
remember {
|
||||
{ index: Int ->
|
||||
if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index")
|
||||
if (index != currentFocusedIndex) {
|
||||
previouslyFocusedIndex = focusedIndex
|
||||
}
|
||||
focusedIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for a recomposition to focus
|
||||
val alphabetFocusRequester = remember { FocusRequester() }
|
||||
|
|
@ -180,7 +184,9 @@ fun <T : CardGridItem> CardGrid(
|
|||
}
|
||||
}
|
||||
|
||||
val jump = { jump: Int ->
|
||||
val jump =
|
||||
remember {
|
||||
{ jump: Int ->
|
||||
scope.launch(ExceptionHandler()) {
|
||||
val newPosition =
|
||||
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
|
||||
|
|
@ -189,9 +195,12 @@ fun <T : CardGridItem> CardGrid(
|
|||
gridState.scrollToItem(newPosition, 0)
|
||||
}
|
||||
}
|
||||
val jumpToTop = {
|
||||
}
|
||||
val jumpToTop =
|
||||
remember {
|
||||
{
|
||||
scope.launch(ExceptionHandler()) {
|
||||
if (focusedIndex < (columns * 6)) {
|
||||
if (currentFocusedIndex < (columns * 6)) {
|
||||
// If close, animate the scroll
|
||||
gridState.animateScrollToItem(0, 0)
|
||||
} else {
|
||||
|
|
@ -201,6 +210,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
zeroFocus.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val jumpToLetter: (Char) -> Unit =
|
||||
remember {
|
||||
|
|
@ -298,6 +308,9 @@ fun <T : CardGridItem> CardGrid(
|
|||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
var cardWidthPx by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
|
|
@ -314,20 +327,26 @@ fun <T : CardGridItem> CardGrid(
|
|||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
// Leaving the grid, so "forget" the position
|
||||
// focusedIndex = -1
|
||||
}
|
||||
onEnter = {
|
||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||
focusedIndex = startPosition
|
||||
}
|
||||
}
|
||||
}.onLayoutRectChanged(0, 0) {
|
||||
val width = it.width
|
||||
val spacingPx = with(density) { spacing.toPx() }
|
||||
val cardWidth =
|
||||
ceil((width - (spacingPx * (columns - 1))) / columns)
|
||||
cardWidthPx = cardWidth.toInt()
|
||||
Timber.v("cardWidthPx=%s", cardWidthPx)
|
||||
},
|
||||
) {
|
||||
items(pager.size) { index ->
|
||||
val item = pager[index]
|
||||
val details =
|
||||
remember(index, item) {
|
||||
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")
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
|
|
@ -336,19 +355,27 @@ fun <T : CardGridItem> CardGrid(
|
|||
} else {
|
||||
Modifier
|
||||
}
|
||||
val item = pager[index]
|
||||
cardContent(
|
||||
item,
|
||||
{
|
||||
GridItemDetails(
|
||||
item = item,
|
||||
index = index,
|
||||
onClick = {
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
onClickItem.invoke(index, item)
|
||||
}
|
||||
},
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
onLongClick = {
|
||||
if (item != null) {
|
||||
onLongClickItem.invoke(index, item)
|
||||
}
|
||||
},
|
||||
widthPx = cardWidthPx,
|
||||
mod =
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
.onFocusChanged { focusState ->
|
||||
.ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(zeroFocus),
|
||||
).onFocusChanged { focusState ->
|
||||
if (DEBUG) {
|
||||
Timber.v(
|
||||
"$index isFocused=${focusState.isFocused}",
|
||||
|
|
@ -358,14 +385,12 @@ fun <T : CardGridItem> CardGrid(
|
|||
// Focused, so set that up
|
||||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
// savedFocusedIndex = index
|
||||
// // Was focused on this, so mark unfocused
|
||||
// focusedIndex = -1
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
cardContent.invoke(details)
|
||||
}
|
||||
}
|
||||
if (pager.isEmpty()) {
|
||||
// focusedIndex = -1
|
||||
|
|
@ -429,11 +454,21 @@ fun <T : CardGridItem> CardGrid(
|
|||
// Add end padding to push away from edge
|
||||
letterClicked = jumpToLetter,
|
||||
)
|
||||
} else {
|
||||
// Spacer ensures card sizes do not change if the alphabet buttons are not shown
|
||||
Spacer(
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.width(letterHorizontalPadding * 2 + letterButtonSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val letterHorizontalPadding = 2.dp
|
||||
private val letterButtonSize = 14.dp
|
||||
|
||||
@Composable
|
||||
fun JumpButtons(
|
||||
jump1: Int,
|
||||
|
|
@ -500,7 +535,7 @@ fun AlphabetButtons(
|
|||
var alphabetPickerFocused by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
|
||||
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = letterHorizontalPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(1.1.dp),
|
||||
state = listState,
|
||||
modifier =
|
||||
|
|
@ -534,14 +569,14 @@ fun AlphabetButtons(
|
|||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(14.dp)
|
||||
.size(letterButtonSize)
|
||||
.clip(CircleShape)
|
||||
.alpha(itemAlpha),
|
||||
) {
|
||||
Button(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(14.dp)
|
||||
.size(letterButtonSize)
|
||||
.focusRequester(focusRequesters[index]),
|
||||
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
|
||||
interactionSource = interactionSource,
|
||||
|
|
|
|||
|
|
@ -23,14 +23,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||
import com.github.damontecres.wholphin.ui.components.RecommendedMusic
|
||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
||||
|
|
@ -40,17 +44,36 @@ import com.github.damontecres.wholphin.ui.data.SongSortOptions
|
|||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.logTab
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import javax.inject.Inject
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel
|
||||
@HiltViewModel(assistedFactory = CollectionFolderMusicViewModel.Factory::class)
|
||||
class CollectionFolderMusicViewModel
|
||||
@Inject
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val musicService: MusicService,
|
||||
) : ViewModel() {
|
||||
private val navigationManager: NavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
private val rememberTabManager: RememberTabManager,
|
||||
@Assisted private val itemId: UUID,
|
||||
) : ViewModel(),
|
||||
RememberTabManager by rememberTabManager {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: UUID): CollectionFolderMusicViewModel
|
||||
}
|
||||
|
||||
fun play(item: BaseItem) {
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
viewModelScope.launchDefault {
|
||||
|
|
@ -58,6 +81,44 @@ class CollectionFolderMusicViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onClick(
|
||||
index: Int,
|
||||
item: BaseItem,
|
||||
) {
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
viewModelScope.launchDefault {
|
||||
musicService.setQueue(listOf(item), false)
|
||||
}
|
||||
} else {
|
||||
navigationManager.navigateTo(item.destination())
|
||||
}
|
||||
}
|
||||
|
||||
fun onClickPlayAll(shuffle: Boolean) {
|
||||
viewModelScope.launchDefault {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
parentId = itemId,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
recursive = true,
|
||||
)
|
||||
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||
musicService.setQueue(pager, 0, shuffle)
|
||||
}
|
||||
}
|
||||
|
||||
fun onClickPlayRemoteButton(
|
||||
index: Int,
|
||||
item: BaseItem,
|
||||
) {
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
viewModelScope.launchDefault {
|
||||
musicService.setQueue(listOf(item), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -65,11 +126,13 @@ fun CollectionFolderMusic(
|
|||
preferences: UserPreferences,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CollectionFolderMusicViewModel = hiltViewModel(),
|
||||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||
viewModel: CollectionFolderMusicViewModel =
|
||||
hiltViewModel<CollectionFolderMusicViewModel, CollectionFolderMusicViewModel.Factory>(
|
||||
creationCallback = { it.create(destination.itemId) },
|
||||
),
|
||||
) {
|
||||
val rememberedTabIndex =
|
||||
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||
|
||||
val tabs =
|
||||
listOf(
|
||||
|
|
@ -88,12 +151,22 @@ fun CollectionFolderMusic(
|
|||
|
||||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("music", selectedTabIndex)
|
||||
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||
preferencesViewModel.backdropService.clearBackdrop()
|
||||
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||
viewModel.backdropService.clearBackdrop()
|
||||
}
|
||||
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
val actions =
|
||||
remember {
|
||||
GridClickActions(
|
||||
onClickItem = viewModel::onClick,
|
||||
onLongClickItem = null,
|
||||
onClickPlayAll = viewModel::onClickPlayAll,
|
||||
onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
|
|
@ -133,9 +206,7 @@ fun CollectionFolderMusic(
|
|||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
actions = actions,
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_albums",
|
||||
initialFilter =
|
||||
|
|
@ -165,9 +236,7 @@ fun CollectionFolderMusic(
|
|||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
actions = actions,
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_artists",
|
||||
initialFilter =
|
||||
|
|
@ -209,9 +278,7 @@ fun CollectionFolderMusic(
|
|||
4 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
viewModel.play(item)
|
||||
},
|
||||
actions = actions,
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_songs",
|
||||
initialFilter =
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||
|
|
@ -51,14 +52,22 @@ fun CollectionFolderPhotoAlbum(
|
|||
var showHeader by remember { mutableStateOf(true) }
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
actions =
|
||||
remember {
|
||||
GridClickActions(
|
||||
onClickItem = { index, item ->
|
||||
val destination =
|
||||
if (item.type == BaseItemKind.PHOTO) {
|
||||
Destination.Slideshow(
|
||||
parentId = itemId,
|
||||
index = index,
|
||||
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
|
||||
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
filter = viewModel.filter.value ?: GetItemsFilter(),
|
||||
),
|
||||
sortAndDirection =
|
||||
viewModel.sortAndDirection.value
|
||||
?: SortAndDirection.DEFAULT,
|
||||
recursive = recursive,
|
||||
startSlideshow = false,
|
||||
)
|
||||
|
|
@ -67,6 +76,8 @@ fun CollectionFolderPhotoAlbum(
|
|||
}
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
)
|
||||
},
|
||||
itemId = itemId.toServerString(),
|
||||
initialFilter = filter,
|
||||
showTitle = showHeader,
|
||||
|
|
|
|||
|
|
@ -6,21 +6,16 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
|||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.util.supportedPlayableTypes
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -36,234 +31,6 @@ data class MoreDialogActions(
|
|||
val onClickAddToQueue: (BaseItem) -> Unit = {},
|
||||
)
|
||||
|
||||
enum class ClearChosenStreams {
|
||||
NONE,
|
||||
ITEM_AND_SERIES,
|
||||
SERIES,
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the [DialogItem]s when clicking "More"
|
||||
*
|
||||
* If there are multiple versions, adds an option to pick one
|
||||
*
|
||||
* If there is more than one (ie two or more) audio track, adds an option to pick one
|
||||
*
|
||||
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
|
||||
*
|
||||
* @param item the media item to build for, typically an Episode or Movie
|
||||
* @param seriesId the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
|
||||
* @param sourceId the item's media source UUID
|
||||
* @param onChooseVersion callback to pick a version of the item
|
||||
* @param onChooseTracks callback to pick a track for the given type of the item
|
||||
* @param onShowOverview callback to show overview dialog with media information
|
||||
*/
|
||||
fun buildMoreDialogItems(
|
||||
context: Context,
|
||||
item: BaseItem,
|
||||
seriesId: UUID?,
|
||||
sourceId: UUID?,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canClearChosenStreams: Boolean,
|
||||
canDelete: Boolean,
|
||||
actions: MoreDialogActions,
|
||||
onChooseVersion: () -> Unit,
|
||||
onChooseTracks: (MediaStreamType) -> Unit,
|
||||
onShowOverview: () -> Unit,
|
||||
onClearChosenStreams: () -> Unit,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.Playback(
|
||||
item.id,
|
||||
item.resumeMs ?: 0L,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
item.data.mediaSources?.letNotEmpty { sources ->
|
||||
val source =
|
||||
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } }
|
||||
?: sources.firstOrNull()
|
||||
source?.mediaStreams?.letNotEmpty { streams ->
|
||||
val audioCount = streams.count { it.type == MediaStreamType.AUDIO }
|
||||
val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE }
|
||||
if (audioCount > 1) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(
|
||||
R.string.choose_stream,
|
||||
context.getString(R.string.audio),
|
||||
),
|
||||
R.string.fa_volume_low,
|
||||
) {
|
||||
onChooseTracks.invoke(MediaStreamType.AUDIO)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (subtitleCount > 0) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(
|
||||
R.string.choose_stream,
|
||||
context.getString(R.string.subtitles),
|
||||
),
|
||||
R.string.fa_closed_captioning,
|
||||
) {
|
||||
onChooseTracks.invoke(MediaStreamType.SUBTITLE)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sources.size > 1) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(
|
||||
R.string.choose_stream,
|
||||
context.getString(R.string.version),
|
||||
),
|
||||
R.string.fa_file_video,
|
||||
) {
|
||||
onChooseVersion.invoke()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.add_to_playlist,
|
||||
iconStringRes = R.string.fa_list_ul,
|
||||
) {
|
||||
actions.onClickAddPlaylist.invoke(item.id)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
||||
) {
|
||||
actions.onClickWatch.invoke(item.id, !watched)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||
iconStringRes = R.string.fa_heart,
|
||||
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||
) {
|
||||
actions.onClickFavorite.invoke(item.id, !favorite)
|
||||
},
|
||||
)
|
||||
item.data.albumId?.let { albumId ->
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_album),
|
||||
R.string.fa_compact_disc,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.MediaItem(
|
||||
albumId,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_artist),
|
||||
R.string.fa_user,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.MediaItem(
|
||||
artistId,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
seriesId?.let {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_series),
|
||||
Icons.AutoMirrored.Filled.ArrowForward,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.MediaItem(
|
||||
seriesId,
|
||||
BaseItemKind.SERIES,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (item.data.mediaSources?.isNotEmpty() == true) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.media_information),
|
||||
Icons.Default.Info,
|
||||
) {
|
||||
onShowOverview.invoke()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canClearChosenStreams) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.clear_track_choices),
|
||||
Icons.Default.Delete,
|
||||
) {
|
||||
onClearChosenStreams()
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play_with_transcoding),
|
||||
Icons.Default.PlayArrow,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.Playback(
|
||||
item.id,
|
||||
item.resumeMs ?: 0L,
|
||||
forceTranscoding = true,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.send_media_info_log_to_server,
|
||||
iconStringRes = R.string.fa_file_video,
|
||||
) {
|
||||
actions.onSendMediaInfo.invoke(item.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun buildMoreDialogItemsForHome(
|
||||
context: Context,
|
||||
item: BaseItem,
|
||||
|
|
@ -424,29 +191,3 @@ fun buildMoreDialogItemsForHome(
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun buildMoreDialogItemsForPerson(
|
||||
context: Context,
|
||||
person: Person,
|
||||
actions: MoreDialogActions,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
val itemId = person.id
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||
iconStringRes = R.string.fa_heart,
|
||||
iconColor = if (person.favorite) Color.Red else Color.Unspecified,
|
||||
) {
|
||||
actions.onClickFavorite.invoke(itemId, !person.favorite)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
||||
|
|
@ -109,13 +110,20 @@ fun FavoritesPage(
|
|||
focusRequesters = tabFocusRequesters,
|
||||
)
|
||||
}
|
||||
val actions =
|
||||
remember {
|
||||
GridClickActions(
|
||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||
)
|
||||
}
|
||||
|
||||
// TODO playEnabled = true for movies & episodes
|
||||
when (selectedTabIndex) {
|
||||
// Movies
|
||||
0 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_movies",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
|
|
@ -147,7 +155,7 @@ fun FavoritesPage(
|
|||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_series",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
|
|
@ -179,7 +187,7 @@ fun FavoritesPage(
|
|||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_episodes",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
|
|
@ -212,7 +220,7 @@ fun FavoritesPage(
|
|||
3 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_videos",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
|
|
@ -244,7 +252,7 @@ fun FavoritesPage(
|
|||
4 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_playlists",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
|
|
@ -276,7 +284,7 @@ fun FavoritesPage(
|
|||
5 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_people",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -72,14 +73,15 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
|||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||
import com.github.damontecres.wholphin.ui.components.FilterByButton
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.MusicContextActions
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.SortByButton
|
||||
|
|
@ -87,10 +89,8 @@ import com.github.damontecres.wholphin.ui.components.TextButton
|
|||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
|
||||
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
|
||||
import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||
|
|
@ -371,7 +371,7 @@ fun PlaylistDetails(
|
|||
val state by viewModel.state.collectAsState()
|
||||
val musicState by viewModel.musicState.collectAsState()
|
||||
|
||||
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
|
@ -404,23 +404,23 @@ fun PlaylistDetails(
|
|||
}
|
||||
}
|
||||
}
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val musicMoreActions =
|
||||
MusicMoreDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
val musicContextActions =
|
||||
MusicContextActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
|
||||
onClickPlayNext = { index, item -> viewModel.playNext(item) },
|
||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
|
||||
onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
|
||||
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||
onClickAddPlaylist = { itemId ->
|
||||
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onClickRemoveFromQueue = {},
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
onClickRemoveFromQueue = { _, _ -> },
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
)
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
|
||||
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||
|
|
@ -429,9 +429,15 @@ fun PlaylistDetails(
|
|||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel::sendMediaReport,
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
|
||||
onShowOverview = {},
|
||||
onChooseVersion = { _, _ -> },
|
||||
onChooseTracks = {},
|
||||
onClearChosenStreams = {},
|
||||
onClickRemoveFromNextUp = {},
|
||||
)
|
||||
}
|
||||
|
||||
PlaylistDetailsContent(
|
||||
loadingState = state.loading,
|
||||
|
|
@ -448,43 +454,41 @@ fun PlaylistDetails(
|
|||
}
|
||||
},
|
||||
onLongClickIndex = { index, item ->
|
||||
longClickDialog =
|
||||
DialogParams(
|
||||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
showContextMenu =
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = musicMoreActions,
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = true,
|
||||
item = item,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
canRemoveFromQueue = false,
|
||||
actions = musicContextActions,
|
||||
)
|
||||
} else {
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = item,
|
||||
seriesId = item.data.seriesId,
|
||||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions = moreActions,
|
||||
)
|
||||
},
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
}
|
||||
},
|
||||
filterAndSort = state.filterAndSort,
|
||||
onFilterAndSortChange = viewModel::loadItems,
|
||||
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
||||
modifier = modifier,
|
||||
)
|
||||
longClickDialog?.let { params ->
|
||||
DialogPopup(
|
||||
params = params,
|
||||
onDismissRequest = { longClickDialog = null },
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showConfirmTypeDialog?.let { (index, item, shuffle) ->
|
||||
|
|
@ -510,16 +514,6 @@ fun PlaylistDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -788,6 +782,9 @@ fun PlaylistItem(
|
|||
isQueued: Boolean = false,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val imageWidth = 160.dp
|
||||
val density = LocalDensity.current
|
||||
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
|
||||
ListItem(
|
||||
selected = false,
|
||||
onClick = onClick,
|
||||
|
|
@ -850,8 +847,9 @@ fun PlaylistItem(
|
|||
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = 0.0,
|
||||
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||
modifier = Modifier.width(160.dp),
|
||||
modifier = Modifier.width(imageWidth),
|
||||
useFallbackText = false,
|
||||
fillWidth = imageWidthPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ fun CollectionButtons(
|
|||
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||
onClickViewOptions: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
deleteOnClick: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
canDelete: Boolean,
|
||||
moreOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -91,9 +91,9 @@ fun CollectionButtons(
|
|||
if (canDelete) {
|
||||
item("delete") {
|
||||
DeleteButton(
|
||||
onClick = deleteOnClick,
|
||||
modifier =
|
||||
Modifier,
|
||||
title = state.collection?.title ?: "",
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.collection
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -16,9 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -33,7 +28,6 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -44,12 +38,12 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
|
|
@ -57,10 +51,8 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -85,33 +77,15 @@ fun CollectionDetails(
|
|||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// Dialogs
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var showDeleteDialog by remember { mutableStateOf<Pair<RowColumn?, BaseItem>?>(null) }
|
||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
|
||||
// Actions
|
||||
val onClickItem =
|
||||
remember {
|
||||
{ _: 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,
|
||||
fun contextActionsFor(position: RowColumn?) =
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(itemId, watched, position)
|
||||
},
|
||||
|
|
@ -123,15 +97,38 @@ fun CollectionDetails(
|
|||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { item -> showDeleteDialog = Pair(position, item) },
|
||||
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
|
||||
),
|
||||
onDeleteItem = { viewModel.deleteItem(it, position) },
|
||||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||
onChooseVersion = { _, _ ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onChooseTracks = { result ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
// Not supported on this page
|
||||
},
|
||||
)
|
||||
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,
|
||||
title = item.title ?: "",
|
||||
items = dialogItems,
|
||||
item = item,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActionsFor(position),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -144,7 +141,7 @@ fun CollectionDetails(
|
|||
{ filter: GetItemsFilter -> viewModel.changeFilter(filter) }
|
||||
}
|
||||
val onClickPlay = { _: RowColumn, item: BaseItem ->
|
||||
viewModel.navigate(Destination.Playback(item = item))
|
||||
viewModel.navigateTo(Destination.Playback(item = item))
|
||||
}
|
||||
val onClickPlayAll =
|
||||
remember {
|
||||
|
|
@ -158,7 +155,7 @@ fun CollectionDetails(
|
|||
sortAndDirection = state.sortAndDirection,
|
||||
filter = state.itemFilter,
|
||||
)
|
||||
viewModel.navigate(dest)
|
||||
viewModel.navigateTo(dest)
|
||||
}
|
||||
}
|
||||
val onClickViewOptions = remember { { showViewOptionsDialog = true } }
|
||||
|
|
@ -201,7 +198,7 @@ fun CollectionDetails(
|
|||
}
|
||||
}
|
||||
},
|
||||
deleteOnClick =
|
||||
onConfirmDelete =
|
||||
remember {
|
||||
{
|
||||
state.collection?.let {
|
||||
|
|
@ -210,43 +207,30 @@ fun CollectionDetails(
|
|||
}
|
||||
},
|
||||
canDelete =
|
||||
remember(state.collection) {
|
||||
remember(
|
||||
state.collection,
|
||||
preferences.appPreferences.interfacePreferences.enableMediaManagement,
|
||||
) {
|
||||
state.collection?.let {
|
||||
viewModel.canDelete(it, preferences.appPreferences)
|
||||
} ?: false
|
||||
},
|
||||
moreOnClick = {
|
||||
val collection = state.collection!!
|
||||
val items =
|
||||
buildMoreDialogItemsForCollection(
|
||||
context = context,
|
||||
item = collection,
|
||||
favorite = collection.favorite,
|
||||
canDelete = viewModel.canDelete(collection, preferences.appPreferences),
|
||||
onClickPlayAll = onClickPlayAll,
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel::navigate,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(itemId, watched, null)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(itemId, favorite, null)
|
||||
},
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { item -> showDeleteDialog = Pair(null, item) },
|
||||
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
|
||||
),
|
||||
)
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = false,
|
||||
title = collection.title ?: "",
|
||||
items = items,
|
||||
item = state.collection!!,
|
||||
chosenStreams = null,
|
||||
showGoTo = false,
|
||||
showStreamChoices = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
state.collection!!,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActionsFor(null),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -259,14 +243,22 @@ fun CollectionDetails(
|
|||
onViewOptionsChange = viewModel::changeViewOptions,
|
||||
)
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
|
|
@ -286,26 +278,6 @@ fun CollectionDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item, position)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -324,7 +296,7 @@ fun CollectionDetailsContent(
|
|||
onClickViewOptions: () -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
deleteOnClick: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
canDelete: Boolean,
|
||||
moreOnClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
|
|
@ -363,15 +335,9 @@ fun CollectionDetailsContent(
|
|||
) {
|
||||
// This box exists so that there is something focusable above the item content
|
||||
// allowing focus to move up to restore the collection's header
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(0.dp)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) itemsContentHasFocus = false
|
||||
}.focusable(),
|
||||
)
|
||||
HiddenFocusBox {
|
||||
itemsContentHasFocus = false
|
||||
}
|
||||
if (state.viewOptions.cardViewOptions.showDetails) {
|
||||
HomePageHeader(
|
||||
item = focusedItem,
|
||||
|
|
@ -429,7 +395,7 @@ fun CollectionDetailsContent(
|
|||
getPossibleFilterValues = getPossibleFilterValues,
|
||||
onClickViewOptions = onClickViewOptions,
|
||||
favoriteOnClick = favoriteOnClick,
|
||||
deleteOnClick = deleteOnClick,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
canDelete = canDelete,
|
||||
moreOnClick = moreOnClick,
|
||||
modifier =
|
||||
|
|
@ -491,60 +457,3 @@ fun CollectionDetailsContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMoreDialogItemsForCollection(
|
||||
context: Context,
|
||||
item: BaseItem,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
||||
actions: MoreDialogActions,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
onClickPlayAll.invoke(false)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.shuffle),
|
||||
R.string.fa_shuffle,
|
||||
) {
|
||||
onClickPlayAll.invoke(true)
|
||||
},
|
||||
)
|
||||
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.add_to_playlist,
|
||||
iconStringRes = R.string.fa_list_ul,
|
||||
) {
|
||||
actions.onClickAddPlaylist.invoke(item.id)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||
iconStringRes = R.string.fa_heart,
|
||||
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||
) {
|
||||
actions.onClickFavorite.invoke(item.id, !favorite)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ fun CollectionMixedGrid(
|
|||
positionCallback = { _, newPosition ->
|
||||
onFocusPosition.invoke(RowColumn(0, newPosition))
|
||||
},
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
|
|
@ -69,6 +69,7 @@ fun CollectionMixedGrid(
|
|||
imageAspectRatio = cardViewOptions.aspectRatio.ratio,
|
||||
imageType = cardViewOptions.imageType,
|
||||
showTitle = cardViewOptions.showTitles,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ class CollectionViewModel
|
|||
result.totalRecordCount
|
||||
}
|
||||
|
||||
fun navigate(destination: Destination) {
|
||||
fun navigateTo(destination: Destination) {
|
||||
release()
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
|
@ -393,7 +393,9 @@ class CollectionViewModel
|
|||
position: RowColumn?,
|
||||
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||
favoriteWatchManager.setWatched(itemId, played)
|
||||
if (position != null) {
|
||||
if (itemId == state.value.collection?.id) {
|
||||
refreshCollection()
|
||||
} else if (position != null) {
|
||||
refreshItem(itemId, position, false)
|
||||
}
|
||||
}
|
||||
|
|
@ -404,11 +406,24 @@ class CollectionViewModel
|
|||
position: RowColumn?,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
if (position != null) {
|
||||
if (itemId == state.value.collection?.id) {
|
||||
refreshCollection()
|
||||
} else if (position != null) {
|
||||
refreshItem(itemId, position, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCollection() {
|
||||
viewModelScope.launchDefault {
|
||||
val collection =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId)
|
||||
.content
|
||||
.let { BaseItem(it, false) }
|
||||
_state.update { it.copy(collection = collection) }
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ fun DiscoverPersonPage(
|
|||
showJumpButtons = false,
|
||||
showLetterButtons = false,
|
||||
spacing = 16.dp,
|
||||
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
||||
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
|
|
|
|||
|
|
@ -31,33 +31,27 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -82,10 +76,8 @@ fun EpisodeDetails(
|
|||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val canDelete by viewModel.canDelete.collectAsState()
|
||||
|
||||
|
|
@ -96,22 +88,36 @@ fun EpisodeDetails(
|
|||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(itemId, watched)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(itemId, favorite)
|
||||
},
|
||||
onClickWatch = viewModel::setWatched,
|
||||
onClickFavorite = viewModel::setFavorite,
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
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) {
|
||||
is LoadingState.Error -> {
|
||||
|
|
@ -152,75 +158,17 @@ fun EpisodeDetails(
|
|||
ItemDetailsDialogInfo(ep)
|
||||
},
|
||||
moreOnClick = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = false,
|
||||
title = ep.name + " (${ep.data.productionYear ?: ""})",
|
||||
items =
|
||||
buildMoreDialogItems(
|
||||
context = context,
|
||||
item = ep,
|
||||
watched = ep.data.userData?.played ?: false,
|
||||
favorite = ep.data.userData?.isFavorite ?: false,
|
||||
seriesId = ep.data.seriesId,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
chooseVersionParams(
|
||||
context,
|
||||
ep.data.mediaSources!!,
|
||||
chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
) { idx ->
|
||||
val source = ep.data.mediaSources!![idx]
|
||||
viewModel.savePlayVersion(
|
||||
ep,
|
||||
source.id!!.toUUID(),
|
||||
)
|
||||
}
|
||||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
ep.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
ep,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
},
|
||||
onShowOverview = {
|
||||
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
|
||||
if (source != null) {
|
||||
overviewDialog = ItemDetailsDialogInfo(ep)
|
||||
}
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
viewModel.clearChosenStreams(chosenStreams)
|
||||
},
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = canDelete,
|
||||
),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
watchOnClick = {
|
||||
|
|
@ -230,7 +178,7 @@ fun EpisodeDetails(
|
|||
viewModel.setFavorite(ep.id, !ep.favorite)
|
||||
},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = { showDeleteDialog = ep },
|
||||
onConfirmDelete = { viewModel.deleteItem(ep) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -246,24 +194,12 @@ fun EpisodeDetails(
|
|||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
chooseVersion?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { chooseVersion = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
|
|
@ -283,16 +219,6 @@ fun EpisodeDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
|
|
@ -308,7 +234,7 @@ fun EpisodeDetailsContent(
|
|||
favoriteOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
canDelete: Boolean,
|
||||
deleteOnClick: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -346,6 +272,7 @@ fun EpisodeDetailsContent(
|
|||
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||
)
|
||||
ExpandablePlayButtons(
|
||||
title = ep.title ?: "",
|
||||
resumePosition = resumePosition,
|
||||
watched = dto.userData?.played ?: false,
|
||||
favorite = dto.userData?.isFavorite ?: false,
|
||||
|
|
@ -367,7 +294,7 @@ fun EpisodeDetailsContent(
|
|||
trailers = null,
|
||||
trailerOnClick = {},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = deleteOnClick,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
|||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.combinePair
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -38,9 +37,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -81,12 +77,8 @@ class EpisodeViewModel
|
|||
init {
|
||||
init()
|
||||
viewModelScope.launchDefault {
|
||||
item
|
||||
.asFlow()
|
||||
.filterNotNull()
|
||||
.combinePair(userPreferencesService.flow.map { it.appPreferences })
|
||||
.collectLatest { (item, preferences) ->
|
||||
canDelete.update { mediaManagementService.canDelete(item, preferences) }
|
||||
mediaManagementService.collectCanDelete(item.asFlow()) { canDelete ->
|
||||
this@EpisodeViewModel.canDelete.update { canDelete }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,25 +45,20 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
|||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
||||
import com.github.damontecres.wholphin.ui.components.PersonContextActions
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
|
|
@ -74,11 +69,9 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -101,11 +94,9 @@ fun MovieDetails(
|
|||
val state by viewModel.state.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
|
|
@ -114,22 +105,36 @@ fun MovieDetails(
|
|||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(itemId, watched)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(itemId, favorite)
|
||||
},
|
||||
onClickWatch = viewModel::setWatched,
|
||||
onClickFavorite = viewModel::setFavorite,
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
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) {
|
||||
is DataLoadingState.Error -> {
|
||||
|
|
@ -183,72 +188,17 @@ fun MovieDetails(
|
|||
ItemDetailsDialogInfo(movie)
|
||||
},
|
||||
moreOnClick = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = false,
|
||||
title = movie.name + " (${movie.data.productionYear ?: ""})",
|
||||
items =
|
||||
buildMoreDialogItems(
|
||||
context = context,
|
||||
item = movie,
|
||||
watched = movie.data.userData?.played ?: false,
|
||||
favorite = movie.data.userData?.isFavorite ?: false,
|
||||
seriesId = null,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = state.canDelete,
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
chooseVersionParams(
|
||||
context,
|
||||
movie.data.mediaSources!!,
|
||||
chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
) { idx ->
|
||||
val source = movie.data.mediaSources!![idx]
|
||||
viewModel.savePlayVersion(
|
||||
movie,
|
||||
source.id!!.toUUID(),
|
||||
)
|
||||
}
|
||||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
movie.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
movie,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
},
|
||||
onShowOverview = {
|
||||
overviewDialog = ItemDetailsDialogInfo(movie)
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
viewModel.clearChosenStreams(chosenStreams)
|
||||
},
|
||||
),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
watchOnClick = {
|
||||
|
|
@ -258,36 +208,29 @@ fun MovieDetails(
|
|||
viewModel.setFavorite(movie.id, !movie.favorite)
|
||||
},
|
||||
onLongClickPerson = { index, person ->
|
||||
val items =
|
||||
buildMoreDialogItemsForPerson(
|
||||
context = context,
|
||||
person = person,
|
||||
actions = moreActions,
|
||||
)
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForPerson(
|
||||
fromLongClick = true,
|
||||
title = person.name ?: "",
|
||||
items = items,
|
||||
person = person,
|
||||
actions =
|
||||
PersonContextActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickFavorite = viewModel::setFavorite,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClickSimilar = { index, similar ->
|
||||
val items =
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
item = similar,
|
||||
seriesId = null,
|
||||
playbackPosition = similar.playbackPosition,
|
||||
watched = similar.played,
|
||||
favorite = similar.favorite,
|
||||
canDelete = false,
|
||||
actions = moreActions,
|
||||
)
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
onLongClickSimilar = { _, similar ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
title = similar.title ?: "",
|
||||
items = items,
|
||||
item = similar,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = false,
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
trailerOnClick = {
|
||||
|
|
@ -300,11 +243,19 @@ fun MovieDetails(
|
|||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
canDelete = state.canDelete,
|
||||
deleteOnClick = { showDeleteDialog = state.movie },
|
||||
onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
|
|
@ -315,26 +266,6 @@ fun MovieDetails(
|
|||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
chooseVersion?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { chooseVersion = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
PlaylistDialog(
|
||||
title = stringResource(R.string.add_to_playlist),
|
||||
|
|
@ -352,16 +283,6 @@ fun MovieDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
|
|
@ -390,7 +311,7 @@ fun MovieDetailsContent(
|
|||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
canDelete: Boolean,
|
||||
deleteOnClick: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -430,6 +351,7 @@ fun MovieDetailsContent(
|
|||
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||
)
|
||||
ExpandablePlayButtons(
|
||||
title = movie.title ?: "",
|
||||
resumePosition = resumePosition,
|
||||
watched = dto.userData?.played ?: false,
|
||||
favorite = dto.userData?.isFavorite ?: false,
|
||||
|
|
@ -454,7 +376,7 @@ fun MovieDetailsContent(
|
|||
trailerOnClick.invoke(it)
|
||||
},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = deleteOnClick,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
|
|
@ -85,18 +85,9 @@ class MovieViewModel
|
|||
init {
|
||||
init()
|
||||
viewModelScope.launchDefault {
|
||||
userPreferencesService.flow.collectLatest { preferences ->
|
||||
mediaManagementService.collectCanDelete(state.map { it.movie }) { canDelete ->
|
||||
_state.update {
|
||||
val canDelete =
|
||||
it.movie?.let {
|
||||
mediaManagementService.canDelete(
|
||||
it,
|
||||
preferences.appPreferences,
|
||||
)
|
||||
}
|
||||
it.copy(
|
||||
canDelete = canDelete ?: false,
|
||||
)
|
||||
it.copy(canDelete = canDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,12 +61,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
|
|||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.MusicContextActions
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
|
|
@ -94,6 +94,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -136,6 +137,11 @@ class AlbumViewModel
|
|||
|
||||
init {
|
||||
init()
|
||||
viewModelScope.launchDefault {
|
||||
mediaManagementService.collectCanDelete(state.map { it.album }) { canDelete ->
|
||||
_state.update { it.copy(canDelete = canDelete) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
|
|
@ -275,6 +281,7 @@ data class AlbumState(
|
|||
val similar: List<BaseItem>,
|
||||
val loading: LoadingState,
|
||||
val musicVideos: List<BaseItem?> = emptyList(),
|
||||
val canDelete: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending)
|
||||
|
|
@ -308,22 +315,21 @@ fun AlbumDetailsPage(
|
|||
val focusManager = LocalFocusManager.current
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
val moreDialogActions =
|
||||
remember {
|
||||
MusicMoreDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
MusicContextActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, _ -> viewModel.play(false, index) },
|
||||
onClickPlayNext = { _, song -> viewModel.playNext(song) },
|
||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
||||
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
|
||||
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onClickRemoveFromQueue = {},
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
onClickRemoveFromQueue = { _, _ -> },
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
)
|
||||
}
|
||||
when (val loading = state.loading) {
|
||||
|
|
@ -390,6 +396,7 @@ fun AlbumDetailsPage(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MusicExpandableButtons(
|
||||
title = album.title ?: "",
|
||||
actions =
|
||||
remember {
|
||||
MusicButtonActions(
|
||||
|
|
@ -402,28 +409,27 @@ fun AlbumDetailsPage(
|
|||
)
|
||||
},
|
||||
onClickMore = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = false,
|
||||
title = album.name + " (${album.data.productionYear ?: ""})",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = album,
|
||||
index = 0,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
album,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
},
|
||||
onConfirmDelete = {
|
||||
viewModel.deleteItem(album)
|
||||
},
|
||||
)
|
||||
},
|
||||
favorite = album.favorite,
|
||||
canDelete = state.canDelete,
|
||||
buttonOnFocusChanged = {
|
||||
if (it.isFocused) {
|
||||
position = RowColumn(HEADER_ROW, 0)
|
||||
|
|
@ -459,45 +465,35 @@ fun AlbumDetailsPage(
|
|||
},
|
||||
onLongClick = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = true,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClickMore = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = false,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -559,23 +555,18 @@ fun AlbumDetailsPage(
|
|||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = item,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
item,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
},
|
||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
|
|
@ -597,14 +588,12 @@ fun AlbumDetailsPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
|
|
@ -624,17 +613,6 @@ fun AlbumDetailsPage(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -59,12 +59,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
|
|||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.MusicContextActions
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
|
|
@ -92,6 +92,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -135,6 +136,11 @@ class ArtistViewModel
|
|||
|
||||
init {
|
||||
init()
|
||||
viewModelScope.launchDefault {
|
||||
mediaManagementService.collectCanDelete(state.map { it.artist }) { canDelete ->
|
||||
_state.update { it.copy(canDelete = canDelete) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
|
|
@ -269,6 +275,7 @@ data class ArtistState(
|
|||
val similar: List<BaseItem>,
|
||||
val loading: LoadingState,
|
||||
val musicVideos: List<BaseItem?>,
|
||||
val canDelete: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY =
|
||||
|
|
@ -280,6 +287,7 @@ data class ArtistState(
|
|||
emptyList(),
|
||||
LoadingState.Pending,
|
||||
emptyList(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -312,22 +320,21 @@ fun ArtistDetailsPage(
|
|||
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
val moreDialogActions =
|
||||
remember {
|
||||
MusicMoreDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
MusicContextActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, item -> viewModel.play(item) },
|
||||
onClickPlayNext = { _, item -> viewModel.playNext(item) },
|
||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
||||
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
|
||||
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onClickRemoveFromQueue = {},
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
onClickRemoveFromQueue = { _, _ -> },
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -379,6 +386,7 @@ fun ArtistDetailsPage(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MusicExpandableButtons(
|
||||
title = artist.title ?: "",
|
||||
actions =
|
||||
remember {
|
||||
MusicButtonActions(
|
||||
|
|
@ -393,28 +401,25 @@ fun ArtistDetailsPage(
|
|||
)
|
||||
},
|
||||
onClickMore = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = false,
|
||||
title = artist.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = artist,
|
||||
index = 0,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
artist,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
},
|
||||
onConfirmDelete = { viewModel.deleteItem(artist) },
|
||||
)
|
||||
},
|
||||
favorite = artist.favorite,
|
||||
canDelete = state.canDelete,
|
||||
buttonOnFocusChanged = {
|
||||
if (it.isFocused) {
|
||||
position = RowColumn(HEADER_ROW, 0)
|
||||
|
|
@ -451,45 +456,35 @@ fun ArtistDetailsPage(
|
|||
},
|
||||
onLongClick = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = true,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClickMore = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = false,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -516,23 +511,18 @@ fun ArtistDetailsPage(
|
|||
viewModel.navigationManager.navigateTo(album.destination())
|
||||
},
|
||||
onLongClickItem = { index, album ->
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = true,
|
||||
title = album.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = album,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
album,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
},
|
||||
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
|
|
@ -588,23 +578,18 @@ fun ArtistDetailsPage(
|
|||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
showContextMenu =
|
||||
ContextMenu.ForMusic(
|
||||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = item,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
item,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
canRemoveFromQueue = false,
|
||||
actions = moreDialogActions,
|
||||
)
|
||||
},
|
||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
|
|
@ -626,14 +611,12 @@ fun ArtistDetailsPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
|
|
@ -653,17 +636,6 @@ fun ArtistDetailsPage(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -18,14 +18,17 @@ import androidx.compose.ui.focus.onFocusChanged
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.components.DeleteButton
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Composable
|
||||
fun MusicExpandableButtons(
|
||||
title: String,
|
||||
actions: MusicButtonActions,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -75,6 +78,15 @@ fun MusicExpandableButtons(
|
|||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
if (canDelete) {
|
||||
item("delete") {
|
||||
DeleteButton(
|
||||
title = title,
|
||||
onConfirmDelete = actions.onConfirmDelete,
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
item("more") {
|
||||
ExpandablePlayButton(
|
||||
title = R.string.more,
|
||||
|
|
@ -92,4 +104,5 @@ data class MusicButtonActions(
|
|||
val onClickInstantMix: () -> Unit,
|
||||
val onClickFavorite: () -> Unit,
|
||||
val onClickMore: () -> Unit,
|
||||
val onConfirmDelete: () -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
|
@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -22,7 +20,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -35,7 +32,6 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
|||
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
||||
import androidx.media3.ui.compose.state.rememberRepeatButtonState
|
||||
import androidx.media3.ui.compose.state.rememberShuffleButtonState
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -45,11 +41,10 @@ import com.github.damontecres.wholphin.ui.FontAwesome
|
|||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
||||
import com.github.damontecres.wholphin.ui.playback.buttonSpacing
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing
|
||||
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import com.github.damontecres.wholphin.ui.components.Button
|
|||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.playback.SeekBar
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.SeekBar
|
||||
import com.github.damontecres.wholphin.ui.preferences.MoveButton
|
||||
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
|
|||
|
|
@ -60,15 +60,16 @@ import com.github.damontecres.wholphin.preferences.BackdropStyle
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.rememberQueue
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.QueueContextActions
|
||||
import com.github.damontecres.wholphin.ui.findActivity
|
||||
import com.github.damontecres.wholphin.ui.nav.Backdrop
|
||||
import com.github.damontecres.wholphin.ui.playback.BottomDialog
|
||||
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
|
||||
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
|
@ -129,18 +130,9 @@ fun NowPlayingPage(
|
|||
)
|
||||
}
|
||||
|
||||
val actions =
|
||||
remember {
|
||||
MusicQueueDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, _ -> viewModel.play(index) },
|
||||
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
|
||||
onClickRemoveFromQueue = { index, _ -> viewModel.removeFromQueue(index) },
|
||||
)
|
||||
}
|
||||
|
||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) }
|
||||
|
||||
var lyricsHaveFocus by remember { mutableStateOf(false) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
|
@ -209,6 +201,7 @@ fun NowPlayingPage(
|
|||
remember {
|
||||
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
|
||||
}
|
||||
//noinspection UnnecessaryFullyQualifiedName
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = musicPrefs.showAlbumArt,
|
||||
enter = enter,
|
||||
|
|
@ -250,6 +243,7 @@ fun NowPlayingPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
//noinspection UnnecessaryFullyQualifiedName
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = musicPrefs.showVisualizer,
|
||||
enter = enter,
|
||||
|
|
@ -308,17 +302,21 @@ fun NowPlayingPage(
|
|||
val showContextForItem =
|
||||
remember {
|
||||
{ fromLongClick: Boolean, index: Int, song: AudioItem ->
|
||||
itemMoreDialog =
|
||||
DialogParams(
|
||||
title = song.title ?: "",
|
||||
showContextMenu =
|
||||
ContextMenu.ForQueue(
|
||||
fromLongClick = fromLongClick,
|
||||
items =
|
||||
buildMoreDialogForMusicQueue(
|
||||
context = context,
|
||||
actions = actions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = true,
|
||||
actions =
|
||||
QueueContextActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, _ -> viewModel.play(index) },
|
||||
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
|
||||
onClickRemoveFromQueue = { index, _ ->
|
||||
viewModel.removeFromQueue(
|
||||
index,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -368,14 +366,12 @@ fun NowPlayingPage(
|
|||
LoadingPage(focusEnabled = false)
|
||||
}
|
||||
}
|
||||
itemMoreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { itemMoreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
if (showViewOptionsDialog) {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -23,193 +11,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
|
|||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import java.util.UUID
|
||||
|
||||
data class MusicMoreDialogActions(
|
||||
val onNavigate: (Destination) -> Unit,
|
||||
val onClickPlay: (Int, BaseItem) -> Unit,
|
||||
val onClickPlayNext: (Int, BaseItem) -> Unit,
|
||||
val onClickAddToQueue: (Int, BaseItem) -> Unit,
|
||||
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||
val onClickAddPlaylist: (UUID) -> Unit,
|
||||
val onClickGoToAlbum: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
||||
},
|
||||
val onClickGoToArtist: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
||||
},
|
||||
val onClickRemoveFromQueue: (Int) -> Unit,
|
||||
val onClickDelete: (BaseItem) -> Unit,
|
||||
)
|
||||
|
||||
fun buildMoreDialogForMusic(
|
||||
context: Context,
|
||||
actions: MusicMoreDialogActions,
|
||||
item: BaseItem,
|
||||
index: Int,
|
||||
canRemove: Boolean,
|
||||
canDelete: Boolean,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlay(index, item)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play_next),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlayNext(index, item)
|
||||
},
|
||||
)
|
||||
if (canRemove) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.remove_from_queue),
|
||||
Icons.Default.Delete,
|
||||
) {
|
||||
actions.onClickRemoveFromQueue(index)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.add_to_queue),
|
||||
Icons.Default.Add,
|
||||
) {
|
||||
actions.onClickAddToQueue(index, item)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.add_to_playlist,
|
||||
iconStringRes = R.string.fa_list_ul,
|
||||
) {
|
||||
actions.onClickAddPlaylist.invoke(item.id)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||
iconStringRes = R.string.fa_heart,
|
||||
iconColor = if (item.favorite) Color.Red else Color.Unspecified,
|
||||
) {
|
||||
actions.onClickFavorite.invoke(item.id, !item.favorite)
|
||||
},
|
||||
)
|
||||
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_album),
|
||||
R.string.fa_compact_disc,
|
||||
) {
|
||||
actions.onClickGoToAlbum.invoke(item.data.albumId!!)
|
||||
},
|
||||
)
|
||||
}
|
||||
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_artist),
|
||||
R.string.fa_user,
|
||||
) {
|
||||
actions.onClickGoToArtist.invoke(
|
||||
item.data.artistItems!!
|
||||
.first()
|
||||
.id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class MusicQueueDialogActions(
|
||||
val onNavigate: (Destination) -> Unit,
|
||||
val onClickPlay: (Int, AudioItem) -> Unit,
|
||||
val onClickPlayNext: (Int, AudioItem) -> Unit,
|
||||
val onClickGoToAlbum: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
||||
},
|
||||
val onClickGoToArtist: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
||||
},
|
||||
val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
|
||||
)
|
||||
|
||||
fun buildMoreDialogForMusicQueue(
|
||||
context: Context,
|
||||
actions: MusicQueueDialogActions,
|
||||
item: AudioItem,
|
||||
index: Int,
|
||||
canRemove: Boolean,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlay(index, item)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play_next),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlayNext(index, item)
|
||||
},
|
||||
)
|
||||
if (canRemove) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.remove_from_queue),
|
||||
Icons.Default.Delete,
|
||||
) {
|
||||
actions.onClickRemoveFromQueue(index, item)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (item.albumId != null) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_album),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.onClickGoToAlbum.invoke(item.albumId)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (item.artistId != null) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_artist),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.onClickGoToArtist.invoke(item.artistId)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ViewModel.getPagerForAlbum(
|
||||
api: ApiClient,
|
||||
albumId: UUID,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ fun FocusedEpisodeFooter(
|
|||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
canDelete: Boolean,
|
||||
deleteOnClick: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit = {},
|
||||
) {
|
||||
|
|
@ -39,6 +39,7 @@ fun FocusedEpisodeFooter(
|
|||
modifier = modifier,
|
||||
) {
|
||||
ExpandablePlayButtons(
|
||||
title = ep.title ?: "",
|
||||
resumePosition = resumePosition,
|
||||
watched = dto.userData?.played ?: false,
|
||||
favorite = dto.userData?.isFavorite ?: false,
|
||||
|
|
@ -50,7 +51,7 @@ fun FocusedEpisodeFooter(
|
|||
trailers = null,
|
||||
trailerOnClick = {},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = deleteOnClick,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
|
|
@ -57,12 +56,13 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
|||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DeleteButton
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||
|
|
@ -71,20 +71,17 @@ import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
|||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.PersonContextActions
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||
import com.github.damontecres.wholphin.ui.components.TrailerButton
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
|
|
@ -95,6 +92,7 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -128,10 +126,50 @@ fun SeriesDetails(
|
|||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
|
||||
var showWatchConfirmation by remember { mutableStateOf(false) }
|
||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
if (itemId == destination.itemId) {
|
||||
// Confirm if marking whole series
|
||||
showWatchConfirmation = true
|
||||
} else {
|
||||
viewModel.setWatched(itemId, watched, null)
|
||||
}
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(itemId, favorite, null)
|
||||
},
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog = Optional.present(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
onChooseVersion = { item, source ->
|
||||
viewModel.savePlayVersion(
|
||||
item,
|
||||
source.id!!.toUUID(),
|
||||
)
|
||||
},
|
||||
onChooseTracks = { result ->
|
||||
viewModel.saveTrackSelection(
|
||||
result.item,
|
||||
result.itemPlayback,
|
||||
result.trackIndex,
|
||||
result.streamType,
|
||||
)
|
||||
},
|
||||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||
onClearChosenStreams = {},
|
||||
)
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.refresh()
|
||||
|
|
@ -185,30 +223,19 @@ fun SeriesDetails(
|
|||
)
|
||||
},
|
||||
onLongClickItem = { index, season ->
|
||||
scope.launchDefault {
|
||||
seasonDialog =
|
||||
buildDialogForSeason(
|
||||
context = context,
|
||||
s = season,
|
||||
canDelete = viewModel.canDelete(season),
|
||||
onClickItem = { viewModel.navigateTo(it.destination()) },
|
||||
markPlayed = { played ->
|
||||
viewModel.setSeasonWatched(season.id, played)
|
||||
},
|
||||
onClickPlay = { shuffle ->
|
||||
viewModel.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = season.id,
|
||||
shuffle = shuffle,
|
||||
),
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = season,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(season, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
onClickDelete = {
|
||||
showDeleteDialog = it
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog = ItemDetailsDialogInfo(item)
|
||||
},
|
||||
|
|
@ -245,24 +272,10 @@ fun SeriesDetails(
|
|||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
moreActions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigateTo(it) },
|
||||
onClickWatch = { itemId, played ->
|
||||
viewModel.setWatched(itemId, played, null)
|
||||
onShowContextMenu = {
|
||||
showContextMenu = it
|
||||
},
|
||||
onClickFavorite = { itemId, played ->
|
||||
viewModel.setFavorite(itemId, played, null)
|
||||
},
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = it
|
||||
},
|
||||
),
|
||||
actions = contextActions,
|
||||
)
|
||||
if (showWatchConfirmation) {
|
||||
ConfirmDialog(
|
||||
|
|
@ -281,6 +294,14 @@ fun SeriesDetails(
|
|||
}
|
||||
}
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
|
|
@ -288,15 +309,6 @@ fun SeriesDetails(
|
|||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
seasonDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
waitToLoad = params.fromLongClick,
|
||||
onDismissRequest = { seasonDialog = null },
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
PlaylistDialog(
|
||||
title = stringResource(R.string.add_to_playlist),
|
||||
|
|
@ -314,19 +326,6 @@ fun SeriesDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
if (seasons?.lastOrNull()?.id == item.id) {
|
||||
focusManager.moveFocus(FocusDirection.Previous)
|
||||
}
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
|
|
@ -359,7 +358,8 @@ fun SeriesDetailsContent(
|
|||
favoriteOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
moreActions: MoreDialogActions,
|
||||
onShowContextMenu: (ContextMenu) -> Unit,
|
||||
actions: ContextMenuActions,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
discoverSeries: DiscoverItem?,
|
||||
onClickDiscoverSeries: () -> Unit,
|
||||
|
|
@ -373,7 +373,6 @@ fun SeriesDetailsContent(
|
|||
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
|
|
@ -486,9 +485,10 @@ fun SeriesDetailsContent(
|
|||
)
|
||||
if (canDelete) {
|
||||
DeleteButton(
|
||||
onClick = {
|
||||
title = series.title ?: "",
|
||||
onConfirmDelete = {
|
||||
position = HEADER_ROW
|
||||
moreActions.onClickDelete.invoke(series)
|
||||
actions.onDeleteItem.invoke(series)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -561,17 +561,16 @@ fun SeriesDetailsContent(
|
|||
},
|
||||
onLongClick = { index, person ->
|
||||
position = PEOPLE_ROW
|
||||
val items =
|
||||
buildMoreDialogItemsForPerson(
|
||||
context = context,
|
||||
person = person,
|
||||
actions = moreActions,
|
||||
)
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
onShowContextMenu.invoke(
|
||||
ContextMenu.ForPerson(
|
||||
fromLongClick = true,
|
||||
title = person.name ?: "",
|
||||
items = items,
|
||||
person = person,
|
||||
actions =
|
||||
PersonContextActions(
|
||||
navigateTo = actions.navigateTo,
|
||||
onClickFavorite = actions.onClickFavorite,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
|
@ -608,22 +607,18 @@ fun SeriesDetailsContent(
|
|||
},
|
||||
onLongClickItem = { index, item ->
|
||||
position = SIMILAR_ROW
|
||||
val items =
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
item = item,
|
||||
seriesId = null,
|
||||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
actions = moreActions,
|
||||
canDelete = false,
|
||||
)
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
onShowContextMenu.invoke(
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items = items,
|
||||
item = item,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = false,
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = actions,
|
||||
),
|
||||
)
|
||||
},
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.runtime.livedata.observeAsState
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -20,41 +21,30 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.map
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -98,16 +88,19 @@ fun SeriesOverview(
|
|||
val episodeRowFocusRequester = remember { FocusRequester() }
|
||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||
val guestStarRowFocusRequester = remember { FocusRequester() }
|
||||
val extrasRowFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
|
||||
val series by viewModel.item.observeAsState(null)
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val seasons by viewModel.seasons.observeAsState(emptyList())
|
||||
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
|
||||
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
|
||||
val seasonExtras by viewModel.extras.observeAsState(emptyList())
|
||||
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList())
|
||||
val episodeList = (episodes as? EpisodeList.Success)?.episodes
|
||||
|
||||
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
||||
val currentPosition by rememberUpdatedState(position)
|
||||
LaunchedEffect(Unit) {
|
||||
if (seasons.isNotEmpty()) {
|
||||
seasons.getOrNull(position.seasonTabIndex)?.let {
|
||||
|
|
@ -117,13 +110,54 @@ fun SeriesOverview(
|
|||
}
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
var rowFocused by rememberInt()
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(itemId, watched, currentPosition.episodeRowIndex)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex)
|
||||
},
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog = itemId
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onDeleteItem = viewModel::deleteItem,
|
||||
onChooseVersion = { item, source ->
|
||||
viewModel.savePlayVersion(
|
||||
item,
|
||||
source.id!!.toUUID(),
|
||||
)
|
||||
},
|
||||
onChooseTracks = { result ->
|
||||
viewModel.saveTrackSelection(
|
||||
result.item,
|
||||
result.itemPlayback,
|
||||
result.trackIndex,
|
||||
result.streamType,
|
||||
)
|
||||
},
|
||||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||
onClearChosenStreams = {
|
||||
val focusedEpisode =
|
||||
(episodes as? EpisodeList.Success)
|
||||
?.episodes
|
||||
?.getOrNull(currentPosition.episodeRowIndex)
|
||||
if (focusedEpisode != null) {
|
||||
viewModel.clearChosenStreams(focusedEpisode, it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(position, episodes) {
|
||||
val focusedEpisode =
|
||||
|
|
@ -164,6 +198,7 @@ fun SeriesOverview(
|
|||
EPISODE_ROW -> episodeRowFocusRequester
|
||||
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
|
||||
GUEST_STAR_ROW -> guestStarRowFocusRequester
|
||||
EXTRAS_ROW -> extrasRowFocusRequester
|
||||
else -> episodeRowFocusRequester
|
||||
},
|
||||
"series_overview",
|
||||
|
|
@ -176,103 +211,6 @@ fun SeriesOverview(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun buildMoreForEpisode(
|
||||
ep: BaseItem,
|
||||
chosenStreams: ChosenStreams?,
|
||||
fromLongClick: Boolean,
|
||||
): DialogParams =
|
||||
DialogParams(
|
||||
fromLongClick = fromLongClick,
|
||||
title = series.name + " - " + ep.data.seasonEpisode,
|
||||
items =
|
||||
buildMoreDialogItems(
|
||||
context = context,
|
||||
item = ep,
|
||||
watched = ep.data.userData?.played ?: false,
|
||||
favorite = ep.data.userData?.isFavorite ?: false,
|
||||
seriesId = series.id,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
canDelete = viewModel.canDelete(ep),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(
|
||||
itemId,
|
||||
watched,
|
||||
position.episodeRowIndex,
|
||||
)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(
|
||||
itemId,
|
||||
favorite,
|
||||
position.episodeRowIndex,
|
||||
)
|
||||
},
|
||||
onClickAddPlaylist = {
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog = it
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = it
|
||||
},
|
||||
),
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
chooseVersionParams(
|
||||
context,
|
||||
ep.data.mediaSources!!,
|
||||
chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
) { idx ->
|
||||
val source = ep.data.mediaSources!![idx]
|
||||
viewModel.savePlayVersion(
|
||||
ep,
|
||||
source.id!!.toUUID(),
|
||||
)
|
||||
}
|
||||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
ep.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
ep,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
},
|
||||
onShowOverview = {
|
||||
overviewDialog = ItemDetailsDialogInfo(ep)
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
viewModel.clearChosenStreams(ep, chosenStreams)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
SeriesOverviewContent(
|
||||
preferences = preferences,
|
||||
series = series,
|
||||
|
|
@ -280,11 +218,13 @@ fun SeriesOverview(
|
|||
episodes = episodes,
|
||||
chosenStreams = chosenStreams,
|
||||
peopleInEpisode = peopleInEpisode,
|
||||
seasonExtras = seasonExtras,
|
||||
position = position,
|
||||
firstItemFocusRequester = firstItemFocusRequester,
|
||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
||||
extrasRowFocusRequester = extrasRowFocusRequester,
|
||||
onChangeSeason = { index ->
|
||||
if (index != position.seasonTabIndex) {
|
||||
seasons.getOrNull(index)?.let { season ->
|
||||
|
|
@ -314,9 +254,18 @@ fun SeriesOverview(
|
|||
)
|
||||
},
|
||||
onLongClick = { ep ->
|
||||
scope.launchDefault {
|
||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
|
||||
}
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = ep,
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
playOnClick = { resume ->
|
||||
rowFocused = EPISODE_ROW
|
||||
|
|
@ -344,9 +293,18 @@ fun SeriesOverview(
|
|||
},
|
||||
moreOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||
scope.launchDefault {
|
||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
|
||||
}
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = false,
|
||||
item = ep,
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
|
|
@ -364,14 +322,25 @@ fun SeriesOverview(
|
|||
),
|
||||
)
|
||||
},
|
||||
onClickExtra = { _, extra ->
|
||||
rowFocused = EXTRAS_ROW
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
|
||||
deleteOnClick = { showDeleteDialog = it },
|
||||
onConfirmDelete = viewModel::deleteItem,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
|
|
@ -382,26 +351,6 @@ fun SeriesOverview(
|
|||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
chooseVersion?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { chooseVersion = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog?.let { itemId ->
|
||||
PlaylistDialog(
|
||||
title = stringResource(R.string.add_to_playlist),
|
||||
|
|
@ -419,19 +368,9 @@ fun SeriesOverview(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.subtitle ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
episodeRowFocusRequester.tryRequestFocus()
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val EPISODE_ROW = 0
|
||||
private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1
|
||||
private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1
|
||||
private const val EXTRAS_ROW = GUEST_STAR_ROW + 1
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
|
|
@ -47,11 +46,13 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||
|
|
@ -74,6 +75,7 @@ fun SeriesOverviewContent(
|
|||
series: BaseItem,
|
||||
seasons: List<BaseItem?>,
|
||||
episodes: EpisodeList,
|
||||
seasonExtras: List<ExtrasItem>,
|
||||
chosenStreams: ChosenStreams?,
|
||||
peopleInEpisode: List<Person>,
|
||||
position: SeriesOverviewPosition,
|
||||
|
|
@ -81,6 +83,7 @@ fun SeriesOverviewContent(
|
|||
episodeRowFocusRequester: FocusRequester,
|
||||
castCrewRowFocusRequester: FocusRequester,
|
||||
guestStarRowFocusRequester: FocusRequester,
|
||||
extrasRowFocusRequester: FocusRequester,
|
||||
onChangeSeason: (Int) -> Unit,
|
||||
onFocusEpisode: (Int) -> Unit,
|
||||
onClick: (BaseItem) -> Unit,
|
||||
|
|
@ -92,7 +95,8 @@ fun SeriesOverviewContent(
|
|||
overviewOnClick: () -> Unit,
|
||||
personOnClick: (Person) -> Unit,
|
||||
canDelete: (BaseItem) -> Boolean,
|
||||
deleteOnClick: (BaseItem) -> Unit,
|
||||
onConfirmDelete: (BaseItem) -> Unit,
|
||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -318,7 +322,7 @@ fun SeriesOverviewContent(
|
|||
}
|
||||
},
|
||||
canDelete = canDelete.invoke(ep),
|
||||
deleteOnClick = { deleteOnClick.invoke(ep) },
|
||||
onConfirmDelete = { onConfirmDelete.invoke(ep) },
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
|
|
@ -365,6 +369,21 @@ fun SeriesOverviewContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = seasonExtras.isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
ExtrasRow(
|
||||
extras = seasonExtras,
|
||||
onClickItem = onClickExtra,
|
||||
onLongClickItem = { _, _ -> },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(extrasRowFocusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
|
@ -138,7 +139,11 @@ class SeriesViewModel
|
|||
Timber.v("Start")
|
||||
addCloseable { themeSongPlayer.stop() }
|
||||
val item = fetchItem(seriesId)
|
||||
canDeleteSeries.update { mediaManagementService.canDelete(item) }
|
||||
viewModelScope.launchDefault {
|
||||
mediaManagementService.collectCanDelete(flowOf(item)) { canDelete ->
|
||||
canDeleteSeries.update { canDelete }
|
||||
}
|
||||
}
|
||||
backdropService.submit(item)
|
||||
|
||||
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
||||
|
|
@ -184,6 +189,10 @@ class SeriesViewModel
|
|||
it.copy(seasonTabIndex = index.coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
|
||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
||||
}
|
||||
}
|
||||
val remoteTrailers = trailerService.getRemoteTrailers(item)
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -388,6 +397,7 @@ class SeriesViewModel
|
|||
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
||||
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
|
||||
this@SeriesViewModel.episodes.value = EpisodeList.Loading
|
||||
this@SeriesViewModel.extras.value = emptyList()
|
||||
}
|
||||
viewModelScope.launchIO(ExceptionHandler(true)) {
|
||||
val episodes =
|
||||
|
|
@ -401,6 +411,10 @@ class SeriesViewModel
|
|||
this@SeriesViewModel.episodes.value = episodes
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val extras = extrasService.getExtras(seasonId)
|
||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
||||
}
|
||||
}
|
||||
|
||||
fun setWatched(
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ fun DiscoverRequestGrid(
|
|||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false,
|
||||
showLetterButtons = false,
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ fun SeerrRequestsPage(
|
|||
showJumpButtons = false,
|
||||
showLetterButtons = false,
|
||||
spacing = 16.dp,
|
||||
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
||||
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
DiscoverItemCard(
|
||||
item = item?.item,
|
||||
onClick = onClick,
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -56,24 +58,23 @@ import com.github.damontecres.wholphin.ui.cards.GenreCard
|
|||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.RowColumnItem
|
||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -120,9 +121,10 @@ fun HomePage(
|
|||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var position by rememberPosition()
|
||||
|
||||
|
|
@ -144,43 +146,46 @@ fun HomePage(
|
|||
row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
||||
val canRemoveNextUp =
|
||||
row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
||||
val dialogItems =
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = item,
|
||||
seriesId = item.data.seriesId,
|
||||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
canRemoveNextUp = canRemoveNextUp,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
item,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
canRemoveContinueWatching = canRemoveContinueWatching,
|
||||
canRemoveNextUp = canRemoveNextUp,
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel.navigationManager::navigateTo,
|
||||
onClickWatch = { itemId, played ->
|
||||
viewModel.setWatched(itemId, played)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(itemId, favorite)
|
||||
},
|
||||
onClickWatch = viewModel::setWatched,
|
||||
onClickFavorite = viewModel::setFavorite,
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog = itemId
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = RowColumnItem(position, item)
|
||||
onDeleteItem = {
|
||||
viewModel.deleteItem(position, it)
|
||||
},
|
||||
onChooseVersion = { _, _ ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onChooseTracks = {
|
||||
// Not supported on this page
|
||||
},
|
||||
onShowOverview = {
|
||||
overviewDialog = ItemDetailsDialogInfo(it)
|
||||
},
|
||||
onClearChosenStreams = {},
|
||||
onClickRemoveFromNextUp = viewModel::removeFromNextUp,
|
||||
),
|
||||
)
|
||||
dialog =
|
||||
DialogParams(
|
||||
title = item.title ?: "",
|
||||
fromLongClick = true,
|
||||
items = dialogItems,
|
||||
)
|
||||
}
|
||||
}
|
||||
val onClickPlay =
|
||||
|
|
@ -203,10 +208,19 @@ fun HomePage(
|
|||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||
modifier = modifier,
|
||||
)
|
||||
dialog?.let { params ->
|
||||
DialogPopup(
|
||||
params = params,
|
||||
onDismissRequest = { dialog = null },
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath = false,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog?.let { itemId ->
|
||||
|
|
@ -226,20 +240,8 @@ fun HomePage(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
|
@ -306,7 +308,15 @@ fun HomePageContent(
|
|||
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.focusProperties {
|
||||
onEnter = {
|
||||
rowFocusRequesters.getOrNull(currentPosition.row)?.tryRequestFocus()
|
||||
}
|
||||
}.fillMaxSize(),
|
||||
) {
|
||||
headerComposable.invoke(focusedItem)
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -406,7 +416,7 @@ fun HomePageContent(
|
|||
}
|
||||
val onKey =
|
||||
remember(item) {
|
||||
{ event: androidx.compose.ui.input.key.KeyEvent ->
|
||||
{ event: KeyEvent ->
|
||||
if (isPlayKeyUp(event) && item?.type?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.id}")
|
||||
currentOnClickPlay(
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -87,26 +88,30 @@ class HomeViewModel
|
|||
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
||||
val refresh =
|
||||
state.loadingState == LoadingState.Success && state.settings == settings
|
||||
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
|
||||
Timber.v(
|
||||
"refresh=%s, state.loadingState=%s, %s rows",
|
||||
refresh,
|
||||
state.loadingState,
|
||||
settings.rows.size,
|
||||
)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingState = if (refresh) LoadingState.Success else LoadingState.Loading,
|
||||
refreshState = LoadingState.Loading,
|
||||
settings = settings,
|
||||
homeRows =
|
||||
if (refresh) {
|
||||
it.homeRows
|
||||
} else {
|
||||
List(settings.rows.size) { HomeRowLoadingState.Pending("") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val semaphore = Semaphore(4)
|
||||
|
||||
val watchingRowIndexes =
|
||||
settings.rows
|
||||
.mapIndexedNotNull { index, row ->
|
||||
if (isWatchingRow(row.config)) index else null
|
||||
}
|
||||
val deferred =
|
||||
settings.rows
|
||||
// Load the watching rows first
|
||||
.sortedByDescending { isWatchingRow(it.config) }
|
||||
.map { row ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
|
|
@ -123,40 +128,47 @@ class HomeViewModel
|
|||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error on row %s", row)
|
||||
HomeRowLoadingState.Error(row.title, exception = ex)
|
||||
HomeRowLoadingState.Error(
|
||||
row.title,
|
||||
exception = ex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
|
||||
// Replace watching rows first
|
||||
Timber.v("Refreshing rows: %s", watchingRowIndexes)
|
||||
val rows =
|
||||
deferred
|
||||
.filterIndexed { index, _ -> index in watchingRowIndexes }
|
||||
.awaitAll()
|
||||
_state.update {
|
||||
if (refresh) {
|
||||
// Replace rows as they complete
|
||||
val remaining = deferred.withIndex().toMutableList()
|
||||
while (remaining.isNotEmpty()) {
|
||||
val (rowIndex, rowData) =
|
||||
select {
|
||||
// "Return" the first remaining that is completed
|
||||
remaining
|
||||
.forEach { (rowIndex, deferred) ->
|
||||
deferred.onAwait { rowIndex to it }
|
||||
}
|
||||
}
|
||||
Timber.v("Got row data index=%s", rowIndex)
|
||||
remaining.removeIf { it.index == rowIndex }
|
||||
_state.update { state ->
|
||||
val newRows =
|
||||
it.homeRows.toMutableList().apply {
|
||||
rows.forEachIndexed { index, row ->
|
||||
set(watchingRowIndexes[index], row)
|
||||
state.homeRows.toMutableList().apply {
|
||||
set(rowIndex, rowData)
|
||||
}
|
||||
}
|
||||
it.copy(
|
||||
loadingState = LoadingState.Success,
|
||||
state.copy(
|
||||
homeRows = newRows,
|
||||
)
|
||||
}
|
||||
}
|
||||
val rows =
|
||||
deferred
|
||||
.awaitAll()
|
||||
.filter {
|
||||
// Include only errors & non-empty successes
|
||||
it is HomeRowLoadingState.Error ||
|
||||
(it is HomeRowLoadingState.Success && it.items.isNotEmpty())
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingState = LoadingState.Success,
|
||||
refreshState = LoadingState.Success,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val rows = deferred.awaitAll()
|
||||
Timber.v("Got all rows")
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
|
@ -166,10 +178,13 @@ class HomeViewModel
|
|||
)
|
||||
}
|
||||
}
|
||||
Timber.d("Home page load complete")
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception during home page loading")
|
||||
if (state.value.loadingState == LoadingState.Success) {
|
||||
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
||||
_state.update { it.copy(refreshState = LoadingState.Error(ex)) }
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(loadingState = LoadingState.Error(ex))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
|
|
@ -78,8 +78,8 @@ sealed class Destination(
|
|||
data class Playback(
|
||||
val itemId: UUID,
|
||||
val positionMs: Long,
|
||||
val itemPlayback: ItemPlayback? = null,
|
||||
val forceTranscoding: Boolean = false,
|
||||
val backend: PlayerBackend? = null,
|
||||
) : Destination(true) {
|
||||
constructor(item: BaseItem) : this(item.id, item.resumeMs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,9 +76,25 @@ fun DestinationContent(
|
|||
HomeSettingsPage(preferences, modifier)
|
||||
}
|
||||
|
||||
is Destination.PlaybackList,
|
||||
is Destination.Playback,
|
||||
-> {
|
||||
is Destination.Playback -> {
|
||||
val backend =
|
||||
destination.backend ?: preferences.appPreferences.playbackPreferences.playerBackend
|
||||
if (backend == PlayerBackend.EXTERNAL_PLAYER) {
|
||||
PlayExternalPage(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
PlaybackPage(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Destination.PlaybackList -> {
|
||||
if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
|
||||
PlayExternalPage(
|
||||
preferences = preferences,
|
||||
|
|
|
|||
|
|
@ -8,14 +8,13 @@ import android.widget.Toast
|
|||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
|
|
@ -25,6 +24,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
|||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.findActivity
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -40,9 +41,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
|
@ -54,17 +52,22 @@ import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
|||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class)
|
||||
@HiltViewModel
|
||||
class PlayExternalViewModel
|
||||
@AssistedInject
|
||||
@Inject
|
||||
constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
|
|
@ -75,16 +78,13 @@ class PlayExternalViewModel
|
|||
private val streamChoiceService: StreamChoiceService,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
@Assisted val destination: Destination,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(destination: Destination): PlayExternalViewModel
|
||||
}
|
||||
|
||||
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||
val state = MutableStateFlow(PlayExternalState())
|
||||
|
||||
fun init() {
|
||||
fun init(destination: Destination) {
|
||||
Timber.v("init called: %s", destination)
|
||||
state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchDefault {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val positionMs: Long
|
||||
|
|
@ -106,9 +106,9 @@ class PlayExternalViewModel
|
|||
}
|
||||
try {
|
||||
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||
val base =
|
||||
val playlistItem =
|
||||
if (queriedItem.type.playable) {
|
||||
queriedItem
|
||||
PlaylistItem.Media(BaseItem(queriedItem))
|
||||
} else if (destination is Destination.PlaybackList) {
|
||||
val playlistResult =
|
||||
playlistCreator.createFrom(
|
||||
|
|
@ -135,18 +135,15 @@ class PlayExternalViewModel
|
|||
navigationManager.goBack()
|
||||
return@launchDefault
|
||||
}
|
||||
r.playlist.items
|
||||
.first()
|
||||
.data
|
||||
r.playlist.items.first()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
||||
}
|
||||
val item = BaseItem(base, false)
|
||||
val playbackConfig =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
||||
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
|
||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||
if (it.sourceId != null) {
|
||||
it
|
||||
|
|
@ -155,20 +152,25 @@ class PlayExternalViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
||||
val item =
|
||||
when (playlistItem) {
|
||||
is PlaylistItem.Intro -> playlistItem.item
|
||||
is PlaylistItem.Media -> playlistItem.item
|
||||
}
|
||||
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
if (mediaSource == null) {
|
||||
Timber.w("Media source is null")
|
||||
return@launchDefault
|
||||
}
|
||||
savedStateHandle[KEY_ID] = base.id
|
||||
savedStateHandle[KEY_ID] = playlistItem.id
|
||||
savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
|
||||
val subtitleIndex =
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
source = mediaSource,
|
||||
audioStream = null,
|
||||
seriesId = base.seriesId,
|
||||
seriesId = item.data.seriesId,
|
||||
itemPlayback = playbackConfig,
|
||||
plc = plc,
|
||||
prefs = prefs,
|
||||
|
|
@ -252,7 +254,17 @@ class PlayExternalViewModel
|
|||
putExtra("forcedsrt", subtitleUrls[it])
|
||||
}
|
||||
}
|
||||
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
canSeek = false,
|
||||
itemId = itemId,
|
||||
isPaused = false,
|
||||
playMethod = PlayMethod.DIRECT_PLAY,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
isMuted = false,
|
||||
),
|
||||
)
|
||||
state.update {
|
||||
PlayExternalState(
|
||||
loading = LoadingState.Success,
|
||||
|
|
@ -278,10 +290,10 @@ class PlayExternalViewModel
|
|||
return@launchDefault
|
||||
}
|
||||
Timber.v(
|
||||
"Result: result=%s, itemId=%s action=%s",
|
||||
"Result: result=%s, action=%s, itemId=%s",
|
||||
result.resultCode,
|
||||
itemId,
|
||||
result.data?.action,
|
||||
itemId,
|
||||
)
|
||||
if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
|
||||
// Vimu return 1 for video completion
|
||||
|
|
@ -295,7 +307,7 @@ class PlayExternalViewModel
|
|||
position =
|
||||
data
|
||||
.getLongExtra("extra_position", Long.MIN_VALUE)
|
||||
.takeIf { it >= 0 }
|
||||
.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
// mpv-android: https://mpv-android.github.io/mpv-android/intent.html
|
||||
|
|
@ -326,6 +338,7 @@ class PlayExternalViewModel
|
|||
}
|
||||
}
|
||||
Timber.v("Result position: %s", position?.milliseconds)
|
||||
if (position != null || result.data?.action != null) {
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = itemId,
|
||||
|
|
@ -334,11 +347,18 @@ class PlayExternalViewModel
|
|||
failed = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.w("Activity result: %s", result.resultCode)
|
||||
Timber.w(
|
||||
"Activity result: %s, action=%s",
|
||||
result.resultCode,
|
||||
result.data?.action,
|
||||
)
|
||||
showToast(context, "Unknown result from external player")
|
||||
}
|
||||
navigationManager.goBack()
|
||||
state.update { PlayExternalState() }
|
||||
launched.update { false }
|
||||
} catch (_: CancellationException) {
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during external playback of %s", itemId)
|
||||
|
|
@ -359,7 +379,7 @@ class PlayExternalViewModel
|
|||
}
|
||||
|
||||
data class PlayExternalState(
|
||||
val loading: LoadingState = LoadingState.Loading,
|
||||
val loading: LoadingState = LoadingState.Pending,
|
||||
val intent: Intent = Intent(),
|
||||
)
|
||||
|
||||
|
|
@ -369,8 +389,8 @@ fun PlayExternalPage(
|
|||
destination: Destination,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlayExternalViewModel =
|
||||
hiltViewModel<PlayExternalViewModel, PlayExternalViewModel.Factory>(
|
||||
creationCallback = { it.create(destination) },
|
||||
hiltViewModel(
|
||||
viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity,
|
||||
),
|
||||
) {
|
||||
val launcher =
|
||||
|
|
@ -380,15 +400,18 @@ fun PlayExternalPage(
|
|||
)
|
||||
|
||||
val state by viewModel.state.collectAsState()
|
||||
var launched by rememberSaveable { mutableStateOf(false) }
|
||||
if (!launched) {
|
||||
val launched by viewModel.launched.collectAsState()
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init()
|
||||
if (!launched) {
|
||||
viewModel.init(destination)
|
||||
}
|
||||
}
|
||||
|
||||
when (val l = state.loading) {
|
||||
LoadingState.Pending,
|
||||
LoadingState.Pending -> {
|
||||
LoadingPage(modifier, false)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
|
|
@ -403,7 +426,7 @@ fun PlayExternalPage(
|
|||
if (!launched) {
|
||||
LifecycleStartEffect(Unit) {
|
||||
Timber.i("Launching external playback")
|
||||
launched = true
|
||||
viewModel.launched.update { true }
|
||||
try {
|
||||
launcher.launch(state.intent)
|
||||
} catch (ex: Exception) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,11 +36,14 @@ import com.github.damontecres.wholphin.data.model.TrackIndex
|
|||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlin.time.Duration
|
||||
|
||||
enum class PlaybackDialogType {
|
||||
MORE,
|
||||
DEBUG,
|
||||
CAPTIONS,
|
||||
SETTINGS,
|
||||
AUDIO,
|
||||
|
|
@ -65,10 +68,10 @@ data class PlaybackSettings(
|
|||
/**
|
||||
* Centralized UI component for displaying dialogs during playback
|
||||
*
|
||||
* Typically, the user will click something generating a [PlaybackAction] which translates into the
|
||||
* Typically, the user will click something generating a [com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction] which translates into the
|
||||
* [PlaybackDialogType] determining which dialog is shown by this component.
|
||||
*
|
||||
* @see PlaybackAction
|
||||
* @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||
*/
|
||||
@Composable
|
||||
fun PlaybackDialog(
|
||||
|
|
@ -83,28 +86,8 @@ fun PlaybackDialog(
|
|||
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||
) {
|
||||
when (type) {
|
||||
PlaybackDialogType.MORE -> {
|
||||
val options =
|
||||
buildList {
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = 0,
|
||||
headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
|
||||
supporting = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
BottomDialog(
|
||||
choices = options,
|
||||
onDismissRequest = {
|
||||
onDismissRequest.invoke()
|
||||
// focusRequester.tryRequestFocus()
|
||||
},
|
||||
onSelectChoice = { index, choice ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
|
||||
},
|
||||
gravity = Gravity.START,
|
||||
)
|
||||
PlaybackDialogType.DEBUG -> {
|
||||
throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG)
|
||||
}
|
||||
|
||||
PlaybackDialogType.CAPTIONS -> {
|
||||
|
|
@ -135,17 +118,8 @@ fun PlaybackDialog(
|
|||
}
|
||||
|
||||
PlaybackDialogType.SETTINGS -> {
|
||||
val currentAudio =
|
||||
remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } }
|
||||
val options =
|
||||
buildList {
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.AUDIO,
|
||||
headline = stringResource(R.string.audio),
|
||||
supporting = currentAudio?.displayTitle,
|
||||
),
|
||||
)
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.PLAYBACK_SPEED,
|
||||
|
|
@ -174,15 +148,26 @@ fun PlaybackDialog(
|
|||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.DEBUG,
|
||||
headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
|
||||
supporting = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
BottomDialog(
|
||||
choices = options,
|
||||
currentChoice = null,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onSelectChoice = { _, choice ->
|
||||
if (choice.data == PlaybackDialogType.DEBUG) {
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
|
||||
} else {
|
||||
onClickPlaybackDialogType(choice.data)
|
||||
}
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
gravity = Gravity.START,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -193,10 +178,6 @@ fun PlaybackDialog(
|
|||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
// scope.launch {
|
||||
// delay(250L)
|
||||
// settingsFocusRequester.tryRequestFocus()
|
||||
// }
|
||||
},
|
||||
onSelectChoice = { _, choice ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index))
|
||||
|
|
@ -220,15 +201,11 @@ fun PlaybackDialog(
|
|||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
// scope.launch {
|
||||
// delay(250L)
|
||||
// settingsFocusRequester.tryRequestFocus()
|
||||
// }
|
||||
},
|
||||
onSelectChoice = { _, value ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
gravity = Gravity.START,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -247,15 +224,11 @@ fun PlaybackDialog(
|
|||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
// scope.launch {
|
||||
// delay(250L)
|
||||
// settingsFocusRequester.tryRequestFocus()
|
||||
// }
|
||||
},
|
||||
onSelectChoice = { _, choice ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data))
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
gravity = Gravity.START,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
|
|
@ -28,6 +29,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -35,12 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
|
|
@ -74,8 +76,13 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
|||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.SkipIndicator
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.SkipSegmentButton
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.rememberSeekBarState
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
|
||||
|
|
@ -167,6 +174,7 @@ fun PlaybackPageContent(
|
|||
),
|
||||
)
|
||||
val currentSegment by viewModel.currentSegment.collectAsState()
|
||||
val analyticsState by viewModel.analyticsState.collectAsState()
|
||||
|
||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||
|
|
@ -389,7 +397,7 @@ fun PlaybackPageContent(
|
|||
.fillMaxSize()
|
||||
.background(Color.Transparent),
|
||||
item = currentPlayback?.item,
|
||||
playerControls = player,
|
||||
player = player,
|
||||
controllerViewState = controllerViewState,
|
||||
showPlay = playPauseState.showPlay,
|
||||
previousEnabled = true,
|
||||
|
|
@ -412,6 +420,7 @@ fun PlaybackPageContent(
|
|||
},
|
||||
currentSegment = currentSegment?.segment,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
analyticsState = analyticsState,
|
||||
)
|
||||
|
||||
val subtitleSettings =
|
||||
|
|
@ -427,9 +436,12 @@ fun PlaybackPageContent(
|
|||
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
|
||||
|
||||
// Subtitles
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) {
|
||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
|
||||
var cueCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SubtitleView(context).apply {
|
||||
|
|
@ -455,16 +467,21 @@ fun PlaybackPageContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setCues(cues)
|
||||
update = { subtitleView ->
|
||||
subtitleView.setCues(cues)
|
||||
if (cues.size > cueCount) {
|
||||
// The output creates a painter for each cue, so need to apply the changes when the number of cues increases
|
||||
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
||||
.apply(it)
|
||||
it.children.firstOrNull { it is AssSubtitleView }?.let {
|
||||
.apply(subtitleView)
|
||||
cueCount = cues.size
|
||||
}
|
||||
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
|
||||
(it as? AssSubtitleView)?.apply {
|
||||
val resized =
|
||||
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
|
||||
if (resized) {
|
||||
Timber.v("Resizing AssSubtitleView: $playerSurfaceSize")
|
||||
|
||||
if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) {
|
||||
Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize)
|
||||
layoutParams =
|
||||
FrameLayout
|
||||
.LayoutParams(
|
||||
|
|
@ -480,12 +497,20 @@ fun PlaybackPageContent(
|
|||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(maxSize)
|
||||
.fillMaxSize(subtitleMaxSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.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
|
||||
|
|
@ -642,7 +667,15 @@ fun PlaybackPageContent(
|
|||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog = null
|
||||
playbackDialog =
|
||||
when (type) {
|
||||
// Go back to settings dialog
|
||||
PlaybackDialogType.PLAYBACK_SPEED,
|
||||
PlaybackDialogType.VIDEO_SCALE,
|
||||
-> PlaybackDialogType.SETTINGS
|
||||
|
||||
else -> null
|
||||
}
|
||||
if (controllerViewState.controlsVisible) {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
|
|
@ -33,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
|
|
@ -51,6 +54,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
|||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||
import com.github.damontecres.wholphin.ui.gt
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -105,6 +109,7 @@ import org.jellyfin.sdk.model.api.ImageType
|
|||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||
|
|
@ -118,6 +123,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|||
import timber.log.Timber
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -175,12 +181,13 @@ class PlaybackViewModel
|
|||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||
val analyticsState = MutableStateFlow(AnalyticsState())
|
||||
|
||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||
|
||||
private lateinit var preferences: UserPreferences
|
||||
internal lateinit var itemId: UUID
|
||||
internal lateinit var item: BaseItem
|
||||
internal lateinit var currentItem: PlaylistItem
|
||||
internal var forceTranscoding: Boolean = false
|
||||
private var activityListener: TrackActivityPlaybackListener? = null
|
||||
private val jobs = mutableListOf<Job>()
|
||||
|
|
@ -225,8 +232,11 @@ class PlaybackViewModel
|
|||
) {
|
||||
val softwareDecoding =
|
||||
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
|
||||
val requestBackend =
|
||||
(destination as? Destination.Playback)?.backend
|
||||
?: preferences.appPreferences.playbackPreferences.playerBackend
|
||||
val playerBackend =
|
||||
when (preferences.appPreferences.playbackPreferences.playerBackend) {
|
||||
when (requestBackend) {
|
||||
PlayerBackend.UNRECOGNIZED,
|
||||
PlayerBackend.EXO_PLAYER,
|
||||
-> PlayerBackend.EXO_PLAYER
|
||||
|
|
@ -289,21 +299,18 @@ class PlaybackViewModel
|
|||
this.forceTranscoding =
|
||||
(destination as? Destination.Playback)?.forceTranscoding ?: false
|
||||
val positionMs: Long
|
||||
val itemPlayback: ItemPlayback?
|
||||
val forceTranscoding: Boolean
|
||||
|
||||
val itemId =
|
||||
when (val d = destination) {
|
||||
is Destination.Playback -> {
|
||||
positionMs = d.positionMs
|
||||
itemPlayback = d.itemPlayback
|
||||
forceTranscoding = d.forceTranscoding
|
||||
d.itemId
|
||||
}
|
||||
|
||||
is Destination.PlaybackList -> {
|
||||
positionMs = 0
|
||||
itemPlayback = null
|
||||
forceTranscoding = false
|
||||
d.itemId
|
||||
}
|
||||
|
|
@ -314,9 +321,9 @@ class PlaybackViewModel
|
|||
}
|
||||
this.itemId = itemId
|
||||
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||
val base =
|
||||
val playlistItem =
|
||||
if (queriedItem.type.playable) {
|
||||
queriedItem
|
||||
PlaylistItem.Media(BaseItem(queriedItem, false))
|
||||
} else if (destination is Destination.PlaybackList) {
|
||||
val playlistResult =
|
||||
playlistCreator.createFrom(
|
||||
|
|
@ -344,9 +351,7 @@ class PlaybackViewModel
|
|||
this@PlaybackViewModel.playlist.value = r.playlist
|
||||
}
|
||||
}
|
||||
r.playlist.items
|
||||
.first()
|
||||
.data
|
||||
r.playlist.items.first()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -355,12 +360,39 @@ class PlaybackViewModel
|
|||
|
||||
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
||||
|
||||
val item = BaseItem.from(base, api)
|
||||
val intros =
|
||||
// If not resuming playback & cinema mode is enabled, get potential intros
|
||||
if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) {
|
||||
api.userLibraryApi
|
||||
.getIntros(
|
||||
itemId = playlistItem.id,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
).content.items
|
||||
.map {
|
||||
PlaylistItem.Intro(BaseItem(it))
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val firstItem =
|
||||
if (intros.isNotEmpty()) {
|
||||
Timber.v("Got %s intros", intros.size)
|
||||
val currentPlaylist =
|
||||
this@PlaybackViewModel
|
||||
.playlist.value
|
||||
?.items
|
||||
.orEmpty()
|
||||
val newPlaylist = Playlist(intros + currentPlaylist)
|
||||
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
||||
intros.first()
|
||||
} else {
|
||||
playlistItem
|
||||
}
|
||||
|
||||
val played =
|
||||
play(
|
||||
item,
|
||||
firstItem,
|
||||
positionMs,
|
||||
itemPlayback,
|
||||
forceTranscoding,
|
||||
)
|
||||
if (!played) {
|
||||
|
|
@ -370,9 +402,13 @@ class PlaybackViewModel
|
|||
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
||||
val result = playlistCreator.createFrom(queriedItem)
|
||||
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = result.playlist
|
||||
}
|
||||
val currentPlaylist =
|
||||
this@PlaybackViewModel
|
||||
.playlist.value
|
||||
?.items
|
||||
.orEmpty()
|
||||
val newPlaylist = Playlist(currentPlaylist + result.playlist.items)
|
||||
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -380,18 +416,23 @@ class PlaybackViewModel
|
|||
/**
|
||||
* Play an item
|
||||
*
|
||||
* @param item the item to play
|
||||
* @param currentItem the item to play
|
||||
* @param positionMs the starting playback position in milliseconds
|
||||
* @param itemPlayback the parameters for playback such chosen subtitle or audio streams
|
||||
* @param forceTranscoding whether the user has requested to force playback via transcoding
|
||||
*/
|
||||
private suspend fun play(
|
||||
item: BaseItem,
|
||||
playlistItem: PlaylistItem,
|
||||
positionMs: Long,
|
||||
itemPlayback: ItemPlayback? = null,
|
||||
forceTranscoding: Boolean = this.forceTranscoding,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val item =
|
||||
when (playlistItem) {
|
||||
is PlaylistItem.Intro -> playlistItem.item
|
||||
is PlaylistItem.Media -> playlistItem.item
|
||||
}
|
||||
|
||||
Timber.i("Playing ${item.id}")
|
||||
|
||||
// New item, so we can clear the media segment tracker & subtitle cues
|
||||
|
|
@ -411,16 +452,15 @@ class PlaybackViewModel
|
|||
)
|
||||
return@withContext false
|
||||
}
|
||||
this@PlaybackViewModel.item = item
|
||||
this@PlaybackViewModel.currentItem = playlistItem
|
||||
this@PlaybackViewModel.itemId = item.id
|
||||
|
||||
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
|
||||
val base = item.data
|
||||
|
||||
// Use the provided playback parameters or else check if the database has some
|
||||
val playbackConfig =
|
||||
itemPlayback
|
||||
?: serverRepository.currentUser.value?.let { user ->
|
||||
val itemPlayback =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||
if (it.sourceId != null) {
|
||||
|
|
@ -430,7 +470,7 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
|
||||
val mediaSource = streamChoiceService.chooseSource(base, itemPlayback)
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
||||
|
||||
if (mediaSource == null) {
|
||||
|
|
@ -486,7 +526,7 @@ class PlaybackViewModel
|
|||
.chooseAudioStream(
|
||||
source = mediaSource,
|
||||
seriesId = base.seriesId,
|
||||
itemPlayback = playbackConfig,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = plc,
|
||||
prefs = preferences,
|
||||
)
|
||||
|
|
@ -498,7 +538,7 @@ class PlaybackViewModel
|
|||
source = mediaSource,
|
||||
audioStream = audioStream,
|
||||
seriesId = base.seriesId,
|
||||
itemPlayback = playbackConfig,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = plc,
|
||||
prefs = preferences,
|
||||
)?.index
|
||||
|
|
@ -506,7 +546,7 @@ class PlaybackViewModel
|
|||
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||
|
||||
val itemPlaybackToUse =
|
||||
playbackConfig ?: ItemPlayback(
|
||||
itemPlayback ?: ItemPlayback(
|
||||
rowId = -1,
|
||||
userId = -1,
|
||||
itemId = base.id,
|
||||
|
|
@ -776,6 +816,7 @@ class PlaybackViewModel
|
|||
subtitleIndex,
|
||||
source,
|
||||
)
|
||||
Timber.v("onTracksChanged: %s", result)
|
||||
if (result.bothSelected) {
|
||||
player.trackSelectionParameters =
|
||||
result.trackSelectionParameters
|
||||
|
|
@ -885,7 +926,7 @@ class PlaybackViewModel
|
|||
Timber.d("Changing audio track to %s", index)
|
||||
val itemPlayback =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
item = currentItem.item,
|
||||
itemPlayback = currentItemPlayback.value!!,
|
||||
trackIndex = index,
|
||||
type = MediaStreamType.AUDIO,
|
||||
|
|
@ -899,7 +940,7 @@ class PlaybackViewModel
|
|||
streamChoiceService.resolveSubtitleIndex(
|
||||
source = source,
|
||||
audioStreamIndex = index,
|
||||
seriesId = item.data.seriesId,
|
||||
seriesId = currentItem.item.data.seriesId,
|
||||
subtitleIndex = itemPlayback.subtitleIndex,
|
||||
prefs = preferences,
|
||||
)
|
||||
|
|
@ -908,7 +949,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
changeStreams(
|
||||
item,
|
||||
currentItem.item,
|
||||
itemPlayback,
|
||||
index,
|
||||
resolvedSubtitleIndex,
|
||||
|
|
@ -923,7 +964,7 @@ class PlaybackViewModel
|
|||
Timber.d("Changing subtitle track to %s", index)
|
||||
val itemPlayback =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
item = currentItem.item,
|
||||
itemPlayback = currentItemPlayback.value!!,
|
||||
trackIndex = index,
|
||||
type = MediaStreamType.SUBTITLE,
|
||||
|
|
@ -937,7 +978,7 @@ class PlaybackViewModel
|
|||
streamChoiceService.resolveSubtitleIndex(
|
||||
source = source,
|
||||
audioStreamIndex = itemPlayback.audioIndex,
|
||||
seriesId = item.data.seriesId,
|
||||
seriesId = currentItem.item.data.seriesId,
|
||||
subtitleIndex = index,
|
||||
prefs = preferences,
|
||||
)
|
||||
|
|
@ -946,7 +987,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
changeStreams(
|
||||
item,
|
||||
currentItem.item,
|
||||
itemPlayback,
|
||||
itemPlayback.audioIndex,
|
||||
resolvedIndex,
|
||||
|
|
@ -981,7 +1022,7 @@ class PlaybackViewModel
|
|||
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
|
||||
): String? =
|
||||
trickPlayInfo?.let {
|
||||
val itemId = item.id
|
||||
val itemId = currentItem.id
|
||||
return api.trickplayApi.getTrickplayTileImageUrl(
|
||||
itemId,
|
||||
trickPlayInfo.width,
|
||||
|
|
@ -992,12 +1033,28 @@ class PlaybackViewModel
|
|||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
viewModelScope.launchIO {
|
||||
val nextItem = playlist.value?.peek()
|
||||
Timber.v("Setting next up to ${nextItem?.id}")
|
||||
Timber.v("Playback state is STATE_ENDED")
|
||||
viewModelScope.launchDefault {
|
||||
when (val nextItem = playlist.value?.peek()) {
|
||||
is PlaylistItem.Intro -> {
|
||||
Timber.v("Next item is intro, so playing immediately")
|
||||
playNextUp()
|
||||
}
|
||||
|
||||
is PlaylistItem.Media -> {
|
||||
if (currentItem is PlaylistItem.Intro) {
|
||||
Timber.v("Current item is intro, so playing next up immediately")
|
||||
playNextUp()
|
||||
} else {
|
||||
Timber.v("Setting next up to ${nextItem.id}")
|
||||
withContext(Dispatchers.Main) {
|
||||
nextUp.value = nextItem
|
||||
if (nextItem == null) {
|
||||
nextUp.value = nextItem.item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null -> {
|
||||
Timber.v("No next up")
|
||||
navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
|
|
@ -1063,9 +1120,11 @@ class PlaybackViewModel
|
|||
outroShownSegments.add(currentSegment.id)
|
||||
) {
|
||||
val nextItem = playlist.peek()
|
||||
if (nextItem is PlaylistItem.Media) {
|
||||
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
||||
withContext(Dispatchers.Main) {
|
||||
nextUp.value = nextItem
|
||||
nextUp.value = nextItem.item
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val behavior =
|
||||
|
|
@ -1190,7 +1249,7 @@ class PlaybackViewModel
|
|||
fun playNextUp() {
|
||||
playlist.value?.let {
|
||||
if (it.hasNext()) {
|
||||
viewModelScope.launchIO {
|
||||
viewModelScope.launchDefault {
|
||||
cancelUpNextEpisode()
|
||||
val item = it.getAndAdvance()
|
||||
val played = play(item, 0)
|
||||
|
|
@ -1205,7 +1264,7 @@ class PlaybackViewModel
|
|||
fun playPrevious() {
|
||||
playlist.value?.let {
|
||||
if (it.hasPrevious()) {
|
||||
viewModelScope.launchIO {
|
||||
viewModelScope.launchDefault {
|
||||
cancelUpNextEpisode()
|
||||
val item = it.getPreviousAndReverse()
|
||||
val played = play(item, 0)
|
||||
|
|
@ -1266,7 +1325,7 @@ class PlaybackViewModel
|
|||
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
|
||||
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
|
||||
changeStreams(
|
||||
item,
|
||||
currentItem.item,
|
||||
currentItemPlayback.value!!,
|
||||
currentItemPlayback.value?.audioIndex,
|
||||
currentItemPlayback.value?.subtitleIndex,
|
||||
|
|
@ -1358,6 +1417,35 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateDecoder(
|
||||
decoderName: String,
|
||||
type: MediaType,
|
||||
) {
|
||||
viewModelScope.launchDefault {
|
||||
val codecInfo =
|
||||
MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
.codecInfos
|
||||
.firstOrNull { !it.isEncoder && it.name == decoderName }
|
||||
val decoderString =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (codecInfo?.isHardwareAccelerated == true) {
|
||||
"$decoderName (HW)"
|
||||
} else {
|
||||
decoderName
|
||||
}
|
||||
} else {
|
||||
decoderName
|
||||
}
|
||||
currentPlayback.update {
|
||||
when (type) {
|
||||
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
|
||||
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
|
||||
else -> throw IllegalArgumentException("Unsupported type: $type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVideoDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
|
|
@ -1365,7 +1453,7 @@ class PlaybackViewModel
|
|||
initializationDurationMs: Long,
|
||||
) {
|
||||
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
|
||||
currentPlayback.update { it?.copy(videoDecoder = decoderName) }
|
||||
updateDecoder(decoderName, MediaType.VIDEO)
|
||||
}
|
||||
|
||||
override fun onVideoDisabled(
|
||||
|
|
@ -1384,7 +1472,7 @@ class PlaybackViewModel
|
|||
decoderReuseEvaluation?.let { decoder ->
|
||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}")
|
||||
currentPlayback.update { it?.copy(videoDecoder = decoder.decoderName) }
|
||||
updateDecoder(decoder.decoderName, MediaType.VIDEO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1396,7 +1484,7 @@ class PlaybackViewModel
|
|||
initializationDurationMs: Long,
|
||||
) {
|
||||
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
|
||||
currentPlayback.update { it?.copy(audioDecoder = decoderName) }
|
||||
updateDecoder(decoderName, MediaType.AUDIO)
|
||||
}
|
||||
|
||||
override fun onAudioInputFormatChanged(
|
||||
|
|
@ -1407,7 +1495,7 @@ class PlaybackViewModel
|
|||
decoderReuseEvaluation?.let { decoder ->
|
||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}")
|
||||
currentPlayback.update { it?.copy(audioDecoder = decoder.decoderName) }
|
||||
updateDecoder(decoder.decoderName, MediaType.AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1466,6 +1554,37 @@ class PlaybackViewModel
|
|||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
screensaverService.keepScreenOn(isPlaying)
|
||||
}
|
||||
|
||||
override fun onBandwidthEstimate(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
totalLoadTimeMs: Int,
|
||||
totalBytesLoaded: Long,
|
||||
bitrateEstimate: Long,
|
||||
) {
|
||||
Timber.v(
|
||||
"onBandwidthEstimate: totalLoadTimeMs=%s, totalBytesLoaded=%s, bitrateEstimate=%s",
|
||||
totalLoadTimeMs,
|
||||
totalBytesLoaded,
|
||||
bitrateEstimate,
|
||||
)
|
||||
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
|
||||
analyticsState.update {
|
||||
it.copy(
|
||||
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
|
||||
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDroppedVideoFrames(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
droppedFrames: Int,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
|
||||
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
||||
}
|
||||
}
|
||||
|
||||
data class PlayerState(
|
||||
|
|
@ -1478,3 +1597,9 @@ data class MediaSegmentState(
|
|||
val segment: MediaSegmentDto,
|
||||
val interacted: Boolean,
|
||||
)
|
||||
|
||||
data class AnalyticsState(
|
||||
val bitrate: String = formatBitrate(0),
|
||||
val bitrateEstimate: String = formatBitrate(0),
|
||||
val droppedFrames: Int = 0,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.text.intl.Locale
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
|
|
@ -106,12 +107,13 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
while (maxAttempts > 0 && subtitleCount == newCount) {
|
||||
maxAttempts--
|
||||
delay(1500)
|
||||
item =
|
||||
BaseItem.from(
|
||||
api.userLibraryApi.getItem(itemId = it.itemId).content,
|
||||
api,
|
||||
)
|
||||
mediaSource = streamChoiceService.chooseSource(item.data, it)
|
||||
val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content)
|
||||
currentItem =
|
||||
when (currentItem) {
|
||||
is PlaylistItem.Intro -> PlaylistItem.Intro(base)
|
||||
is PlaylistItem.Media -> PlaylistItem.Media(base)
|
||||
}
|
||||
mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it)
|
||||
if (mediaSource == null) {
|
||||
// This shouldn't happen, but just in case
|
||||
showToast(
|
||||
|
|
@ -158,7 +160,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
)
|
||||
}
|
||||
this@downloadAndSwitchSubtitles.changeStreams(
|
||||
item,
|
||||
currentItem.item,
|
||||
currentItemPlayback.value!!,
|
||||
audioIndex,
|
||||
newStream.index,
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ object TrackSelectionUtils {
|
|||
true
|
||||
}
|
||||
val audioSelected =
|
||||
if (audioIndex != null && supportsDirectPlay) {
|
||||
if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) {
|
||||
val indexToFind =
|
||||
calculateIndexToFind(
|
||||
audioIndex,
|
||||
|
|
@ -141,7 +141,7 @@ object TrackSelectionUtils {
|
|||
}
|
||||
chosenTrack != null
|
||||
} else {
|
||||
audioIndex == null
|
||||
true
|
||||
}
|
||||
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.github.damontecres.wholphin.ui.playback
|
||||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.annotation.DrawableRes
|
||||
|
|
@ -69,6 +69,8 @@ import com.github.damontecres.wholphin.ui.components.Button
|
|||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.github.damontecres.wholphin.ui.seekForward
|
||||
import com.github.damontecres.wholphin.ui.skipStringRes
|
||||
|
|
@ -123,13 +125,10 @@ sealed interface PlaybackAction {
|
|||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackControls(
|
||||
playerControls: Player,
|
||||
player: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
moreFocusRequester: FocusRequester,
|
||||
captionFocusRequester: FocusRequester,
|
||||
settingsFocusRequester: FocusRequester,
|
||||
onSeekProgress: (Long) -> Unit,
|
||||
showPlay: Boolean,
|
||||
previousEnabled: Boolean,
|
||||
|
|
@ -164,7 +163,7 @@ fun PlaybackControls(
|
|||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
SeekBar(
|
||||
player = playerControls,
|
||||
player = player,
|
||||
controllerViewState = controllerViewState,
|
||||
onSeekProgress = onSeekProgress,
|
||||
interactionSource = seekBarInteractionSource,
|
||||
|
|
@ -184,13 +183,12 @@ fun PlaybackControls(
|
|||
.fillMaxWidth(),
|
||||
) {
|
||||
LeftPlaybackButtons(
|
||||
moreFocusRequester = moreFocusRequester,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
)
|
||||
PlaybackButtons(
|
||||
player = playerControls,
|
||||
player = player,
|
||||
initialFocusRequester = initialFocusRequester,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
|
|
@ -209,7 +207,7 @@ fun PlaybackControls(
|
|||
TextButton(
|
||||
stringRes = segment.type.skipStringRes,
|
||||
onClick = {
|
||||
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -218,8 +216,6 @@ fun PlaybackControls(
|
|||
)
|
||||
}
|
||||
RightPlaybackButtons(
|
||||
captionFocusRequester = captionFocusRequester,
|
||||
settingsFocusRequester = settingsFocusRequester,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||
modifier = Modifier,
|
||||
|
|
@ -301,7 +297,6 @@ val buttonSpacing = 12.dp
|
|||
|
||||
@Composable
|
||||
fun LeftPlaybackButtons(
|
||||
moreFocusRequester: FocusRequester,
|
||||
onControllerInteraction: () -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -310,24 +305,21 @@ fun LeftPlaybackButtons(
|
|||
modifier = modifier.focusGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||
) {
|
||||
// More options
|
||||
PlaybackButton(
|
||||
iconRes = R.drawable.baseline_more_vert_96,
|
||||
iconRes = R.drawable.vector_settings,
|
||||
onClick = {
|
||||
onControllerInteraction.invoke()
|
||||
onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE)
|
||||
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
|
||||
},
|
||||
enabled = true,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
modifier = Modifier.focusRequester(moreFocusRequester),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RightPlaybackButtons(
|
||||
captionFocusRequester: FocusRequester,
|
||||
settingsFocusRequester: FocusRequester,
|
||||
onControllerInteraction: () -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -345,18 +337,18 @@ fun RightPlaybackButtons(
|
|||
onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS)
|
||||
},
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
modifier = Modifier.focusRequester(captionFocusRequester),
|
||||
modifier = Modifier,
|
||||
)
|
||||
// Playback speed, etc
|
||||
PlaybackButton(
|
||||
iconRes = R.drawable.vector_settings,
|
||||
// Audio
|
||||
PlaybackFaButton(
|
||||
enabled = true,
|
||||
iconRes = R.string.fa_volume_high,
|
||||
onClick = {
|
||||
onControllerInteraction.invoke()
|
||||
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
|
||||
onClickPlaybackDialogType.invoke(PlaybackDialogType.AUDIO)
|
||||
},
|
||||
enabled = true,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
modifier = Modifier.focusRequester(settingsFocusRequester),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -623,7 +615,7 @@ private fun ButtonPreview() {
|
|||
onControllerInteraction = {},
|
||||
)
|
||||
PlaybackFaButton(
|
||||
iconRes = R.string.fa_shuffle,
|
||||
iconRes = R.string.fa_volume_high,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
textColor = Color.Green,
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
/*
|
||||
* Modified from https://github.com/android/tv-samples
|
||||
|
|
@ -46,6 +46,8 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.playback.calculateSeekAccelerationMultiplier
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.foundation.Canvas
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.animation.animateColor
|
||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||
|
|
@ -36,6 +36,7 @@ enum class PreferenceScreenOption {
|
|||
EXO_PLAYER,
|
||||
MPV,
|
||||
SCREENSAVER,
|
||||
SKIP_SEGMENTS,
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
|||
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||
import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences
|
||||
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||
import com.github.damontecres.wholphin.preferences.screensaverPreferences
|
||||
|
|
@ -142,6 +143,7 @@ fun PreferencesContent(
|
|||
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
||||
PreferenceScreenOption.MPV -> MpvPreferences
|
||||
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
||||
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
|
||||
}
|
||||
val screenTitle =
|
||||
when (preferenceScreenOption) {
|
||||
|
|
@ -150,6 +152,7 @@ fun PreferencesContent(
|
|||
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
||||
PreferenceScreenOption.MPV -> R.string.mpv_options
|
||||
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
||||
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
|
||||
}
|
||||
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
|
@ -707,6 +710,7 @@ fun PreferencesPage(
|
|||
PreferenceScreenOption.EXO_PLAYER,
|
||||
PreferenceScreenOption.MPV,
|
||||
PreferenceScreenOption.SCREENSAVER,
|
||||
PreferenceScreenOption.SKIP_SEGMENTS,
|
||||
-> {
|
||||
PreferencesContent(
|
||||
initialPreferences,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.ui.input.key.key
|
|||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -72,6 +73,16 @@ fun PinEntry(
|
|||
Key.DirectionRight -> "R"
|
||||
Key.DirectionDown -> "D"
|
||||
Key.DirectionLeft -> "L"
|
||||
Key.Zero, Key.NumPad0 -> "0"
|
||||
Key.One, Key.NumPad1 -> "1"
|
||||
Key.Two, Key.NumPad2 -> "2"
|
||||
Key.Three, Key.NumPad3 -> "3"
|
||||
Key.Four, Key.NumPad4 -> "4"
|
||||
Key.Five, Key.NumPad5 -> "5"
|
||||
Key.Six, Key.NumPad6 -> "6"
|
||||
Key.Seven, Key.NumPad7 -> "7"
|
||||
Key.Eight, Key.NumPad8 -> "8"
|
||||
Key.Nine, Key.NumPad9 -> "9"
|
||||
else -> return@onKeyEvent false
|
||||
}
|
||||
onTextChange.invoke(str)
|
||||
|
|
@ -122,6 +133,16 @@ fun PinEntryCreate(
|
|||
Key.DirectionRight -> "R"
|
||||
Key.DirectionDown -> "D"
|
||||
Key.DirectionLeft -> "L"
|
||||
Key.Zero, Key.NumPad0 -> "0"
|
||||
Key.One, Key.NumPad1 -> "1"
|
||||
Key.Two, Key.NumPad2 -> "2"
|
||||
Key.Three, Key.NumPad3 -> "3"
|
||||
Key.Four, Key.NumPad4 -> "4"
|
||||
Key.Five, Key.NumPad5 -> "5"
|
||||
Key.Six, Key.NumPad6 -> "6"
|
||||
Key.Seven, Key.NumPad7 -> "7"
|
||||
Key.Eight, Key.NumPad8 -> "8"
|
||||
Key.Nine, Key.NumPad9 -> "9"
|
||||
else -> return@onKeyEvent false
|
||||
}
|
||||
onTextChange.invoke(str)
|
||||
|
|
@ -154,6 +175,7 @@ fun PinEntryCreate(
|
|||
@Composable
|
||||
fun PinArrowRow(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
|
|
@ -167,6 +189,12 @@ fun PinArrowRow(modifier: Modifier = Modifier) {
|
|||
fontFamily = FontAwesome,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.pin_digits_hint),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,19 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -31,12 +24,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.IconButtonDefaults
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -44,9 +34,6 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus {
|
|||
) : ServerConnectionStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a list of servers plus option to add a new one
|
||||
*/
|
||||
@Composable
|
||||
fun ServerList(
|
||||
servers: List<JellyfinServer>,
|
||||
connectionStatus: Map<UUID, ServerConnectionStatus>,
|
||||
onSwitchServer: (JellyfinServer) -> Unit,
|
||||
onTestServer: (JellyfinServer) -> Unit,
|
||||
onAddServer: () -> Unit,
|
||||
onRemoveServer: (JellyfinServer) -> Unit,
|
||||
allowAdd: Boolean,
|
||||
allowDelete: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
items(servers) { server ->
|
||||
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) },
|
||||
supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) },
|
||||
leadingContent = {
|
||||
when (status) {
|
||||
is ServerConnectionStatus.Success -> {}
|
||||
|
||||
ServerConnectionStatus.Pending -> {
|
||||
CircularProgress(
|
||||
Modifier.size(IconButtonDefaults.MediumIconSize),
|
||||
)
|
||||
}
|
||||
|
||||
is ServerConnectionStatus.Error -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = status.message,
|
||||
tint = MaterialTheme.colorScheme.errorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
when (status) {
|
||||
is ServerConnectionStatus.Success -> {
|
||||
onSwitchServer.invoke(server)
|
||||
}
|
||||
|
||||
ServerConnectionStatus.Pending -> {}
|
||||
|
||||
is ServerConnectionStatus.Error -> {
|
||||
onTestServer.invoke(server)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
if (allowDelete) {
|
||||
showDeleteDialog = server
|
||||
}
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
if (allowAdd) {
|
||||
item {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = { Text(text = stringResource(R.string.add_server)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
tint = Color.Green.copy(alpha = .8f),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = { onAddServer.invoke() },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
showDeleteDialog?.let { server ->
|
||||
DialogPopup(
|
||||
showDialog = allowDelete,
|
||||
title = server.name ?: server.url,
|
||||
dialogItems =
|
||||
listOf(
|
||||
DialogItem(
|
||||
stringResource(R.string.switch_servers),
|
||||
R.string.fa_arrow_left_arrow_right,
|
||||
) {
|
||||
onSwitchServer.invoke(server)
|
||||
},
|
||||
DialogItem(
|
||||
stringResource(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
onRemoveServer.invoke(server)
|
||||
},
|
||||
),
|
||||
onDismissRequest = { showDeleteDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = true,
|
||||
properties = DialogProperties(),
|
||||
elevation = 5.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent color for a UUID
|
||||
*/
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue