mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/libass-android-support
This commit is contained in:
commit
20fed46382
93 changed files with 8674 additions and 726 deletions
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
|
|
@ -21,7 +21,7 @@ runs:
|
||||||
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
|
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
|
||||||
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v4
|
||||||
with:
|
with:
|
||||||
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
||||||
- name: Add NDK to path
|
- name: Add NDK to path
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ dependencies {
|
||||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.protobuf.kotlin.lite)
|
implementation(libs.protobuf.kotlin.lite)
|
||||||
implementation(libs.androidx.tvprovider)
|
implementation(libs.androidx.tvprovider)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import com.github.damontecres.wholphin.MainContent
|
import com.github.damontecres.wholphin.MainContent
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverState
|
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
|
@ -43,16 +45,17 @@ class InstrumentedBasicUiTests {
|
||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun myTest() {
|
fun myTest() {
|
||||||
|
val navigationManager = NavigationManager()
|
||||||
|
navigationManager.backStack = NavBackStack(Destination.Home())
|
||||||
// Start the app
|
// Start the app
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
WholphinTheme {
|
WholphinTheme {
|
||||||
MainContent(
|
MainContent(
|
||||||
backStack = mutableListOf(SetupDestination.ServerList),
|
backStack = mutableListOf(SetupDestination.ServerList),
|
||||||
navigationManager = mockk(relaxed = true),
|
navigationManager = navigationManager,
|
||||||
appPreferences = mockk(relaxed = true),
|
appPreferences = mockk(relaxed = true),
|
||||||
backdropService = mockk(relaxed = true),
|
backdropService = mockk(relaxed = true),
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
requestedDestination = Destination.Home(),
|
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
|
@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -127,6 +129,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
|
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
classDiscriminator = "_type"
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -134,6 +141,23 @@ class MainActivity : AppCompatActivity() {
|
||||||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||||
lifecycle.addObserver(playbackLifecycleObserver)
|
lifecycle.addObserver(playbackLifecycleObserver)
|
||||||
|
|
||||||
|
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
|
||||||
|
if (backStackStr != null) {
|
||||||
|
Timber.d("Restoring back stack")
|
||||||
|
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
||||||
|
val lastDest = backStack.lastOrNull()
|
||||||
|
if (lastDest is Destination.Playback ||
|
||||||
|
lastDest is Destination.PlaybackList ||
|
||||||
|
lastDest is Destination.Slideshow
|
||||||
|
) {
|
||||||
|
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||||
|
}
|
||||||
|
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
|
||||||
|
} else {
|
||||||
|
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
|
||||||
|
navigationManager.backStack = NavBackStack(startDestination)
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
viewModel.serverRepository.currentUser.observe(this) { user ->
|
||||||
if (user?.hasPin == true) {
|
if (user?.hasPin == true) {
|
||||||
window?.setFlags(
|
window?.setFlags(
|
||||||
|
|
@ -206,17 +230,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||||
) {
|
) {
|
||||||
ProvideLocalClock {
|
ProvideLocalClock {
|
||||||
val requestedDestination =
|
|
||||||
remember(intent) {
|
|
||||||
intent?.let(::extractDestination) ?: Destination.Home()
|
|
||||||
}
|
|
||||||
MainContent(
|
MainContent(
|
||||||
backStack = setupNavigationManager.backStack,
|
backStack = setupNavigationManager.backStack,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
appPreferences = appPreferences,
|
appPreferences = appPreferences,
|
||||||
backdropService = backdropService,
|
backdropService = backdropService,
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
requestedDestination = requestedDestination,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -287,6 +306,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
Timber.d("onSaveInstanceState")
|
Timber.d("onSaveInstanceState")
|
||||||
|
val str = json.encodeToString(navigationManager.backStack.toList())
|
||||||
|
outState.putString(KEY_BACK_STACK, str)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
|
@ -362,6 +383,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
const val INTENT_SEASON_NUMBER = "seaNum"
|
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||||
const val INTENT_SEASON_ID = "seaId"
|
const val INTENT_SEASON_ID = "seaId"
|
||||||
|
|
||||||
|
private const val KEY_BACK_STACK = "backStack"
|
||||||
|
|
||||||
lateinit var instance: MainActivity
|
lateinit var instance: MainActivity
|
||||||
private set
|
private set
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -33,10 +34,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
|
|
@ -45,9 +44,9 @@ fun MainContent(
|
||||||
appPreferences: AppPreferences,
|
appPreferences: AppPreferences,
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
requestedDestination: Destination,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
|
|
@ -96,10 +95,6 @@ fun MainContent(
|
||||||
backdropService.clearBackdrop()
|
backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
val current = key.current
|
val current = key.current
|
||||||
val preferences =
|
|
||||||
remember(appPreferences) {
|
|
||||||
UserPreferences(appPreferences)
|
|
||||||
}
|
|
||||||
var showContent by remember {
|
var showContent by remember {
|
||||||
mutableStateOf(true)
|
mutableStateOf(true)
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +108,6 @@ fun MainContent(
|
||||||
ApplicationContent(
|
ApplicationContent(
|
||||||
user = current.user,
|
user = current.user,
|
||||||
server = current.server,
|
server = current.server,
|
||||||
startDestination = requestedDestination,
|
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
|
@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
@ -61,6 +60,7 @@ class WholphinDreamService :
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Timber.d("onCreate")
|
||||||
|
|
||||||
savedStateRegistryController.performRestore(null)
|
savedStateRegistryController.performRestore(null)
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
|
|
@ -74,6 +74,7 @@ class WholphinDreamService :
|
||||||
|
|
||||||
override fun onAttachedToWindow() {
|
override fun onAttachedToWindow() {
|
||||||
super.onAttachedToWindow()
|
super.onAttachedToWindow()
|
||||||
|
Timber.d("onAttachedToWindow")
|
||||||
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
||||||
setContentView(
|
setContentView(
|
||||||
ComposeView(this).apply {
|
ComposeView(this).apply {
|
||||||
|
|
@ -109,11 +110,13 @@ class WholphinDreamService :
|
||||||
|
|
||||||
override fun onDreamingStarted() {
|
override fun onDreamingStarted() {
|
||||||
super.onDreamingStarted()
|
super.onDreamingStarted()
|
||||||
|
Timber.d("onDreamingStarted")
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDreamingStopped() {
|
override fun onDreamingStopped() {
|
||||||
super.onDreamingStopped()
|
super.onDreamingStopped()
|
||||||
|
Timber.d("onDreamingStopped")
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.room.Query
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
|
|
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
|
||||||
itemId: String,
|
itemId: String,
|
||||||
): LibraryDisplayInfo?
|
): LibraryDisplayInfo?
|
||||||
|
|
||||||
|
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
|
||||||
|
fun getItemAsFlow(
|
||||||
|
userId: Int,
|
||||||
|
itemId: String,
|
||||||
|
): Flow<LibraryDisplayInfo?>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun saveItem(item: LibraryDisplayInfo): Long
|
fun saveItem(item: LibraryDisplayInfo): Long
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class AudioItem(
|
||||||
|
val key: Long = keyTracker++,
|
||||||
|
val id: UUID,
|
||||||
|
val albumId: UUID?,
|
||||||
|
val artistId: UUID?,
|
||||||
|
val title: String?,
|
||||||
|
val albumTitle: String?,
|
||||||
|
val artistNames: String?,
|
||||||
|
val runtime: Duration?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val hasLyrics: Boolean,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private var keyTracker = 0L
|
||||||
|
|
||||||
|
fun from(
|
||||||
|
item: BaseItem,
|
||||||
|
imageUrl: String?,
|
||||||
|
): AudioItem =
|
||||||
|
AudioItem(
|
||||||
|
id = item.id,
|
||||||
|
albumId = item.data.albumId,
|
||||||
|
artistId =
|
||||||
|
item.data.artistItems
|
||||||
|
?.firstOrNull()
|
||||||
|
?.id,
|
||||||
|
title = item.title,
|
||||||
|
albumTitle = item.data.album,
|
||||||
|
artistNames = item.data.albumArtist,
|
||||||
|
runtime = item.data.runTimeTicks?.ticks,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
hasLyrics = item.data.hasLyrics == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,13 @@ import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import com.github.damontecres.wholphin.ui.DateFormatter
|
|
||||||
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.artistsString
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.dot
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
|
import com.github.damontecres.wholphin.ui.getDateFormatter
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
|
|
@ -32,7 +33,7 @@ import kotlin.time.Duration
|
||||||
@Stable
|
@Stable
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean = false,
|
||||||
val imageUrlOverride: String? = null,
|
val imageUrlOverride: String? = null,
|
||||||
val destinationOverride: Destination? = null,
|
val destinationOverride: Destination? = null,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
|
|
@ -57,6 +58,7 @@ data class BaseItem(
|
||||||
when (type) {
|
when (type) {
|
||||||
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
||||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||||
|
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
|
||||||
else -> data.productionYear?.toString()
|
else -> data.productionYear?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +118,7 @@ data class BaseItem(
|
||||||
buildList {
|
buildList {
|
||||||
if (type == BaseItemKind.EPISODE) {
|
if (type == BaseItemKind.EPISODE) {
|
||||||
data.seasonEpisode?.let(::add)
|
data.seasonEpisode?.let(::add)
|
||||||
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
data.premiereDate?.let { add(getDateFormatter().format(it)) }
|
||||||
} else if (type == BaseItemKind.SERIES) {
|
} else if (type == BaseItemKind.SERIES) {
|
||||||
data.seriesProductionYears?.let(::add)
|
data.seriesProductionYears?.let(::add)
|
||||||
} else if (type == BaseItemKind.PHOTO) {
|
} else if (type == BaseItemKind.PHOTO) {
|
||||||
|
|
@ -125,6 +127,9 @@ data class BaseItem(
|
||||||
} else if (data.premiereDate != null) {
|
} else if (data.premiereDate != null) {
|
||||||
add(data.premiereDate!!.toLocalDate().toString())
|
add(data.premiereDate!!.toLocalDate().toString())
|
||||||
}
|
}
|
||||||
|
} else if (type == BaseItemKind.BOX_SET) {
|
||||||
|
data.productionYear?.let { add(it.toString()) }
|
||||||
|
data.childCount?.let { add("$it items") }
|
||||||
} else {
|
} else {
|
||||||
data.productionYear?.let { add(it.toString()) }
|
data.productionYear?.let { add(it.toString()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import androidx.room.Ignore
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
|
|
@ -41,4 +43,13 @@ data class LibraryDisplayInfo(
|
||||||
) {
|
) {
|
||||||
@Ignore @Transient
|
@Ignore @Transient
|
||||||
val sortAndDirection = SortAndDirection(sort, direction)
|
val sortAndDirection = SortAndDirection(sort, direction)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
user: JellyfinUser,
|
||||||
|
itemId: UUID,
|
||||||
|
sort: ItemSortBy,
|
||||||
|
direction: SortOrder,
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
viewOptions: ViewOptions?,
|
||||||
|
) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,15 @@ class AppPreferencesSerializer
|
||||||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||||
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
musicPreferences =
|
||||||
|
MusicPreferences
|
||||||
|
.newBuilder()
|
||||||
|
.apply {
|
||||||
|
showBackdrop = true
|
||||||
|
showLyrics = true
|
||||||
|
showAlbumArt = true
|
||||||
|
}.build()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||||
|
|
@ -220,6 +229,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
|
||||||
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences =
|
||||||
|
update {
|
||||||
|
musicPreferences = musicPreferences.toBuilder().apply(block).build()
|
||||||
|
}
|
||||||
|
|
||||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
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.updatePhotoPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||||
|
|
@ -280,5 +281,15 @@ class AppUpgradeHandler
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateMusicPreferences {
|
||||||
|
showBackdrop = true
|
||||||
|
showLyrics = true
|
||||||
|
showAlbumArt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (imageUrl.isNullOrBlank()) {
|
if (imageUrl.isNullOrBlank()) {
|
||||||
return@withContext ExtractedColors.DEFAULT
|
return@withContext ExtractedColors.DEFAULT
|
||||||
|
|
|
||||||
|
|
@ -438,10 +438,7 @@ class HomeSettingsService
|
||||||
): HomeRowConfigDisplay =
|
): HomeRowConfigDisplay =
|
||||||
when (config) {
|
when (config) {
|
||||||
is HomeRowConfig.ByParent -> {
|
is HomeRowConfig.ByParent -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -466,10 +463,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.Genres -> {
|
is HomeRowConfig.Genres -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
context.getString(R.string.genres_in, name),
|
context.getString(R.string.genres_in, name),
|
||||||
|
|
@ -490,10 +484,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.RecentlyAdded -> {
|
is HomeRowConfig.RecentlyAdded -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
context.getString(R.string.recently_added_in, name),
|
context.getString(R.string.recently_added_in, name),
|
||||||
|
|
@ -502,10 +493,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.RecentlyReleased -> {
|
is HomeRowConfig.RecentlyReleased -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
context.getString(R.string.recently_released_in, name),
|
context.getString(R.string.recently_released_in, name),
|
||||||
|
|
@ -547,10 +535,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.Suggestions -> {
|
is HomeRowConfig.Suggestions -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id,
|
id = id,
|
||||||
title = context.getString(R.string.suggestions_for, name),
|
title = context.getString(R.string.suggestions_for, name),
|
||||||
|
|
@ -559,6 +544,16 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getItemName(itemId: UUID): String? =
|
||||||
|
try {
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content.name
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Could not get name for %s", itemId)
|
||||||
|
context.getString(R.string.unknown)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the data from the server for a given [HomeRowConfig]
|
* Fetch the data from the server for a given [HomeRowConfig]
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets/saves a serializable object by name
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class KeyValueService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
val json =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = false
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T> get(key: String): Flow<T?> =
|
||||||
|
dataStore.data.map { preferences ->
|
||||||
|
preferences[stringPreferencesKey(key)]?.let {
|
||||||
|
json.decodeFromString<T>(serializer<T>(), it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T> get(
|
||||||
|
key: String,
|
||||||
|
defaultValue: T,
|
||||||
|
): Flow<T> =
|
||||||
|
dataStore.data.map { preferences ->
|
||||||
|
preferences[stringPreferencesKey(key)]?.let {
|
||||||
|
json.decodeFromString<T>(serializer<T>(), it)
|
||||||
|
} ?: defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T> get(
|
||||||
|
userId: UUID,
|
||||||
|
key: String,
|
||||||
|
defaultValue: T,
|
||||||
|
): Flow<T> =
|
||||||
|
dataStore.data.map { preferences ->
|
||||||
|
preferences[stringPreferencesKey("${userId}_$key")]?.let {
|
||||||
|
json.decodeFromString<T>(serializer<T>(), it)
|
||||||
|
} ?: defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified T> save(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
) {
|
||||||
|
dataStore.updateData { preferences ->
|
||||||
|
val valueStr = json.encodeToString(value)
|
||||||
|
preferences.toMutablePreferences().apply {
|
||||||
|
set(stringPreferencesKey(key), valueStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified T> save(
|
||||||
|
userId: UUID,
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
) {
|
||||||
|
dataStore.updateData { preferences ->
|
||||||
|
val valueStr = json.encodeToString(value)
|
||||||
|
preferences.toMutablePreferences().apply {
|
||||||
|
set(stringPreferencesKey("${userId}_$key"), valueStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,519 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
import com.github.damontecres.wholphin.ui.seekBack
|
||||||
|
import com.github.damontecres.wholphin.ui.seekForward
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import com.github.damontecres.wholphin.util.BlockingList
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.PlaybackItemState
|
||||||
|
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||||
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.instantMixApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||||
|
import org.jellyfin.sdk.api.sockets.subscribe
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
|
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Singleton
|
||||||
|
class MusicService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
|
@param:DefaultCoroutineScope private val defaultScope: CoroutineScope,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
||||||
|
val state: StateFlow<MusicServiceState> = _state
|
||||||
|
|
||||||
|
val player: Player by lazy {
|
||||||
|
ExoPlayer
|
||||||
|
.Builder(context)
|
||||||
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(
|
||||||
|
OkHttpDataSource.Factory(authOkHttpClient),
|
||||||
|
),
|
||||||
|
).build()
|
||||||
|
.also {
|
||||||
|
it.setAudioAttributes(
|
||||||
|
AudioAttributes
|
||||||
|
.Builder()
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
|
.build(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
it.addListener(MusicPlayerListener(it, _state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
var mediaSession: MediaSession? = null
|
||||||
|
private set
|
||||||
|
private var activityTracker: TrackActivityPlaybackListener? = null
|
||||||
|
private var websocketJob: Job? = null
|
||||||
|
|
||||||
|
suspend fun start() {
|
||||||
|
if (mediaSession == null) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (mediaSession == null) {
|
||||||
|
Timber.i("Starting music MediaSession")
|
||||||
|
mediaSession = MediaSession.Builder(context, player).build()
|
||||||
|
activityTracker =
|
||||||
|
TrackActivityPlaybackListener(api, player) {
|
||||||
|
state.value.currentItemId?.let { itemId ->
|
||||||
|
PlaybackItemState(
|
||||||
|
itemId = itemId,
|
||||||
|
playMethod = PlayMethod.DIRECT_PLAY,
|
||||||
|
// playSessionId = mediaSession?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.also { player.addListener(it) }
|
||||||
|
websocketJob = subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMain {
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stop() {
|
||||||
|
mutex.withLock {
|
||||||
|
Timber.i("Stopping music")
|
||||||
|
if (mediaSession == null) {
|
||||||
|
Timber.w("Stopping but no MediaSession")
|
||||||
|
}
|
||||||
|
mediaSession?.release()
|
||||||
|
mediaSession = null
|
||||||
|
onMain {
|
||||||
|
websocketJob?.cancel()
|
||||||
|
websocketJob = null
|
||||||
|
activityTracker?.let {
|
||||||
|
it.release()
|
||||||
|
player.removeListener(it)
|
||||||
|
}
|
||||||
|
activityTracker = null
|
||||||
|
player.stop()
|
||||||
|
player.setMediaItems(emptyList())
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
MusicServiceState.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches instant mix items, replaces the queue, and begins playback
|
||||||
|
*/
|
||||||
|
suspend fun startInstantMix(itemId: UUID) =
|
||||||
|
loading {
|
||||||
|
val items =
|
||||||
|
api.instantMixApi
|
||||||
|
.getInstantMixFromItem(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
itemId = itemId,
|
||||||
|
limit = 200,
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
).content.items
|
||||||
|
.map { BaseItem(it, false) }
|
||||||
|
setQueue(items, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the queue with the given list and starting playing the song as startIndex as soon as its ready
|
||||||
|
*
|
||||||
|
* Fetches each item in a blocking way and adds to the queue
|
||||||
|
*/
|
||||||
|
suspend fun setQueue(
|
||||||
|
items: BlockingList<BaseItem?>,
|
||||||
|
startIndex: Int,
|
||||||
|
shuffled: Boolean,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
player.setMediaItems(emptyList())
|
||||||
|
player.shuffleModeEnabled = shuffled
|
||||||
|
}
|
||||||
|
start()
|
||||||
|
addAllToQueue(items, startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setQueue(
|
||||||
|
items: List<BaseItem>,
|
||||||
|
shuffled: Boolean,
|
||||||
|
) {
|
||||||
|
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
|
||||||
|
val mediaItems =
|
||||||
|
items
|
||||||
|
.filter { it.type == BaseItemKind.AUDIO }
|
||||||
|
.map(::convert)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
player.setMediaItems(mediaItems)
|
||||||
|
player.shuffleModeEnabled = shuffled
|
||||||
|
updateQueueSize()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addToQueue(
|
||||||
|
item: BaseItem,
|
||||||
|
index: Int? = null,
|
||||||
|
) {
|
||||||
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
|
val mediaItem = convert(item)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (index != null) {
|
||||||
|
player.addMediaItem(index, mediaItem)
|
||||||
|
} else {
|
||||||
|
player.addMediaItem(mediaItem)
|
||||||
|
}
|
||||||
|
updateQueueSize()
|
||||||
|
if (player.mediaItemCount == 1) {
|
||||||
|
// Start playing if this was the first time added
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addAllToQueue(
|
||||||
|
list: BlockingList<BaseItem?>,
|
||||||
|
startIndex: Int,
|
||||||
|
) = loading {
|
||||||
|
var remaining = startIndex
|
||||||
|
list.indices
|
||||||
|
.chunked(25)
|
||||||
|
.forEach {
|
||||||
|
val mediaItems =
|
||||||
|
it.mapNotNull {
|
||||||
|
if (remaining == 0) {
|
||||||
|
list
|
||||||
|
.getBlocking(it)
|
||||||
|
?.takeIf { it.type == BaseItemKind.AUDIO }
|
||||||
|
?.let(::convert)
|
||||||
|
} else {
|
||||||
|
Timber.v("Skipping $remaining")
|
||||||
|
remaining--
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMain { player.addMediaItems(mediaItems) }
|
||||||
|
}
|
||||||
|
updateQueueSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convert(audio: BaseItem): MediaItem {
|
||||||
|
val url =
|
||||||
|
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||||
|
itemId = audio.id,
|
||||||
|
container =
|
||||||
|
listOf(
|
||||||
|
Codec.Audio.OPUS,
|
||||||
|
Codec.Audio.MP3,
|
||||||
|
Codec.Audio.AAC,
|
||||||
|
Codec.Audio.FLAC,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val imageUrl =
|
||||||
|
audio.data.albumId?.let { albumId ->
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
itemId = albumId,
|
||||||
|
imageType = ImageType.PRIMARY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return MediaItem
|
||||||
|
.Builder()
|
||||||
|
.setUri(url)
|
||||||
|
.setMediaId(audio.id.toServerString())
|
||||||
|
.setTag(AudioItem.from(audio, imageUrl))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateQueueSize() {
|
||||||
|
// val ids =
|
||||||
|
// withContext(Dispatchers.Default) {
|
||||||
|
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
|
||||||
|
// }
|
||||||
|
val timeline = onMain { player.currentTimeline }
|
||||||
|
val window = Timeline.Window()
|
||||||
|
val ids =
|
||||||
|
(0..<timeline.windowCount)
|
||||||
|
.map {
|
||||||
|
timeline.getWindow(it, window)
|
||||||
|
window.mediaItem.mediaId.toUUID()
|
||||||
|
}.toSet()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
queueVersion = it.queueVersion + 1,
|
||||||
|
queueSize = player.mediaItemCount,
|
||||||
|
queuedIds = ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun moveQueue(
|
||||||
|
index: Int,
|
||||||
|
direction: MoveDirection,
|
||||||
|
) = withContext(Dispatchers.Main) {
|
||||||
|
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
|
||||||
|
updateQueueSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun moveQueue(
|
||||||
|
index: Int,
|
||||||
|
newIndex: Int,
|
||||||
|
) = withContext(Dispatchers.Main) {
|
||||||
|
player.moveMediaItem(index, newIndex)
|
||||||
|
updateQueueSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun playIndex(index: Int) {
|
||||||
|
onMain {
|
||||||
|
player.seekTo(index, 0L)
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
// MusicPlayerListener will update state
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun playNext(song: BaseItem) {
|
||||||
|
val mediaItem = convert(song)
|
||||||
|
onMain {
|
||||||
|
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
|
||||||
|
if (player.mediaItemCount == 1) {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateQueueSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeFromQueue(index: Int) {
|
||||||
|
onMain { player.removeMediaItem(index) }
|
||||||
|
updateQueueSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> loading(block: suspend () -> T): T {
|
||||||
|
_state.update { it.copy(loadingState = LoadingState.Loading) }
|
||||||
|
val result = block.invoke()
|
||||||
|
_state.update { it.copy(loadingState = LoadingState.Success) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(): Job =
|
||||||
|
api.webSocket
|
||||||
|
.subscribe<PlaystateMessage>()
|
||||||
|
.onEach { message ->
|
||||||
|
message.data?.let {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
when (it.command) {
|
||||||
|
PlaystateCommand.STOP -> {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.PAUSE -> {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.UNPAUSE -> {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.NEXT_TRACK -> {
|
||||||
|
player.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.PREVIOUS_TRACK -> {
|
||||||
|
player.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.SEEK -> {
|
||||||
|
it.seekPositionTicks?.ticks?.let {
|
||||||
|
player.seekTo(
|
||||||
|
it.inWholeMilliseconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.REWIND -> {
|
||||||
|
player.seekBack(10.seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.FAST_FORWARD -> {
|
||||||
|
player.seekForward(30.seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.PLAY_PAUSE -> {
|
||||||
|
if (player.isPlaying) player.pause() else player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.catch { ex ->
|
||||||
|
Timber.e(ex, "Error in websocket subscription")
|
||||||
|
}.launchIn(defaultScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class MusicServiceState(
|
||||||
|
val queueVersion: Long,
|
||||||
|
val queueSize: Int,
|
||||||
|
val currentIndex: Int,
|
||||||
|
val currentItemId: UUID?,
|
||||||
|
val status: NowPlayingStatus,
|
||||||
|
val currentItemTitle: String?,
|
||||||
|
val loadingState: LoadingState = LoadingState.Pending,
|
||||||
|
val queuedIds: Set<UUID>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY =
|
||||||
|
MusicServiceState(
|
||||||
|
0L,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
NowPlayingStatus.IDLE,
|
||||||
|
null,
|
||||||
|
LoadingState.Pending,
|
||||||
|
emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NowPlayingStatus {
|
||||||
|
PLAYING,
|
||||||
|
PAUSED,
|
||||||
|
IDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to [Player] events and updates the [StateFlow]
|
||||||
|
*/
|
||||||
|
private class MusicPlayerListener(
|
||||||
|
private val player: Player,
|
||||||
|
private val state: MutableStateFlow<MusicServiceState>,
|
||||||
|
) : Player.Listener {
|
||||||
|
init {
|
||||||
|
Timber.v("MusicPlayerListener init")
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
queueSize = player.mediaItemCount,
|
||||||
|
currentIndex = player.currentMediaItemIndex,
|
||||||
|
status = if (player.isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.IDLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
Timber.v("MusicPlayerListener onIsPlayingChanged")
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
status = if (isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.PAUSED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaItemTransition(
|
||||||
|
mediaItem: MediaItem?,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
Timber.v("MusicPlayerListener onMediaItemTransition")
|
||||||
|
updateCurrentIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTimelineChanged(
|
||||||
|
timeline: Timeline,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
// Timber.v("MusicPlayerListener onTimelineChanged")
|
||||||
|
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
|
||||||
|
updateCurrentIndex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCurrentIndex() {
|
||||||
|
state.update { state ->
|
||||||
|
player.currentMediaItemIndex.takeIf { it >= 0 }?.let { currentMediaItemIndex ->
|
||||||
|
if (currentMediaItemIndex in (0..<player.mediaItemCount)) {
|
||||||
|
val item =
|
||||||
|
player.getMediaItemAt(currentMediaItemIndex).localConfiguration?.tag as? AudioItem
|
||||||
|
state.copy(
|
||||||
|
currentIndex = currentMediaItemIndex,
|
||||||
|
currentItemId = player.getMediaItemAt(currentMediaItemIndex).mediaId.toUUIDOrNull(),
|
||||||
|
currentItemTitle = item?.title,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
} ?: state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberQueue(
|
||||||
|
player: Player,
|
||||||
|
queueVersion: Long,
|
||||||
|
queueSize: Int,
|
||||||
|
): List<AudioItem> =
|
||||||
|
remember(queueVersion, queueSize) {
|
||||||
|
object : AbstractList<AudioItem>() {
|
||||||
|
override val size: Int
|
||||||
|
get() = player.mediaItemCount
|
||||||
|
|
||||||
|
override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
|
|
@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
@ -33,6 +36,7 @@ import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class NavDrawerService
|
class NavDrawerService
|
||||||
|
|
@ -44,6 +48,7 @@ class NavDrawerService
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val serverPreferencesDao: ServerPreferencesDao,
|
private val serverPreferencesDao: ServerPreferencesDao,
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
private val musicService: MusicService,
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
||||||
val state: StateFlow<NavDrawerItemState> = _state
|
val state: StateFlow<NavDrawerItemState> = _state
|
||||||
|
|
@ -73,6 +78,40 @@ class NavDrawerService
|
||||||
.onEach { discoverActive ->
|
.onEach { discoverActive ->
|
||||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||||
}.launchIn(coroutineScope)
|
}.launchIn(coroutineScope)
|
||||||
|
coroutineScope.launchDefault {
|
||||||
|
musicService.state.collectLatest { music ->
|
||||||
|
Timber.v("MusicService updated")
|
||||||
|
when (music.status) {
|
||||||
|
NowPlayingStatus.PLAYING -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
nowPlayingEnabled = true,
|
||||||
|
nowPlayingTitle = music.currentItemTitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NowPlayingStatus.IDLE -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
nowPlayingEnabled = false,
|
||||||
|
nowPlayingTitle = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NowPlayingStatus.PAUSED -> {
|
||||||
|
delay(2.hours)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
nowPlayingEnabled = false,
|
||||||
|
nowPlayingTitle = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getAllUserLibraries(
|
suspend fun getAllUserLibraries(
|
||||||
|
|
@ -185,9 +224,11 @@ data class NavDrawerItemState(
|
||||||
val items: List<NavDrawerItem>,
|
val items: List<NavDrawerItem>,
|
||||||
val moreItems: List<NavDrawerItem>,
|
val moreItems: List<NavDrawerItem>,
|
||||||
val discoverEnabled: Boolean,
|
val discoverEnabled: Boolean,
|
||||||
|
val nowPlayingEnabled: Boolean,
|
||||||
|
val nowPlayingTitle: String?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
|
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -14,7 +14,7 @@ import javax.inject.Singleton
|
||||||
class NavigationManager
|
class NavigationManager
|
||||||
@Inject
|
@Inject
|
||||||
constructor() {
|
constructor() {
|
||||||
var backStack: MutableList<NavKey> = mutableListOf()
|
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import javax.inject.Singleton
|
||||||
@Singleton
|
@Singleton
|
||||||
class SetupNavigationManager
|
class SetupNavigationManager
|
||||||
@Inject
|
@Inject
|
||||||
constructor() {
|
constructor(
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
) {
|
||||||
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +27,9 @@ class SetupNavigationManager
|
||||||
fun navigateTo(destination: SetupDestination) {
|
fun navigateTo(destination: SetupDestination) {
|
||||||
backStack[0] = destination
|
backStack[0] = destination
|
||||||
log()
|
log()
|
||||||
|
if (destination !is SetupDestination.AppContent) {
|
||||||
|
navigationManager.reloadHome()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun log() {
|
private fun log() {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.core.DataStoreFactory
|
import androidx.datastore.core.DataStoreFactory
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.dataStoreFile
|
import androidx.datastore.dataStoreFile
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.github.damontecres.wholphin.data.AppDatabase
|
import com.github.damontecres.wholphin.data.AppDatabase
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
|
|
@ -85,4 +88,13 @@ object DatabaseModule {
|
||||||
produceNewData = { AppPreferences.getDefaultInstance() },
|
produceNewData = { AppPreferences.getDefaultInstance() },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun keyValueDataStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { context.preferencesDataStoreFile("key_value") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
|
|
@ -434,3 +437,18 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
||||||
fun Int?.gt(that: Int) = (this ?: 0) > that
|
fun Int?.gt(that: Int) = (this ?: 0) > that
|
||||||
|
|
||||||
fun Int?.lt(that: Int) = (this ?: 0) < that
|
fun Int?.lt(that: Int) = (this ?: 0) < that
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplifies endlessly collecting a flow
|
||||||
|
*/
|
||||||
|
fun <T> Flow<T>.collectLatestIn(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
action: suspend (value: T) -> Unit,
|
||||||
|
) {
|
||||||
|
scope.launchDefault { this@collectLatestIn.collectLatest(action) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easy way to combine two flows into a [Pair]
|
||||||
|
*/
|
||||||
|
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
private var timeFormatter: DateTimeFormatter =
|
||||||
val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
|
||||||
|
|
||||||
|
fun getTimeFormatter(): DateTimeFormatter {
|
||||||
|
if (timeFormatter.locale != Locale.getDefault()) {
|
||||||
|
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
|
||||||
|
}
|
||||||
|
return timeFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateFormatter: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())
|
||||||
|
|
||||||
|
fun getDateFormatter(): DateTimeFormatter {
|
||||||
|
if (dateFormatter.locale != Locale.getDefault()) {
|
||||||
|
dateFormatter = dateFormatter.withLocale(Locale.getDefault())
|
||||||
|
}
|
||||||
|
return dateFormatter
|
||||||
|
}
|
||||||
|
|
||||||
// TODO server returns in UTC, but sdk converts to local time
|
// TODO server returns in UTC, but sdk converts to local time
|
||||||
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
|
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
|
||||||
|
|
@ -25,9 +43,9 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy"
|
||||||
/**
|
/**
|
||||||
* Format a [LocalDateTime] as `Aug 24, 2000`
|
* Format a [LocalDateTime] as `Aug 24, 2000`
|
||||||
*/
|
*/
|
||||||
fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime)
|
fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
|
||||||
|
|
||||||
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
|
fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime)
|
||||||
|
|
||||||
fun toLocalDate(date: String?): LocalDate? =
|
fun toLocalDate(date: String?): LocalDate? =
|
||||||
date?.let {
|
date?.let {
|
||||||
|
|
@ -109,30 +127,25 @@ fun abbreviateNumber(number: Int): String {
|
||||||
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
|
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
|
||||||
}
|
}
|
||||||
|
|
||||||
val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB")
|
val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB")
|
||||||
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
|
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
|
||||||
|
|
||||||
/**
|
|
||||||
* Format bytes
|
|
||||||
*/
|
|
||||||
fun formatBytes(
|
|
||||||
bytes: Int,
|
|
||||||
suffixes: List<String> = byteSuffixes,
|
|
||||||
) = formatBytes(bytes.toLong(), suffixes)
|
|
||||||
|
|
||||||
fun formatBytes(
|
fun formatBytes(
|
||||||
bytes: Long,
|
bytes: Long,
|
||||||
suffixes: List<String> = byteSuffixes,
|
suffixes: List<String> = byteSuffixes,
|
||||||
|
divisor: Int = 1024,
|
||||||
): String {
|
): String {
|
||||||
var unit = 0
|
var unit = 0
|
||||||
var count = bytes.toDouble()
|
var count = bytes.toDouble()
|
||||||
while (count >= 1024 && unit + 1 < suffixes.size) {
|
while (count >= divisor && unit + 1 < suffixes.size) {
|
||||||
count /= 1024
|
count /= divisor
|
||||||
unit++
|
unit++
|
||||||
}
|
}
|
||||||
return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit])
|
return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val MediaSegmentType.stringRes: Int
|
val MediaSegmentType.stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
|
|
@ -184,3 +197,45 @@ fun listToDotString(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun formatTypeName(type: BaseItemKind): Int =
|
||||||
|
when (type) {
|
||||||
|
BaseItemKind.MOVIE -> R.string.movies
|
||||||
|
BaseItemKind.SERIES -> R.string.tv_shows
|
||||||
|
BaseItemKind.EPISODE -> R.string.episodes
|
||||||
|
BaseItemKind.VIDEO -> R.string.videos
|
||||||
|
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||||
|
BaseItemKind.PERSON -> R.string.people
|
||||||
|
BaseItemKind.BOX_SET -> R.string.collections
|
||||||
|
BaseItemKind.AUDIO -> TODO()
|
||||||
|
BaseItemKind.CHANNEL -> R.string.channels
|
||||||
|
BaseItemKind.GENRE -> R.string.genres
|
||||||
|
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
|
||||||
|
BaseItemKind.MUSIC_ALBUM -> TODO()
|
||||||
|
BaseItemKind.MUSIC_ARTIST -> TODO()
|
||||||
|
BaseItemKind.MUSIC_GENRE -> TODO()
|
||||||
|
BaseItemKind.MUSIC_VIDEO -> TODO()
|
||||||
|
BaseItemKind.PHOTO -> R.string.photos
|
||||||
|
BaseItemKind.PHOTO_ALBUM -> TODO()
|
||||||
|
BaseItemKind.PROGRAM -> TODO()
|
||||||
|
BaseItemKind.RECORDING -> TODO()
|
||||||
|
BaseItemKind.SEASON -> R.string.tv_seasons
|
||||||
|
BaseItemKind.STUDIO -> R.string.studios
|
||||||
|
BaseItemKind.TRAILER -> R.string.trailers
|
||||||
|
BaseItemKind.TV_CHANNEL -> R.string.channels
|
||||||
|
BaseItemKind.TV_PROGRAM -> TODO()
|
||||||
|
BaseItemKind.USER_ROOT_FOLDER -> TODO()
|
||||||
|
BaseItemKind.USER_VIEW -> TODO()
|
||||||
|
BaseItemKind.YEAR -> TODO()
|
||||||
|
BaseItemKind.AGGREGATE_FOLDER -> TODO()
|
||||||
|
BaseItemKind.AUDIO_BOOK -> TODO()
|
||||||
|
BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
|
||||||
|
BaseItemKind.BOOK -> TODO()
|
||||||
|
BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
|
||||||
|
BaseItemKind.COLLECTION_FOLDER -> TODO()
|
||||||
|
BaseItemKind.FOLDER -> TODO()
|
||||||
|
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
|
||||||
|
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
|
||||||
|
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
|
||||||
import com.github.damontecres.wholphin.ui.dot
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
|
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ fun TimeRemaining(
|
||||||
val now by LocalClock.current.now
|
val now by LocalClock.current.now
|
||||||
val remainingStr =
|
val remainingStr =
|
||||||
remember(remaining, now) {
|
remember(remaining, now) {
|
||||||
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
|
val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
dot()
|
dot()
|
||||||
append(context.getString(R.string.ends_at, endTimeStr))
|
append(context.getString(R.string.ends_at, endTimeStr))
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
|
@ -110,13 +111,14 @@ abstract class RecommendedViewModel(
|
||||||
|
|
||||||
fun update(
|
fun update(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
|
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
block: suspend () -> List<BaseItem>,
|
block: suspend () -> List<BaseItem>,
|
||||||
): Deferred<HomeRowLoadingState> =
|
): Deferred<HomeRowLoadingState> =
|
||||||
viewModelScope.async(Dispatchers.IO) {
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
val titleStr = context.getString(title)
|
val titleStr = context.getString(title)
|
||||||
val row =
|
val row =
|
||||||
try {
|
try {
|
||||||
HomeRowLoadingState.Success(titleStr, block.invoke())
|
HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
HomeRowLoadingState.Error(titleStr, null, ex)
|
HomeRowLoadingState.Error(titleStr, null, ex)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SuggestionService
|
||||||
|
import com.github.damontecres.wholphin.services.SuggestionsResource
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
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.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = RecommendedMusicViewModel.Factory::class)
|
||||||
|
class RecommendedMusicViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
|
private val suggestionService: SuggestionService,
|
||||||
|
@Assisted val parentId: UUID,
|
||||||
|
navigationManager: NavigationManager,
|
||||||
|
favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
mediaReportService: MediaReportService,
|
||||||
|
backdropService: BackdropService,
|
||||||
|
mediaManagementService: MediaManagementService,
|
||||||
|
) : RecommendedViewModel(
|
||||||
|
context,
|
||||||
|
navigationManager,
|
||||||
|
favoriteWatchManager,
|
||||||
|
mediaReportService,
|
||||||
|
backdropService,
|
||||||
|
mediaManagementService,
|
||||||
|
) {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(parentId: UUID): RecommendedMusicViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
override val rows =
|
||||||
|
MutableStateFlow<List<HomeRowLoadingState>>(
|
||||||
|
rowTitles.keys.map {
|
||||||
|
HomeRowLoadingState.Pending(
|
||||||
|
context.getString(it),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
|
val itemsPerRow =
|
||||||
|
preferencesDataStore.data
|
||||||
|
.firstOrNull()
|
||||||
|
?.homePagePreferences
|
||||||
|
?.maxItemsPerRow
|
||||||
|
?: AppPreference.HomePageItems.defaultValue.toInt()
|
||||||
|
|
||||||
|
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||||
|
val viewOptions =
|
||||||
|
HomeRowViewOptions(
|
||||||
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
heightDp = Cards.HEIGHT_EPISODE,
|
||||||
|
showTitles = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
update(R.string.recently_released, viewOptions) {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = parentId,
|
||||||
|
fields = SlimItemFields,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
|
recursive = true,
|
||||||
|
enableUserData = true,
|
||||||
|
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
|
startIndex = 0,
|
||||||
|
limit = itemsPerRow,
|
||||||
|
enableTotalRecordCount = false,
|
||||||
|
)
|
||||||
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
|
}.also(jobs::add)
|
||||||
|
|
||||||
|
update(R.string.recently_added, viewOptions) {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = parentId,
|
||||||
|
fields = SlimItemFields,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
|
recursive = true,
|
||||||
|
enableUserData = true,
|
||||||
|
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
|
startIndex = 0,
|
||||||
|
limit = itemsPerRow,
|
||||||
|
enableTotalRecordCount = false,
|
||||||
|
)
|
||||||
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
|
}.also(jobs::add)
|
||||||
|
|
||||||
|
update(R.string.top_unwatched, viewOptions) {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = parentId,
|
||||||
|
fields = SlimItemFields,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
|
recursive = true,
|
||||||
|
enableUserData = true,
|
||||||
|
isPlayed = false,
|
||||||
|
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
|
startIndex = 0,
|
||||||
|
limit = itemsPerRow,
|
||||||
|
enableTotalRecordCount = false,
|
||||||
|
)
|
||||||
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
|
}.also(jobs::add)
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
suggestionService
|
||||||
|
.getSuggestionsFlow(parentId, BaseItemKind.MUSIC_ALBUM)
|
||||||
|
.collect { resource ->
|
||||||
|
val state =
|
||||||
|
when (resource) {
|
||||||
|
is SuggestionsResource.Loading -> {
|
||||||
|
HomeRowLoadingState.Loading(
|
||||||
|
context.getString(R.string.suggestions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SuggestionsResource.Success -> {
|
||||||
|
HomeRowLoadingState.Success(
|
||||||
|
context.getString(R.string.suggestions),
|
||||||
|
resource.items,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SuggestionsResource.Empty -> {
|
||||||
|
HomeRowLoadingState.Success(
|
||||||
|
context.getString(R.string.suggestions),
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update(R.string.suggestions, state)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Failed to fetch suggestions")
|
||||||
|
update(
|
||||||
|
R.string.suggestions,
|
||||||
|
HomeRowLoadingState.Error(
|
||||||
|
title = context.getString(R.string.suggestions),
|
||||||
|
exception = ex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in 0..<jobs.size) {
|
||||||
|
val result = jobs[i].await()
|
||||||
|
if (result.completed) {
|
||||||
|
Timber.v("First success")
|
||||||
|
loading.setValueOnMain(LoadingState.Success)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(
|
||||||
|
@StringRes title: Int,
|
||||||
|
row: HomeRowLoadingState,
|
||||||
|
): HomeRowLoadingState {
|
||||||
|
rows.update { current ->
|
||||||
|
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val rowTitles =
|
||||||
|
listOf(
|
||||||
|
R.string.recently_released,
|
||||||
|
R.string.recently_added,
|
||||||
|
R.string.suggestions,
|
||||||
|
R.string.top_unwatched,
|
||||||
|
).mapIndexed { index, i -> i to index }.toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecommendedMusic(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
parentId: UUID,
|
||||||
|
onFocusPosition: (RowColumn) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RecommendedMusicViewModel =
|
||||||
|
hiltViewModel<RecommendedMusicViewModel, RecommendedMusicViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(parentId) },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
RecommendedContent(
|
||||||
|
preferences = preferences,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onFocusPosition = onFocusPosition,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||||
import com.github.damontecres.wholphin.ui.formatBytes
|
import com.github.damontecres.wholphin.ui.formatBytes
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
|
@ -116,7 +116,7 @@ fun ItemDetailsDialog(
|
||||||
}
|
}
|
||||||
source.bitrate?.let {
|
source.bitrate?.let {
|
||||||
add(
|
add(
|
||||||
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
bitrateLabel to formatBitrate(it),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
source.runTimeTicks?.let {
|
source.runTimeTicks?.let {
|
||||||
|
|
@ -297,7 +297,7 @@ private fun buildVideoStreamInfo(
|
||||||
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
|
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
|
||||||
add(aspectRatioLabel to aspectRatio)
|
add(aspectRatioLabel to aspectRatio)
|
||||||
}
|
}
|
||||||
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
|
||||||
stream.averageFrameRate?.let {
|
stream.averageFrameRate?.let {
|
||||||
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
|
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
|
||||||
}
|
}
|
||||||
|
|
@ -405,7 +405,7 @@ private fun buildAudioStreamInfo(
|
||||||
stream.channelLayout?.let { add(layoutLabel to it) }
|
stream.channelLayout?.let { add(layoutLabel to it) }
|
||||||
stream.channels?.let { add(channelsLabel to it.toString()) }
|
stream.channels?.let { add(channelsLabel to it.toString()) }
|
||||||
stream.profile?.let { add(profileLabel to it) }
|
stream.profile?.let { add(profileLabel to it) }
|
||||||
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
|
||||||
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
|
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
|
||||||
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,45 @@ val BoxSetSortOptions =
|
||||||
ItemSortBy.RANDOM,
|
ItemSortBy.RANDOM,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val AlbumSortOptions =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.SORT_NAME,
|
||||||
|
ItemSortBy.PREMIERE_DATE,
|
||||||
|
ItemSortBy.DATE_CREATED,
|
||||||
|
ItemSortBy.DATE_PLAYED,
|
||||||
|
ItemSortBy.ALBUM_ARTIST,
|
||||||
|
ItemSortBy.COMMUNITY_RATING,
|
||||||
|
ItemSortBy.CRITIC_RATING,
|
||||||
|
ItemSortBy.RANDOM,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ArtistSortOptions =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.SORT_NAME,
|
||||||
|
ItemSortBy.PREMIERE_DATE,
|
||||||
|
ItemSortBy.DATE_CREATED,
|
||||||
|
ItemSortBy.DATE_PLAYED,
|
||||||
|
ItemSortBy.COMMUNITY_RATING,
|
||||||
|
ItemSortBy.CRITIC_RATING,
|
||||||
|
ItemSortBy.RANDOM,
|
||||||
|
)
|
||||||
|
|
||||||
|
val SongSortOptions =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.SORT_NAME,
|
||||||
|
ItemSortBy.PREMIERE_DATE,
|
||||||
|
ItemSortBy.ALBUM,
|
||||||
|
ItemSortBy.ALBUM_ARTIST,
|
||||||
|
ItemSortBy.ARTIST,
|
||||||
|
ItemSortBy.DATE_CREATED,
|
||||||
|
ItemSortBy.DATE_PLAYED,
|
||||||
|
ItemSortBy.COMMUNITY_RATING,
|
||||||
|
ItemSortBy.CRITIC_RATING,
|
||||||
|
ItemSortBy.PLAY_COUNT,
|
||||||
|
ItemSortBy.RUNTIME,
|
||||||
|
ItemSortBy.RANDOM,
|
||||||
|
)
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun getStringRes(sort: ItemSortBy): Int =
|
fun getStringRes(sort: ItemSortBy): Int =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
|
|
@ -132,5 +171,11 @@ fun getStringRes(sort: ItemSortBy): Int =
|
||||||
|
|
||||||
ItemSortBy.DEFAULT -> R.string.default_track
|
ItemSortBy.DEFAULT -> R.string.default_track
|
||||||
|
|
||||||
|
ItemSortBy.ALBUM -> R.string.album
|
||||||
|
|
||||||
|
ItemSortBy.ALBUM_ARTIST -> R.string.album_artist
|
||||||
|
|
||||||
|
ItemSortBy.ARTIST -> R.string.artist
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Unsupported sort option: $sort")
|
else -> throw IllegalArgumentException("Unsupported sort option: $sort")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -51,7 +52,6 @@ import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
@ -199,6 +199,25 @@ fun <T : CardGridItem> CardGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val jumpToLetter: (Char) -> Unit =
|
||||||
|
remember {
|
||||||
|
{ letter: Char ->
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
val jumpPosition =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
letterPosition.invoke(letter)
|
||||||
|
}
|
||||||
|
Timber.d("Alphabet jump to $jumpPosition")
|
||||||
|
if (jumpPosition >= 0) {
|
||||||
|
pager.getOrNull(jumpPosition)
|
||||||
|
gridState.scrollToItem(jumpPosition)
|
||||||
|
focusOn(jumpPosition)
|
||||||
|
alphabetFocus = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pager.isEmpty()) {
|
if (pager.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|
@ -257,6 +276,12 @@ fun <T : CardGridItem> CardGrid(
|
||||||
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
|
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
|
||||||
jump(-jump1)
|
jump(-jump1)
|
||||||
return@onKeyEvent true
|
return@onKeyEvent true
|
||||||
|
} else if (showLetterButtons && pager.isNotEmpty() &&
|
||||||
|
it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z)
|
||||||
|
) {
|
||||||
|
val letter = it.nativeKeyEvent.unicodeChar.toChar()
|
||||||
|
jumpToLetter.invoke(letter)
|
||||||
|
return@onKeyEvent true
|
||||||
} else {
|
} else {
|
||||||
return@onKeyEvent false
|
return@onKeyEvent false
|
||||||
}
|
}
|
||||||
|
|
@ -372,8 +397,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val context = LocalContext.current
|
val letters = stringResource(R.string.jump_letters)
|
||||||
val letters = context.getString(R.string.jump_letters)
|
|
||||||
// Letters
|
// Letters
|
||||||
val currentLetter =
|
val currentLetter =
|
||||||
remember(focusedIndex) {
|
remember(focusedIndex) {
|
||||||
|
|
@ -383,12 +407,10 @@ fun <T : CardGridItem> CardGrid(
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
?.uppercaseChar()
|
?.uppercaseChar()
|
||||||
?.let {
|
?.let {
|
||||||
if (it >= '0' && it <= '9') {
|
when (it) {
|
||||||
'#'
|
in '0'..'9' -> '#'
|
||||||
} else if (it >= 'A' && it <= 'Z') {
|
in 'A'..'Z' -> it
|
||||||
it
|
else -> null
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?: letters[0]
|
?: letters[0]
|
||||||
|
|
@ -402,21 +424,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
.align(Alignment.CenterVertically)
|
.align(Alignment.CenterVertically)
|
||||||
.padding(start = 16.dp),
|
.padding(start = 16.dp),
|
||||||
// Add end padding to push away from edge
|
// Add end padding to push away from edge
|
||||||
letterClicked = { letter ->
|
letterClicked = jumpToLetter,
|
||||||
scope.launch(ExceptionHandler()) {
|
|
||||||
val jumpPosition =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
letterPosition.invoke(letter)
|
|
||||||
}
|
|
||||||
Timber.d("Alphabet jump to $jumpPosition")
|
|
||||||
if (jumpPosition >= 0) {
|
|
||||||
pager.getOrNull(jumpPosition)
|
|
||||||
gridState.scrollToItem(jumpPosition)
|
|
||||||
focusOn(jumpPosition)
|
|
||||||
alphabetFocus = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
|
||||||
|
|
||||||
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.Modifier
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
|
|
||||||
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
|
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CollectionFolderBoxSet(
|
|
||||||
preferences: UserPreferences,
|
|
||||||
itemId: UUID,
|
|
||||||
recursive: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
|
||||||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
|
||||||
playEnabled: Boolean = false,
|
|
||||||
) {
|
|
||||||
var showHeader by remember { mutableStateOf(true) }
|
|
||||||
CollectionFolderGrid(
|
|
||||||
preferences = preferences,
|
|
||||||
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
|
|
||||||
itemId = itemId,
|
|
||||||
initialFilter = filter,
|
|
||||||
showTitle = showHeader,
|
|
||||||
recursive = recursive,
|
|
||||||
sortOptions = BoxSetSortOptions,
|
|
||||||
initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
|
|
||||||
modifier = modifier,
|
|
||||||
positionCallback = { columns, position ->
|
|
||||||
showHeader = position < columns
|
|
||||||
},
|
|
||||||
defaultViewOptions = ViewOptionsPoster,
|
|
||||||
playEnabled = playEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
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.MusicService
|
||||||
|
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.RecommendedMusic
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
||||||
|
import com.github.damontecres.wholphin.ui.data.AlbumSortOptions
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ArtistSortOptions
|
||||||
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CollectionFolderMusicViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val musicService: MusicService,
|
||||||
|
) : ViewModel() {
|
||||||
|
fun play(item: BaseItem) {
|
||||||
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.setQueue(listOf(item), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionFolderMusic(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
destination: Destination.MediaItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CollectionFolderMusicViewModel = hiltViewModel(),
|
||||||
|
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val rememberedTabIndex =
|
||||||
|
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||||
|
|
||||||
|
val tabs =
|
||||||
|
listOf(
|
||||||
|
stringResource(R.string.recommended),
|
||||||
|
stringResource(R.string.albums),
|
||||||
|
stringResource(R.string.artists),
|
||||||
|
stringResource(R.string.genres),
|
||||||
|
stringResource(R.string.songs),
|
||||||
|
)
|
||||||
|
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||||
|
|
||||||
|
val firstTabFocusRequester = remember { FocusRequester() }
|
||||||
|
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||||
|
|
||||||
|
LaunchedEffect(selectedTabIndex) {
|
||||||
|
logTab("music", selectedTabIndex)
|
||||||
|
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||||
|
preferencesViewModel.backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
|
||||||
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
showHeader,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
) {
|
||||||
|
TabRow(
|
||||||
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(vertical = 16.dp)
|
||||||
|
.focusRequester(firstTabFocusRequester),
|
||||||
|
tabs = tabs,
|
||||||
|
onClick = { selectedTabIndex = it },
|
||||||
|
focusRequesters = tabFocusRequesters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (selectedTabIndex) {
|
||||||
|
// Recommended
|
||||||
|
0 -> {
|
||||||
|
RecommendedMusic(
|
||||||
|
preferences = preferences,
|
||||||
|
parentId = destination.itemId,
|
||||||
|
onFocusPosition = { pos ->
|
||||||
|
showHeader = pos.row < 1
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums
|
||||||
|
1 -> {
|
||||||
|
CollectionFolderGrid(
|
||||||
|
preferences = preferences,
|
||||||
|
onClickItem = { _, item ->
|
||||||
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
},
|
||||||
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_albums",
|
||||||
|
initialFilter =
|
||||||
|
CollectionFolderFilter(
|
||||||
|
filter =
|
||||||
|
GetItemsFilter(
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showTitle = false,
|
||||||
|
recursive = true,
|
||||||
|
sortOptions = AlbumSortOptions,
|
||||||
|
defaultViewOptions = ViewOptionsSquare,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
positionCallback = { columns, position ->
|
||||||
|
showHeader = position < columns
|
||||||
|
},
|
||||||
|
playEnabled = true,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artists
|
||||||
|
2 -> {
|
||||||
|
CollectionFolderGrid(
|
||||||
|
preferences = preferences,
|
||||||
|
onClickItem = { _, item ->
|
||||||
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
},
|
||||||
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_artists",
|
||||||
|
initialFilter =
|
||||||
|
CollectionFolderFilter(
|
||||||
|
filter =
|
||||||
|
GetItemsFilter(
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showTitle = false,
|
||||||
|
recursive = true,
|
||||||
|
sortOptions = ArtistSortOptions,
|
||||||
|
defaultViewOptions = ViewOptionsSquare,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
positionCallback = { columns, position ->
|
||||||
|
showHeader = position < columns
|
||||||
|
},
|
||||||
|
playEnabled = false,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genres
|
||||||
|
3 -> {
|
||||||
|
GenreCardGrid(
|
||||||
|
itemId = destination.itemId,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Songs
|
||||||
|
4 -> {
|
||||||
|
CollectionFolderGrid(
|
||||||
|
preferences = preferences,
|
||||||
|
onClickItem = { _, item ->
|
||||||
|
viewModel.play(item)
|
||||||
|
},
|
||||||
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_songs",
|
||||||
|
initialFilter =
|
||||||
|
CollectionFolderFilter(
|
||||||
|
filter =
|
||||||
|
GetItemsFilter(
|
||||||
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showTitle = false,
|
||||||
|
recursive = true,
|
||||||
|
sortOptions = SongSortOptions,
|
||||||
|
defaultViewOptions = ViewOptionsSquare,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
positionCallback = { columns, position ->
|
||||||
|
showHeader = position < columns
|
||||||
|
},
|
||||||
|
playEnabled = true,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -170,6 +170,38 @@ fun buildMoreDialogItems(
|
||||||
actions.onClickFavorite.invoke(item.id, !favorite)
|
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 {
|
seriesId?.let {
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
|
@ -16,11 +17,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
@ -44,8 +45,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.ListItem
|
import androidx.tv.material3.ListItem
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -60,12 +61,18 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
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.MusicServiceState
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
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.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -73,156 +80,232 @@ import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
import com.github.damontecres.wholphin.ui.components.FilterByButton
|
import com.github.damontecres.wholphin.ui.components.FilterByButton
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.SortByButton
|
import com.github.damontecres.wholphin.ui.components.SortByButton
|
||||||
|
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.BoxSetSortOptions
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
|
||||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
|
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
|
||||||
class PlaylistViewModel
|
class PlaylistViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
|
musicService: MusicService,
|
||||||
|
mediaManagementService: MediaManagementService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||||
) : ItemViewModel(api) {
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val mediaReportService: MediaReportService,
|
||||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
@Assisted itemId: UUID,
|
||||||
val filterAndSort =
|
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
|
||||||
MutableStateFlow<FilterAndSort>(
|
@AssistedFactory
|
||||||
FilterAndSort(
|
interface Factory {
|
||||||
filter = GetItemsFilter(),
|
fun create(itemId: UUID): PlaylistViewModel
|
||||||
sortAndDirection =
|
}
|
||||||
SortAndDirection(
|
|
||||||
|
val state = MutableStateFlow(PlaylistDetailsState())
|
||||||
|
val musicState = musicService.state
|
||||||
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
try {
|
||||||
|
val playlist =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
state.update { it.copy(playlist = playlist) }
|
||||||
|
val libraryDisplayInfo =
|
||||||
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
libraryDisplayInfoDao.getItem(user, itemId)
|
||||||
|
}
|
||||||
|
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
|
||||||
|
val sortAndDirection =
|
||||||
|
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
|
||||||
ItemSortBy.DEFAULT,
|
ItemSortBy.DEFAULT,
|
||||||
SortOrder.ASCENDING,
|
SortOrder.ASCENDING,
|
||||||
),
|
)
|
||||||
),
|
loadItems(filter, sortAndDirection).join()
|
||||||
)
|
determineMediaType()
|
||||||
|
} catch (ex: Exception) {
|
||||||
fun init(playlistId: UUID) {
|
Timber.e(ex, "Error fetching playlist %s", itemId)
|
||||||
loading.value = LoadingState.Loading
|
state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
viewModelScope.launch(
|
}
|
||||||
Dispatchers.IO +
|
|
||||||
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
|
|
||||||
) {
|
|
||||||
val playlist = fetchItem(playlistId)
|
|
||||||
val libraryDisplayInfo =
|
|
||||||
serverRepository.currentUser.value?.let { user ->
|
|
||||||
libraryDisplayInfoDao.getItem(user, itemId)
|
|
||||||
}
|
|
||||||
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
|
|
||||||
val sortAndDirection =
|
|
||||||
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
|
|
||||||
ItemSortBy.DEFAULT,
|
|
||||||
SortOrder.ASCENDING,
|
|
||||||
)
|
|
||||||
loadItems(filter, sortAndDirection)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadItems(
|
fun loadItems(
|
||||||
filter: GetItemsFilter,
|
filter: GetItemsFilter,
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
) {
|
) = viewModelScope.launchIO {
|
||||||
viewModelScope.launchIO {
|
backdropService.clearBackdrop()
|
||||||
backdropService.clearBackdrop()
|
state.update {
|
||||||
loading.setValueOnMain(LoadingState.Loading)
|
it.copy(
|
||||||
this@PlaylistViewModel.filterAndSort.update {
|
loading = LoadingState.Loading,
|
||||||
FilterAndSort(filter, sortAndDirection)
|
filterAndSort = FilterAndSort(filter, sortAndDirection),
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser.value?.let { user ->
|
||||||
val playlistId = item.value!!.id
|
viewModelScope.launchIO {
|
||||||
viewModelScope.launchIO {
|
val libraryDisplayInfo =
|
||||||
val libraryDisplayInfo =
|
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
||||||
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
filter = filter,
|
||||||
filter = filter,
|
sort = sortAndDirection.sort,
|
||||||
|
direction = sortAndDirection.direction,
|
||||||
|
)
|
||||||
|
?: LibraryDisplayInfo(
|
||||||
|
userId = user.rowId,
|
||||||
|
itemId = itemId.toServerString(),
|
||||||
sort = sortAndDirection.sort,
|
sort = sortAndDirection.sort,
|
||||||
direction = sortAndDirection.direction,
|
direction = sortAndDirection.direction,
|
||||||
|
filter = filter,
|
||||||
|
viewOptions = null,
|
||||||
)
|
)
|
||||||
?: LibraryDisplayInfo(
|
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
|
||||||
userId = user.rowId,
|
}
|
||||||
itemId = itemId,
|
|
||||||
sort = sortAndDirection.sort,
|
|
||||||
direction = sortAndDirection.direction,
|
|
||||||
filter = filter,
|
|
||||||
viewOptions = null,
|
|
||||||
)
|
|
||||||
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
val request =
|
val request =
|
||||||
filter.applyTo(
|
filter.applyTo(
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = playlistId,
|
parentId = itemId,
|
||||||
userId = user.id,
|
userId = user.id,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
sortBy = listOf(sortAndDirection.sort),
|
sortBy = listOf(sortAndDirection.sort),
|
||||||
sortOrder = listOf(sortAndDirection.direction),
|
sortOrder = listOf(sortAndDirection.direction),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val pager =
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
).init()
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
items = pager,
|
||||||
|
loading = LoadingState.Success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching playlist %s", itemId)
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
items = emptyList(),
|
||||||
|
loading = LoadingState.Error(ex),
|
||||||
)
|
)
|
||||||
try {
|
|
||||||
val pager =
|
|
||||||
ApiRequestPager(
|
|
||||||
api,
|
|
||||||
request,
|
|
||||||
GetItemsRequestHandler,
|
|
||||||
viewModelScope,
|
|
||||||
).init()
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
items.value = pager
|
|
||||||
loading.value = LoadingState.Success
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Error fetching playlist %s", itemId)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
items.value = listOf()
|
|
||||||
loading.value = LoadingState.Error(ex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method tries to determine the [MediaType] of a playlist
|
||||||
|
*
|
||||||
|
* In theory, the server will set the type, but sometime it doesn't
|
||||||
|
*/
|
||||||
|
private suspend fun determineMediaType() {
|
||||||
|
// Use the type the server says
|
||||||
|
var mediaType =
|
||||||
|
state.value.playlist
|
||||||
|
?.data
|
||||||
|
?.mediaType ?: MediaType.UNKNOWN
|
||||||
|
mediaType =
|
||||||
|
if (mediaType == MediaType.UNKNOWN) {
|
||||||
|
// Otherwise, if a most of the list is one type, we can assume that type
|
||||||
|
val pager = (state.value.items as? ApiRequestPager<*>)
|
||||||
|
if (pager != null && pager.size <= 50) {
|
||||||
|
val types =
|
||||||
|
(0..<50.coerceAtMost(pager.size)).groupBy { index ->
|
||||||
|
val pagerItem = pager.getBlocking(index)
|
||||||
|
when (pagerItem?.type) {
|
||||||
|
BaseItemKind.AUDIO -> MediaType.AUDIO
|
||||||
|
|
||||||
|
BaseItemKind.VIDEO,
|
||||||
|
BaseItemKind.EPISODE,
|
||||||
|
BaseItemKind.MOVIE,
|
||||||
|
BaseItemKind.BOX_SET,
|
||||||
|
-> MediaType.VIDEO
|
||||||
|
|
||||||
|
else -> MediaType.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (types.keys.size == 1) {
|
||||||
|
types.keys.first()
|
||||||
|
} else {
|
||||||
|
MediaType.UNKNOWN
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MediaType.UNKNOWN
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mediaType
|
||||||
|
}
|
||||||
|
Timber.d("mediaType=%s", mediaType)
|
||||||
|
state.update {
|
||||||
|
it.copy(mediaType = mediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
FilterUtils.getFilterOptionValues(
|
FilterUtils.getFilterOptionValues(
|
||||||
api,
|
api,
|
||||||
serverRepository.currentUser.value?.id,
|
serverRepository.currentUser.value?.id,
|
||||||
itemUuid,
|
itemId,
|
||||||
filterOption,
|
filterOption,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -231,6 +314,24 @@ class PlaylistViewModel
|
||||||
backdropService.submit(item)
|
backdropService.submit(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setWatched(
|
||||||
|
itemId: UUID,
|
||||||
|
played: Boolean,
|
||||||
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
|
favoriteWatchManager.setWatched(itemId, played)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFavorite(
|
||||||
|
itemId: UUID,
|
||||||
|
favorite: Boolean,
|
||||||
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMediaReport(itemId: UUID) {
|
||||||
|
viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -239,52 +340,111 @@ data class FilterAndSort(
|
||||||
val sortAndDirection: SortAndDirection,
|
val sortAndDirection: SortAndDirection,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PlaylistDetailsState(
|
||||||
|
val playlist: BaseItem? = null,
|
||||||
|
val mediaType: MediaType = MediaType.UNKNOWN,
|
||||||
|
val items: List<BaseItem?> = emptyList(),
|
||||||
|
val filterAndSort: FilterAndSort =
|
||||||
|
FilterAndSort(
|
||||||
|
filter = GetItemsFilter(),
|
||||||
|
sortAndDirection =
|
||||||
|
SortAndDirection(
|
||||||
|
ItemSortBy.DEFAULT,
|
||||||
|
SortOrder.ASCENDING,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
val loading: LoadingState = LoadingState.Pending,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistDetails(
|
fun PlaylistDetails(
|
||||||
|
preferences: UserPreferences,
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlaylistViewModel = hiltViewModel(),
|
viewModel: PlaylistViewModel =
|
||||||
|
hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(destination.itemId) },
|
||||||
|
),
|
||||||
|
addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
val state by viewModel.state.collectAsState()
|
||||||
viewModel.init(destination.itemId)
|
val musicState by viewModel.musicState.collectAsState()
|
||||||
}
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
|
||||||
val playlist by viewModel.item.observeAsState(null)
|
|
||||||
val items by viewModel.items.observeAsState(listOf())
|
|
||||||
val filterAndSort by viewModel.filterAndSort.collectAsState()
|
|
||||||
|
|
||||||
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var longClickDialog by remember { mutableStateOf<DialogParams?>(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)
|
||||||
|
|
||||||
val goToString = stringResource(R.string.go_to)
|
fun play(
|
||||||
val playFromHereString = stringResource(R.string.play_from_here)
|
index: Int,
|
||||||
|
item: BaseItem,
|
||||||
|
shuffle: Boolean,
|
||||||
|
mediaTypeOverride: MediaType? = null,
|
||||||
|
) {
|
||||||
|
when (mediaTypeOverride ?: state.mediaType) {
|
||||||
|
MediaType.VIDEO -> {
|
||||||
|
viewModel.navigationManager.navigateTo(
|
||||||
|
Destination.PlaybackList(
|
||||||
|
itemId = destination.itemId,
|
||||||
|
startIndex = index,
|
||||||
|
shuffle = shuffle,
|
||||||
|
filter = state.filterAndSort.filter,
|
||||||
|
sortAndDirection = state.filterAndSort.sortAndDirection,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaType.AUDIO -> {
|
||||||
|
viewModel.play(item, index, shuffle)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
showConfirmTypeDialog = Triple(index, item, shuffle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
|
val musicMoreActions =
|
||||||
|
MusicMoreDialogActions(
|
||||||
|
onNavigate = { 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) },
|
||||||
|
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onClickRemoveFromQueue = {},
|
||||||
|
onClickDelete = { showDeleteDialog = it },
|
||||||
|
)
|
||||||
|
val moreActions =
|
||||||
|
MoreDialogActions(
|
||||||
|
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||||
|
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
|
||||||
|
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel::sendMediaReport,
|
||||||
|
onClickDelete = { showDeleteDialog = it },
|
||||||
|
)
|
||||||
|
|
||||||
PlaylistDetailsContent(
|
PlaylistDetailsContent(
|
||||||
loadingState = loading,
|
loadingState = state.loading,
|
||||||
playlist = playlist,
|
playlist = state.playlist,
|
||||||
items = items,
|
items = state.items,
|
||||||
|
musicState = musicState,
|
||||||
onChangeBackdrop = viewModel::updateBackdrop,
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
onClickIndex = { index, _ ->
|
onClickIndex = { index, item ->
|
||||||
viewModel.navigationManager.navigateTo(
|
play(index, item, false)
|
||||||
Destination.PlaybackList(
|
|
||||||
itemId = destination.itemId,
|
|
||||||
startIndex = index,
|
|
||||||
shuffle = false,
|
|
||||||
filter = filterAndSort.filter,
|
|
||||||
sortAndDirection = filterAndSort.sortAndDirection,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onClickPlay = { shuffle ->
|
onClickPlay = { shuffle ->
|
||||||
viewModel.navigationManager.navigateTo(
|
state.playlist?.let {
|
||||||
Destination.PlaybackList(
|
play(0, it, shuffle)
|
||||||
itemId = destination.itemId,
|
}
|
||||||
startIndex = 0,
|
|
||||||
shuffle = shuffle,
|
|
||||||
filter = filterAndSort.filter,
|
|
||||||
sortAndDirection = filterAndSort.sortAndDirection,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onLongClickIndex = { index, item ->
|
onLongClickIndex = { index, item ->
|
||||||
longClickDialog =
|
longClickDialog =
|
||||||
|
|
@ -292,31 +452,30 @@ fun PlaylistDetails(
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = item.name ?: "",
|
title = item.name ?: "",
|
||||||
items =
|
items =
|
||||||
listOf(
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
DialogItem(
|
buildMoreDialogForMusic(
|
||||||
goToString,
|
context = context,
|
||||||
Icons.Default.ArrowForward,
|
actions = musicMoreActions,
|
||||||
) {
|
item = item,
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
index = index,
|
||||||
},
|
canRemove = false,
|
||||||
DialogItem(
|
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||||
playFromHereString,
|
)
|
||||||
Icons.Default.PlayArrow,
|
} else {
|
||||||
) {
|
buildMoreDialogItemsForHome(
|
||||||
viewModel.navigationManager.navigateTo(
|
context = context,
|
||||||
Destination.PlaybackList(
|
item = item,
|
||||||
itemId = destination.itemId,
|
seriesId = item.data.seriesId,
|
||||||
startIndex = index,
|
playbackPosition = item.playbackPosition,
|
||||||
shuffle = false,
|
watched = item.played,
|
||||||
filter = filterAndSort.filter,
|
favorite = item.favorite,
|
||||||
sortAndDirection = filterAndSort.sortAndDirection,
|
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||||
),
|
actions = moreActions,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
filterAndSort = filterAndSort,
|
filterAndSort = state.filterAndSort,
|
||||||
onFilterAndSortChange = viewModel::loadItems,
|
onFilterAndSortChange = viewModel::loadItems,
|
||||||
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -327,12 +486,46 @@ fun PlaylistDetails(
|
||||||
onDismissRequest = { longClickDialog = null },
|
onDismissRequest = { longClickDialog = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showConfirmTypeDialog?.let { (index, item, shuffle) ->
|
||||||
|
ConfirmMediaTypeDialog(
|
||||||
|
onConfirm = { mediaType -> play(index, item, shuffle, mediaType) },
|
||||||
|
onCancel = { showConfirmTypeDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
PlaylistDialog(
|
||||||
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
state = addPlaylistState,
|
||||||
|
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||||
|
onClick = {
|
||||||
|
addToPlaylistViewModel.addToPlaylist(it.id, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
createEnabled = true,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
elevation = 3.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showDeleteDialog?.let { item ->
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
itemTitle = item.title ?: "",
|
||||||
|
onCancel = { showDeleteDialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.deleteItem(item)
|
||||||
|
showDeleteDialog = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistDetailsContent(
|
fun PlaylistDetailsContent(
|
||||||
playlist: BaseItem?,
|
playlist: BaseItem?,
|
||||||
items: List<BaseItem?>,
|
items: List<BaseItem?>,
|
||||||
|
musicState: MusicServiceState,
|
||||||
onClickIndex: (Int, BaseItem) -> Unit,
|
onClickIndex: (Int, BaseItem) -> Unit,
|
||||||
onLongClickIndex: (Int, BaseItem) -> Unit,
|
onLongClickIndex: (Int, BaseItem) -> Unit,
|
||||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||||
|
|
@ -449,10 +642,18 @@ fun PlaylistDetailsContent(
|
||||||
onLongClickIndex.invoke(index, item)
|
onLongClickIndex.invoke(index, item)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isPlaying =
|
||||||
|
equalsNotNull(
|
||||||
|
musicState.currentItemId,
|
||||||
|
item?.id,
|
||||||
|
),
|
||||||
|
isQueued = item?.id in musicState.queuedIds,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.height(80.dp)
|
|
||||||
.ifElse(
|
.ifElse(
|
||||||
|
item?.type != BaseItemKind.AUDIO,
|
||||||
|
Modifier.height(80.dp),
|
||||||
|
).ifElse(
|
||||||
index == savedIndex,
|
index == savedIndex,
|
||||||
Modifier.focusRequester(focus),
|
Modifier.focusRequester(focus),
|
||||||
).onFocusChanged {
|
).onFocusChanged {
|
||||||
|
|
@ -582,6 +783,8 @@ fun PlaylistItem(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
isPlaying: Boolean = false,
|
||||||
|
isQueued: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|
@ -592,14 +795,12 @@ fun PlaylistItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
text = item?.title ?: "",
|
text = item?.title ?: "",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.enableMarquee(focused),
|
modifier = Modifier.enableMarquee(focused),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
text = item?.subtitle ?: "",
|
text = item?.subtitle ?: "",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.enableMarquee(focused),
|
modifier = Modifier.enableMarquee(focused),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -609,16 +810,18 @@ fun PlaylistItem(
|
||||||
val endTimeStr =
|
val endTimeStr =
|
||||||
remember(item, now) {
|
remember(item, now) {
|
||||||
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
||||||
TimeFormatter.format(endTime)
|
getTimeFormatter().format(endTime)
|
||||||
}
|
}
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = duration.toString(),
|
text = duration.toString(),
|
||||||
)
|
)
|
||||||
Text(
|
if (item.type != BaseItemKind.AUDIO) {
|
||||||
text = stringResource(R.string.ends_at, endTimeStr),
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = stringResource(R.string.ends_at, endTimeStr),
|
||||||
)
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -631,20 +834,71 @@ fun PlaylistItem(
|
||||||
text = "${index + 1}.",
|
text = "${index + 1}.",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
ItemCardImage(
|
if (item?.type == BaseItemKind.AUDIO) {
|
||||||
item = item,
|
MusicQueueMarker(
|
||||||
name = item?.name,
|
isPlaying = isPlaying,
|
||||||
showOverlay = true,
|
isQueued = isQueued,
|
||||||
favorite = item?.data?.userData?.isFavorite ?: false,
|
)
|
||||||
watched = item?.data?.userData?.played ?: false,
|
} else {
|
||||||
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
ItemCardImage(
|
||||||
watchedPercent = 0.0,
|
item = item,
|
||||||
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
name = item?.name,
|
||||||
modifier = Modifier.width(160.dp),
|
showOverlay = true,
|
||||||
useFallbackText = false,
|
favorite = item?.data?.userData?.isFavorite ?: false,
|
||||||
)
|
watched = item?.data?.userData?.played ?: false,
|
||||||
|
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
||||||
|
watchedPercent = 0.0,
|
||||||
|
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||||
|
modifier = Modifier.width(160.dp),
|
||||||
|
useFallbackText = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConfirmMediaTypeDialog(
|
||||||
|
onConfirm: (MediaType) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
BasicDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
properties = DialogProperties(),
|
||||||
|
elevation = 3.dp,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
modifier = Modifier.wrapContentSize(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.play_as_type),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillParentMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
stringRes = R.string.audio,
|
||||||
|
onClick = { onConfirm.invoke(MediaType.AUDIO) },
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
stringRes = R.string.video,
|
||||||
|
onClick = { onConfirm.invoke(MediaType.VIDEO) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.focusRestorer
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||||
|
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
|
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 com.github.damontecres.wholphin.ui.components.FilterByButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.SortByButton
|
||||||
|
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
|
||||||
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionButtons(
|
||||||
|
state: CollectionState,
|
||||||
|
onSortChange: (SortAndDirection) -> Unit,
|
||||||
|
onClickPlayAll: (Boolean) -> Unit,
|
||||||
|
onFilterChange: (GetItemsFilter) -> Unit,
|
||||||
|
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||||
|
onClickViewOptions: () -> Unit,
|
||||||
|
favoriteOnClick: () -> Unit,
|
||||||
|
deleteOnClick: () -> Unit,
|
||||||
|
canDelete: Boolean,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sortOptions = MovieSortOptions
|
||||||
|
val filterOptions = DefaultFilterOptions
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusGroup()
|
||||||
|
.focusRestorer(firstFocus),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.play,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.PlayArrow,
|
||||||
|
onClick = { onClickPlayAll.invoke(false) },
|
||||||
|
modifier = Modifier.focusRequester(firstFocus),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.shuffle,
|
||||||
|
iconStringRes = R.string.fa_shuffle,
|
||||||
|
onClick = { onClickPlayAll.invoke(true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item("favorite") {
|
||||||
|
val favorite = remember(state.collection) { state.collection?.favorite == true }
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||||
|
iconStringRes = R.string.fa_heart,
|
||||||
|
onClick = favoriteOnClick,
|
||||||
|
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canDelete) {
|
||||||
|
item("delete") {
|
||||||
|
DeleteButton(
|
||||||
|
onClick = deleteOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.view_options,
|
||||||
|
iconStringRes = R.string.fa_sliders,
|
||||||
|
onClick = onClickViewOptions,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// More button
|
||||||
|
item("more") {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.more,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.MoreVert,
|
||||||
|
onClick = { moreOnClick.invoke() },
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusGroup(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SortByButton(
|
||||||
|
sortOptions = sortOptions,
|
||||||
|
current = state.sortAndDirection,
|
||||||
|
onSortChange = onSortChange,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
FilterByButton(
|
||||||
|
filterOptions = filterOptions,
|
||||||
|
current = state.itemFilter,
|
||||||
|
onFilterChange = onFilterChange,
|
||||||
|
getPossibleValues = getPossibleFilterValues,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,551 @@
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.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
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||||
|
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.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.data.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
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionDetails(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
itemId: UUID,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CollectionViewModel =
|
||||||
|
hiltViewModel<CollectionViewModel, CollectionViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(itemId) },
|
||||||
|
),
|
||||||
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
var moreDialog by remember { mutableStateOf<DialogParams?>(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,
|
||||||
|
onClickWatch = { itemId, watched ->
|
||||||
|
viewModel.setWatched(itemId, watched, position)
|
||||||
|
},
|
||||||
|
onClickFavorite = { itemId, favorite ->
|
||||||
|
viewModel.setFavorite(itemId, favorite, position)
|
||||||
|
},
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onClickDelete = { item -> showDeleteDialog = Pair(position, item) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = true,
|
||||||
|
title = item.title ?: "",
|
||||||
|
items = dialogItems,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onSortChange =
|
||||||
|
remember {
|
||||||
|
{ sort: SortAndDirection -> viewModel.changeSort(sort) }
|
||||||
|
}
|
||||||
|
val onFilterChange =
|
||||||
|
remember {
|
||||||
|
{ filter: GetItemsFilter -> viewModel.changeFilter(filter) }
|
||||||
|
}
|
||||||
|
val onClickPlay = { _: RowColumn, item: BaseItem ->
|
||||||
|
viewModel.navigate(Destination.Playback(item = item))
|
||||||
|
}
|
||||||
|
val onClickPlayAll =
|
||||||
|
remember {
|
||||||
|
{ shuffle: Boolean ->
|
||||||
|
val dest =
|
||||||
|
Destination.PlaybackList(
|
||||||
|
itemId = itemId,
|
||||||
|
startIndex = 0,
|
||||||
|
shuffle = shuffle,
|
||||||
|
recursive = true,
|
||||||
|
sortAndDirection = state.sortAndDirection,
|
||||||
|
filter = state.itemFilter,
|
||||||
|
)
|
||||||
|
viewModel.navigate(dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onClickViewOptions = remember { { showViewOptionsDialog = true } }
|
||||||
|
|
||||||
|
when (val s = state.loadingState) {
|
||||||
|
is LoadingState.Error -> {
|
||||||
|
ErrorMessage(s, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Loading,
|
||||||
|
LoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Success -> {
|
||||||
|
CollectionDetailsContent(
|
||||||
|
preferences = preferences,
|
||||||
|
state = state,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onLongClickItem = onLongClickItem,
|
||||||
|
onSortChange = onSortChange,
|
||||||
|
onClickPlay = onClickPlay,
|
||||||
|
onClickPlayAll = onClickPlayAll,
|
||||||
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
|
onFilterChange = onFilterChange,
|
||||||
|
getPossibleFilterValues = viewModel::getPossibleFilterValues,
|
||||||
|
letterPosition = viewModel::letterPosition,
|
||||||
|
onClickViewOptions = onClickViewOptions,
|
||||||
|
modifier = modifier,
|
||||||
|
overviewOnClick = {
|
||||||
|
val collection = state.collection!!
|
||||||
|
overviewDialog =
|
||||||
|
ItemDetailsDialogInfo(
|
||||||
|
title = collection.title ?: "",
|
||||||
|
overview = collection.data.overview,
|
||||||
|
genres = collection.data.genres.orEmpty(),
|
||||||
|
files = emptyList(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
favoriteOnClick =
|
||||||
|
remember {
|
||||||
|
{
|
||||||
|
state.collection?.let {
|
||||||
|
viewModel.setFavorite(it.id, !it.favorite, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteOnClick =
|
||||||
|
remember {
|
||||||
|
{
|
||||||
|
state.collection?.let {
|
||||||
|
viewModel.deleteItem(it, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canDelete =
|
||||||
|
remember(state.collection) {
|
||||||
|
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) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = collection.title ?: "",
|
||||||
|
items = items,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showViewOptionsDialog) {
|
||||||
|
CollectionViewOptionsDialog(
|
||||||
|
viewOptions = state.viewOptions,
|
||||||
|
onDismissRequest = { showViewOptionsDialog = false },
|
||||||
|
onViewOptionsChange = viewModel::changeViewOptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
moreDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { moreDialog = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
PlaylistDialog(
|
||||||
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
state = playlistState,
|
||||||
|
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||||
|
onClick = {
|
||||||
|
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
createEnabled = true,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
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
|
||||||
|
fun CollectionDetailsContent(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
state: CollectionState,
|
||||||
|
onClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onSortChange: (SortAndDirection) -> Unit,
|
||||||
|
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onClickPlayAll: (Boolean) -> Unit,
|
||||||
|
onChangeBackdrop: (BaseItem) -> Unit,
|
||||||
|
onFilterChange: (GetItemsFilter) -> Unit,
|
||||||
|
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||||
|
letterPosition: suspend (Char) -> Int,
|
||||||
|
onClickViewOptions: () -> Unit,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
favoriteOnClick: () -> Unit,
|
||||||
|
deleteOnClick: () -> Unit,
|
||||||
|
canDelete: Boolean,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
var itemsContentHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val contentFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var focusedItem by remember { mutableStateOf<BaseItem?>(state.collection) }
|
||||||
|
LaunchedEffect(focusedItem) {
|
||||||
|
focusedItem?.let { onChangeBackdrop.invoke(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
SharedTransitionLayout {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = itemsContentHasFocus,
|
||||||
|
label = "header_transition",
|
||||||
|
) { targetState ->
|
||||||
|
if (targetState) {
|
||||||
|
// Show item header
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
contentFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.sharedBounds(
|
||||||
|
rememberSharedContentState(key = "header"),
|
||||||
|
animatedVisibilityScope = this@AnimatedContent,
|
||||||
|
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||||
|
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// 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(),
|
||||||
|
)
|
||||||
|
if (state.viewOptions.cardViewOptions.showDetails) {
|
||||||
|
HomePageHeader(
|
||||||
|
item = focusedItem,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(top = 48.dp, start = 8.dp)
|
||||||
|
.fillMaxHeight(.33f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show collection header
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
focusedItem = state.collection
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.sharedBounds(
|
||||||
|
rememberSharedContentState(key = "header"),
|
||||||
|
animatedVisibilityScope = this@AnimatedContent,
|
||||||
|
enter = slideInVertically { -it / 2 } + fadeIn(),
|
||||||
|
exit = slideOutVertically { -it / 2 } + fadeOut(),
|
||||||
|
).padding(bottom = 16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) {
|
||||||
|
onChangeBackdrop.invoke(state.collection!!)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
CollectionDetailsHeader(
|
||||||
|
collection = state.collection!!,
|
||||||
|
logoImageUrl = state.logoImageUrl,
|
||||||
|
overviewOnClick = overviewOnClick,
|
||||||
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(top = 48.dp, start = 8.dp)
|
||||||
|
// TODO
|
||||||
|
.fillMaxHeight(.36f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
CollectionButtons(
|
||||||
|
state = state,
|
||||||
|
onSortChange = onSortChange,
|
||||||
|
onClickPlayAll = onClickPlayAll,
|
||||||
|
onFilterChange = onFilterChange,
|
||||||
|
getPossibleFilterValues = getPossibleFilterValues,
|
||||||
|
onClickViewOptions = onClickViewOptions,
|
||||||
|
favoriteOnClick = favoriteOnClick,
|
||||||
|
deleteOnClick = deleteOnClick,
|
||||||
|
canDelete = canDelete,
|
||||||
|
moreOnClick = moreOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) itemsContentHasFocus = true
|
||||||
|
}.focusProperties {
|
||||||
|
up = focusRequester
|
||||||
|
}.focusRequester(contentFocusRequester),
|
||||||
|
) {
|
||||||
|
if (state.viewOptions.separateTypes) {
|
||||||
|
CollectionRows(
|
||||||
|
preferences = preferences,
|
||||||
|
state = state,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onLongClickItem = onLongClickItem,
|
||||||
|
onClickPlay = onClickPlay,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onFocusPosition = { position ->
|
||||||
|
Timber.v("onFocusPosition=%s", position)
|
||||||
|
focusedItem =
|
||||||
|
position.let {
|
||||||
|
val key =
|
||||||
|
state.separateItems.keys
|
||||||
|
.toList()
|
||||||
|
.getOrNull(it.row)
|
||||||
|
(state.separateItems[key] as? HomeRowLoadingState.Success)?.items?.getOrNull(
|
||||||
|
it.column,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CollectionMixedGrid(
|
||||||
|
preferences = preferences,
|
||||||
|
state = state,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onLongClickItem = onLongClickItem,
|
||||||
|
onClickPlay = onClickPlay,
|
||||||
|
letterPosition = letterPosition,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onFocusPosition = {
|
||||||
|
Timber.v("onFocusPosition=%s", it)
|
||||||
|
focusedItem = state.items.getOrNull(it.column)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
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.ui.components.GenreText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionDetailsHeader(
|
||||||
|
collection: BaseItem,
|
||||||
|
logoImageUrl: String?,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val dto = collection.data
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
if (logoImageUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = logoImageUrl,
|
||||||
|
contentDescription = collection.name,
|
||||||
|
modifier = Modifier.height(80.dp),
|
||||||
|
alignment = Alignment.TopStart,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
text = collection.name ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
|
) {
|
||||||
|
QuickDetails(
|
||||||
|
collection.ui.quickDetails,
|
||||||
|
collection.timeRemainingOrRuntime,
|
||||||
|
Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
dto.genres?.letNotEmpty {
|
||||||
|
GenreText(it, Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||||
|
Text(
|
||||||
|
text = tagline,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
dto.overview?.let { overview ->
|
||||||
|
OverviewText(
|
||||||
|
overview = overview,
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = overviewOnClick,
|
||||||
|
textBoxHeight = Dp.Unspecified,
|
||||||
|
modifier =
|
||||||
|
Modifier.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.scale
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CollectionMixedGrid(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
state: CollectionState,
|
||||||
|
onClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||||
|
letterPosition: suspend (Char) -> Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onFocusPosition: (RowColumn) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val gridFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val cardViewOptions = state.viewOptions.cardViewOptions
|
||||||
|
CardGrid(
|
||||||
|
pager = state.items,
|
||||||
|
onClickItem = { index: Int, item: BaseItem -> onClickItem.invoke(RowColumn(0, index), item) },
|
||||||
|
onLongClickItem = { index: Int, item: BaseItem -> onLongClickItem.invoke(RowColumn(0, index), item) },
|
||||||
|
onClickPlay = { index: Int, item: BaseItem -> onClickPlay.invoke(RowColumn(0, index), item) },
|
||||||
|
letterPosition = letterPosition,
|
||||||
|
gridFocusRequester = gridFocusRequester,
|
||||||
|
showJumpButtons = false, // TODO add preference
|
||||||
|
showLetterButtons = state.sortAndDirection.sort == ItemSortBy.SORT_NAME,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
initialPosition = 0,
|
||||||
|
positionCallback = { _, newPosition ->
|
||||||
|
onFocusPosition.invoke(RowColumn(0, newPosition))
|
||||||
|
},
|
||||||
|
cardContent = { item, onClick, onLongClick, mod ->
|
||||||
|
GridCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
imageContentScale = cardViewOptions.contentScale.scale,
|
||||||
|
imageAspectRatio = cardViewOptions.aspectRatio.ratio,
|
||||||
|
imageType = cardViewOptions.imageType,
|
||||||
|
showTitle = cardViewOptions.showTitles,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
columns = cardViewOptions.columns,
|
||||||
|
spacing = cardViewOptions.spacing.dp,
|
||||||
|
bringIntoViewSpec =
|
||||||
|
remember(cardViewOptions) {
|
||||||
|
val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() }
|
||||||
|
if (cardViewOptions.showDetails) {
|
||||||
|
ScrollToTopBringIntoViewSpec(spacingPx)
|
||||||
|
} else {
|
||||||
|
defaultBringIntoViewSpec
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CollectionRows(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
state: CollectionState,
|
||||||
|
onClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onFocusPosition: (RowColumn) -> Unit = {},
|
||||||
|
) {
|
||||||
|
var position by rememberPosition(0, 0)
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
val cardViewOptions = state.viewOptions.cardViewOptions
|
||||||
|
val homeRows =
|
||||||
|
remember(state.separateItems, cardViewOptions) {
|
||||||
|
state.separateItems.map { (type, row) ->
|
||||||
|
if (row is HomeRowLoadingState.Success) {
|
||||||
|
// TODO not great to do this in the UI
|
||||||
|
val viewOptions =
|
||||||
|
if (type == BaseItemKind.EPISODE) {
|
||||||
|
HomeRowViewOptions(
|
||||||
|
heightDp = Cards.HEIGHT_EPISODE,
|
||||||
|
episodeAspectRatio = AspectRatio.WIDE,
|
||||||
|
showTitles = cardViewOptions.showTitles,
|
||||||
|
useSeries = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HomeRowViewOptions(
|
||||||
|
showTitles = cardViewOptions.showTitles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
row.copy(viewOptions = viewOptions)
|
||||||
|
} else {
|
||||||
|
row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HomePageContent(
|
||||||
|
homeRows = homeRows,
|
||||||
|
position = position,
|
||||||
|
onFocusPosition = { newPosition ->
|
||||||
|
position = newPosition
|
||||||
|
onFocusPosition.invoke(newPosition)
|
||||||
|
},
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onLongClickItem = onLongClickItem,
|
||||||
|
onClickPlay = onClickPlay,
|
||||||
|
showClock = false,
|
||||||
|
onUpdateBackdrop = {},
|
||||||
|
headerComposable = {},
|
||||||
|
takeFocus = false,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,493 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.asFlow
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||||
|
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.data.model.LibraryDisplayInfo
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.KeyValueService
|
||||||
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
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.SlimItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel(assistedFactory = CollectionViewModel.Factory::class)
|
||||||
|
class CollectionViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val api: ApiClient,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
private val preferencesService: UserPreferencesService,
|
||||||
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
|
private val mediaManagementService: MediaManagementService,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
private val keyValueService: KeyValueService,
|
||||||
|
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
val mediaReportService: MediaReportService,
|
||||||
|
@Assisted private val itemId: UUID,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(itemId: UUID): CollectionViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewOptionsFlow =
|
||||||
|
serverRepository.currentUser
|
||||||
|
.asFlow()
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest {
|
||||||
|
keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions())
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions())
|
||||||
|
|
||||||
|
private val libraryDisplayInfoFlow =
|
||||||
|
serverRepository.currentUser
|
||||||
|
.asFlow()
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest {
|
||||||
|
libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString())
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(CollectionState())
|
||||||
|
val state: StateFlow<CollectionState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
addCloseable { release() }
|
||||||
|
// Get global per-user view options for collections
|
||||||
|
viewOptionsFlow.collectLatestIn(viewModelScope) { viewOptions ->
|
||||||
|
Timber.v("Updated viewOptions")
|
||||||
|
_state.update {
|
||||||
|
it.copy(viewOptions = viewOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
libraryDisplayInfoFlow
|
||||||
|
.filterNotNull()
|
||||||
|
.collectLatestIn(viewModelScope) { libraryDisplayInfo ->
|
||||||
|
Timber.v("Updated libraryDisplayInfo")
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
itemFilter = libraryDisplayInfo.filter,
|
||||||
|
sortAndDirection = libraryDisplayInfo.sortAndDirection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val collection =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
backdropService.submit(collection)
|
||||||
|
val logoImageUrl = null
|
||||||
|
// TODO add logo back
|
||||||
|
// if (ImageType.LOGO in collection.data.imageTags.orEmpty()) {
|
||||||
|
// imageUrlService.getItemImageUrl(collection, ImageType.LOGO)
|
||||||
|
// } else {
|
||||||
|
// null
|
||||||
|
// }
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
collection = collection,
|
||||||
|
logoImageUrl = logoImageUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
listenForStateUpdates()
|
||||||
|
themeSongPlayer.playThemeFor(
|
||||||
|
itemId,
|
||||||
|
preferencesService
|
||||||
|
.getCurrent()
|
||||||
|
.appPreferences.interfacePreferences.playThemeSongs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
themeSongPlayer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects on [state] and fetches data when needed
|
||||||
|
*/
|
||||||
|
private fun listenForStateUpdates() =
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
state
|
||||||
|
.map {
|
||||||
|
Triple(
|
||||||
|
it.sortAndDirection,
|
||||||
|
it.itemFilter,
|
||||||
|
it.viewOptions.separateTypes,
|
||||||
|
)
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.collectLatest { (sort, filter, separateTypes) ->
|
||||||
|
try {
|
||||||
|
updateData(sort, filter, separateTypes)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(
|
||||||
|
ex,
|
||||||
|
"Error fetching data for collection %s",
|
||||||
|
itemId,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(loadingState = LoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateData(
|
||||||
|
sort: SortAndDirection,
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
separateTypes: Boolean,
|
||||||
|
) {
|
||||||
|
Timber.d("Begin updateData for %s", itemId)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loadingState = LoadingState.Loading,
|
||||||
|
items = emptyList(),
|
||||||
|
separateItems = emptyMap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!separateTypes) {
|
||||||
|
val result = fetchItems(sort, filter, typesInCollection)
|
||||||
|
_state.update { it.copy(items = result) }
|
||||||
|
} else {
|
||||||
|
supervisorScope {
|
||||||
|
val jobs =
|
||||||
|
typesInCollection.map { type ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
val title = context.getString(formatTypeName(type))
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
val pager = fetchItems(sort, filter, listOf(type))
|
||||||
|
HomeRowLoadingState.Success(title, pager)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(
|
||||||
|
ex,
|
||||||
|
"Error fetching %s for collection %s",
|
||||||
|
type,
|
||||||
|
itemId,
|
||||||
|
)
|
||||||
|
HomeRowLoadingState.Error(title, exception = ex)
|
||||||
|
}
|
||||||
|
type to result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobs.forEach { job ->
|
||||||
|
val (type, row) = job.await()
|
||||||
|
_state.update {
|
||||||
|
val separateItems =
|
||||||
|
it.separateItems.toMutableMap().apply {
|
||||||
|
put(type, row)
|
||||||
|
}
|
||||||
|
it.copy(separateItems = separateItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(loadingState = LoadingState.Success) }
|
||||||
|
Timber.d("End updateData for %s", itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchItems(
|
||||||
|
sort: SortAndDirection?,
|
||||||
|
filter: GetItemsFilter?,
|
||||||
|
types: List<BaseItemKind>,
|
||||||
|
): ApiRequestPager<GetItemsRequest> {
|
||||||
|
val request = createGetItemsRequest(sort, filter, types)
|
||||||
|
val useSeriesForPrimary = !state.value.viewOptions.separateTypes
|
||||||
|
return ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
).init()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createGetItemsRequest(
|
||||||
|
sort: SortAndDirection?,
|
||||||
|
filter: GetItemsFilter?,
|
||||||
|
types: List<BaseItemKind>,
|
||||||
|
): GetItemsRequest {
|
||||||
|
val includeItemTypes: List<BaseItemKind>?
|
||||||
|
val excludeItemTypes: List<BaseItemKind>?
|
||||||
|
// Workaround for https://github.com/jellyfin/jellyfin/issues/16454
|
||||||
|
if (types.size == 1 && types.first() == BaseItemKind.BOX_SET) {
|
||||||
|
includeItemTypes = null
|
||||||
|
excludeItemTypes =
|
||||||
|
BaseItemKind.entries
|
||||||
|
.toMutableList()
|
||||||
|
.apply { remove(BaseItemKind.BOX_SET) }
|
||||||
|
} else {
|
||||||
|
includeItemTypes = types
|
||||||
|
excludeItemTypes = null
|
||||||
|
}
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
parentId = itemId,
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
|
excludeItemTypes = excludeItemTypes,
|
||||||
|
recursive = false,
|
||||||
|
sortBy = sort?.let { listOf(sort.sort) },
|
||||||
|
sortOrder = sort?.let { listOf(sort.direction) },
|
||||||
|
fields = SlimItemFields,
|
||||||
|
).let {
|
||||||
|
filter?.applyTo(it, false) ?: it
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeSort(sortAndDirection: SortAndDirection) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val user = serverRepository.currentUser.value
|
||||||
|
val state = _state.value
|
||||||
|
if (user != null) {
|
||||||
|
libraryDisplayInfoDao.saveItem(
|
||||||
|
LibraryDisplayInfo(
|
||||||
|
user = user,
|
||||||
|
itemId = itemId,
|
||||||
|
sort = sortAndDirection.sort,
|
||||||
|
direction = sortAndDirection.direction,
|
||||||
|
filter = state.itemFilter,
|
||||||
|
viewOptions = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeFilter(filter: GetItemsFilter) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val user = serverRepository.currentUser.value
|
||||||
|
val state = _state.value
|
||||||
|
if (user != null) {
|
||||||
|
libraryDisplayInfoDao.saveItem(
|
||||||
|
LibraryDisplayInfo(
|
||||||
|
user = user,
|
||||||
|
itemId = itemId,
|
||||||
|
sort = state.sortAndDirection.sort,
|
||||||
|
direction = state.sortAndDirection.direction,
|
||||||
|
filter = filter,
|
||||||
|
viewOptions = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeViewOptions(viewOptions: CollectionViewOptions) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
if (!viewOptions.cardViewOptions.showDetails) {
|
||||||
|
val collection = state.value.collection
|
||||||
|
if (collection != null) {
|
||||||
|
backdropService.submit(collection)
|
||||||
|
} else {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serverRepository.currentUser.value?.id?.let { userId ->
|
||||||
|
keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
|
FilterUtils.getFilterOptionValues(
|
||||||
|
api,
|
||||||
|
serverRepository.currentUser.value?.id,
|
||||||
|
itemId,
|
||||||
|
filterOption,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun letterPosition(letter: Char): Int =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val sort = state.value.sortAndDirection
|
||||||
|
val filter = state.value.itemFilter
|
||||||
|
val request =
|
||||||
|
createGetItemsRequest(
|
||||||
|
sort = sort,
|
||||||
|
filter = filter,
|
||||||
|
types = typesInCollection,
|
||||||
|
).copy(
|
||||||
|
enableImageTypes = null,
|
||||||
|
fields = null,
|
||||||
|
nameLessThan = letter.toString(),
|
||||||
|
limit = 0,
|
||||||
|
enableTotalRecordCount = true,
|
||||||
|
)
|
||||||
|
val result by GetItemsRequestHandler.execute(api, request)
|
||||||
|
result.totalRecordCount
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigate(destination: Destination) {
|
||||||
|
release()
|
||||||
|
navigationManager.navigateTo(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWatched(
|
||||||
|
itemId: UUID,
|
||||||
|
played: Boolean,
|
||||||
|
position: RowColumn?,
|
||||||
|
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
|
favoriteWatchManager.setWatched(itemId, played)
|
||||||
|
if (position != null) {
|
||||||
|
refreshItem(itemId, position, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFavorite(
|
||||||
|
itemId: UUID,
|
||||||
|
favorite: Boolean,
|
||||||
|
position: RowColumn?,
|
||||||
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
|
if (position != null) {
|
||||||
|
refreshItem(itemId, position, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canDelete(
|
||||||
|
item: BaseItem,
|
||||||
|
appPreferences: AppPreferences,
|
||||||
|
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||||
|
|
||||||
|
fun deleteItem(
|
||||||
|
item: BaseItem,
|
||||||
|
position: RowColumn?,
|
||||||
|
) {
|
||||||
|
deleteItem(context, mediaManagementService, item) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
if (position != null) {
|
||||||
|
refreshItem(itemId, position, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshItem(
|
||||||
|
itemId: UUID,
|
||||||
|
position: RowColumn,
|
||||||
|
isDelete: Boolean,
|
||||||
|
) {
|
||||||
|
state.value.let { state ->
|
||||||
|
val items =
|
||||||
|
if (state.viewOptions.separateTypes) {
|
||||||
|
val key =
|
||||||
|
state.separateItems.keys
|
||||||
|
.toList()
|
||||||
|
.getOrNull(position.row)
|
||||||
|
(state.separateItems[key] as? HomeRowLoadingState.Success)?.items as? ApiRequestPager<*>
|
||||||
|
} else {
|
||||||
|
state.items as? ApiRequestPager<*>
|
||||||
|
}
|
||||||
|
if (isDelete) {
|
||||||
|
items?.refreshPagesAfter(position.column)
|
||||||
|
} else {
|
||||||
|
items?.refreshItem(position.column, itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: BaseItem) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val collection = state.value.collection
|
||||||
|
if (item.id == collection?.id) {
|
||||||
|
// Always show the collection's backdrop if requested
|
||||||
|
backdropService.submit(collection)
|
||||||
|
} else if (state.value.viewOptions.cardViewOptions.showDetails) {
|
||||||
|
backdropService.submit(item)
|
||||||
|
} else {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val typesInCollection =
|
||||||
|
listOf(
|
||||||
|
BaseItemKind.MOVIE,
|
||||||
|
BaseItemKind.SERIES,
|
||||||
|
BaseItemKind.EPISODE,
|
||||||
|
BaseItemKind.VIDEO,
|
||||||
|
BaseItemKind.BOX_SET,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val VIEW_OPTIONS_KEY = "CollectionViewOptions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class CollectionState(
|
||||||
|
val loadingState: LoadingState = LoadingState.Pending,
|
||||||
|
val collection: BaseItem? = null,
|
||||||
|
val sortAndDirection: SortAndDirection =
|
||||||
|
SortAndDirection(
|
||||||
|
ItemSortBy.DEFAULT,
|
||||||
|
SortOrder.ASCENDING,
|
||||||
|
),
|
||||||
|
val itemFilter: GetItemsFilter = GetItemsFilter(),
|
||||||
|
val viewOptions: CollectionViewOptions = CollectionViewOptions(),
|
||||||
|
val items: List<BaseItem?> = emptyList(),
|
||||||
|
val separateItems: Map<BaseItemKind, HomeRowLoadingState> = emptyMap(),
|
||||||
|
val logoImageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
|
|
||||||
|
import android.view.Gravity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
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.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsAspectRatio
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsColumns
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsContentScale
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsDetailHeader
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsImageType
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsReset
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsShowTitles
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsSpacing
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionViewOptionsDialog(
|
||||||
|
viewOptions: CollectionViewOptions,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onViewOptionsChange: (CollectionViewOptions) -> Unit,
|
||||||
|
defaultViewOptions: CollectionViewOptions = CollectionViewOptions(),
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
val columnState = rememberLazyListState()
|
||||||
|
val options =
|
||||||
|
if (viewOptions.separateTypes) CollectionViewOptions.SeparateOptions else CollectionViewOptions.MixedOptions
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties =
|
||||||
|
DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||||
|
dialogWindowProvider?.window?.let { window ->
|
||||||
|
window.setGravity(Gravity.END)
|
||||||
|
window.setDimAmount(0f)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(256.dp)
|
||||||
|
.heightIn(max = 380.dp)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
).padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.view_options),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
state = columnState,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
val pref = CollectionViewOptions.SeparateTypes
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
ComposablePreference(
|
||||||
|
preference = pref,
|
||||||
|
value = viewOptions.separateTypes,
|
||||||
|
onNavigate = {},
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = Modifier,
|
||||||
|
onClickPreference = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(options, key = { it.title }) { pref ->
|
||||||
|
pref as AppPreference<ViewOptions, Any>
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val value = pref.getter.invoke(viewOptions.cardViewOptions)
|
||||||
|
ComposablePreference(
|
||||||
|
preference = pref,
|
||||||
|
value = value,
|
||||||
|
onNavigate = {},
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
val newCardViewOptions =
|
||||||
|
pref.setter(viewOptions.cardViewOptions, newValue)
|
||||||
|
onViewOptionsChange.invoke(viewOptions.copy(cardViewOptions = newCardViewOptions))
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
onClickPreference = { pref ->
|
||||||
|
if (pref == ViewOptionsReset) {
|
||||||
|
onViewOptionsChange.invoke(defaultViewOptions)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CollectionViewOptions(
|
||||||
|
val separateTypes: Boolean = true,
|
||||||
|
val cardViewOptions: ViewOptions =
|
||||||
|
ViewOptions(
|
||||||
|
showDetails = true,
|
||||||
|
showTitles = true,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val SeparateTypes =
|
||||||
|
AppSwitchPreference<CollectionViewOptions>(
|
||||||
|
title = R.string.separate_types,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.separateTypes },
|
||||||
|
setter = { vo, value -> vo.copy(separateTypes = value) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val MixedOptions =
|
||||||
|
listOf(
|
||||||
|
ViewOptionsImageType,
|
||||||
|
ViewOptionsAspectRatio,
|
||||||
|
ViewOptionsDetailHeader,
|
||||||
|
ViewOptionsShowTitles,
|
||||||
|
ViewOptionsColumns,
|
||||||
|
ViewOptionsSpacing,
|
||||||
|
ViewOptionsContentScale,
|
||||||
|
ViewOptionsReset,
|
||||||
|
)
|
||||||
|
|
||||||
|
val SeparateOptions =
|
||||||
|
listOf(
|
||||||
|
ViewOptionsDetailHeader,
|
||||||
|
ViewOptionsShowTitles,
|
||||||
|
ViewOptionsReset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -85,6 +86,7 @@ fun EpisodeDetails(
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
val canDelete by viewModel.canDelete.collectAsState()
|
||||||
|
|
||||||
val preferredSubtitleLanguage =
|
val preferredSubtitleLanguage =
|
||||||
viewModel.serverRepository.currentUserDto
|
viewModel.serverRepository.currentUserDto
|
||||||
|
|
@ -226,7 +228,7 @@ fun EpisodeDetails(
|
||||||
onClearChosenStreams = {
|
onClearChosenStreams = {
|
||||||
viewModel.clearChosenStreams(chosenStreams)
|
viewModel.clearChosenStreams(chosenStreams)
|
||||||
},
|
},
|
||||||
canDelete = viewModel.canDelete,
|
canDelete = canDelete,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -236,7 +238,7 @@ fun EpisodeDetails(
|
||||||
favoriteOnClick = {
|
favoriteOnClick = {
|
||||||
viewModel.setFavorite(ep.id, !ep.favorite)
|
viewModel.setFavorite(ep.id, !ep.favorite)
|
||||||
},
|
},
|
||||||
canDelete = viewModel.canDelete,
|
canDelete = canDelete,
|
||||||
deleteOnClick = { showDeleteDialog = ep },
|
deleteOnClick = { showDeleteDialog = ep },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.episode
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.asFlow
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
|
|
@ -19,6 +20,8 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
|
import com.github.damontecres.wholphin.ui.combinePair
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
|
@ -34,6 +37,11 @@ import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -68,11 +76,19 @@ class EpisodeViewModel
|
||||||
val item = MutableLiveData<BaseItem?>(null)
|
val item = MutableLiveData<BaseItem?>(null)
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||||
|
|
||||||
var canDelete: Boolean = false
|
val canDelete = MutableStateFlow(false)
|
||||||
private set
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
item
|
||||||
|
.asFlow()
|
||||||
|
.filterNotNull()
|
||||||
|
.combinePair(userPreferencesService.flow.map { it.appPreferences })
|
||||||
|
.collectLatest { (item, preferences) ->
|
||||||
|
canDelete.update { mediaManagementService.canDelete(item, preferences) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAndSetItem(): Deferred<BaseItem> =
|
private fun fetchAndSetItem(): Deferred<BaseItem> =
|
||||||
|
|
@ -101,7 +117,6 @@ class EpisodeViewModel
|
||||||
) {
|
) {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val item = fetchAndSetItem().await()
|
val item = fetchAndSetItem().await()
|
||||||
canDelete = mediaManagementService.canDelete(item)
|
|
||||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@EpisodeViewModel.item.value = item
|
this@EpisodeViewModel.item.value = item
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ fun MovieDetails(
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||||
val discovered by viewModel.discovered.collectAsState()
|
val discovered by viewModel.discovered.collectAsState()
|
||||||
|
val canDelete by viewModel.canDelete.collectAsState()
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
@ -211,7 +212,7 @@ fun MovieDetails(
|
||||||
seriesId = null,
|
seriesId = null,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||||
canDelete = viewModel.canDelete,
|
canDelete = canDelete,
|
||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
chooseVersion =
|
chooseVersion =
|
||||||
|
|
@ -323,7 +324,7 @@ fun MovieDetails(
|
||||||
onClickDiscover = { index, item ->
|
onClickDiscover = { index, item ->
|
||||||
viewModel.navigateTo(item.destination)
|
viewModel.navigateTo(item.destination)
|
||||||
},
|
},
|
||||||
canDelete = viewModel.canDelete,
|
canDelete = canDelete,
|
||||||
deleteOnClick = { showDeleteDialog = movie },
|
deleteOnClick = { showDeleteDialog = movie },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.movie
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.asFlow
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
|
|
@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
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.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -46,6 +49,9 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -93,11 +99,19 @@ class MovieViewModel
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
var canDelete: Boolean = false
|
val canDelete = MutableStateFlow(false)
|
||||||
private set
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
item
|
||||||
|
.asFlow()
|
||||||
|
.filterNotNull()
|
||||||
|
.combinePair(userPreferencesService.flow.map { it.appPreferences })
|
||||||
|
.collectLatest { (item, preferences) ->
|
||||||
|
canDelete.update { mediaManagementService.canDelete(item, preferences) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAndSetItem(): Deferred<BaseItem> =
|
private fun fetchAndSetItem(): Deferred<BaseItem> =
|
||||||
|
|
@ -112,7 +126,6 @@ class MovieViewModel
|
||||||
api.userLibraryApi.getItem(itemId).content.let {
|
api.userLibraryApi.getItem(itemId).content.let {
|
||||||
BaseItem.from(it, api)
|
BaseItem.from(it, api)
|
||||||
}
|
}
|
||||||
canDelete = mediaManagementService.canDelete(item)
|
|
||||||
this@MovieViewModel.item.setValueOnMain(item)
|
this@MovieViewModel.item.setValueOnMain(item)
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,721 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
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.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
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.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
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.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
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.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class)
|
||||||
|
class AlbumViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
api: ApiClient,
|
||||||
|
musicService: MusicService,
|
||||||
|
navigationManager: NavigationManager,
|
||||||
|
mediaManagementService: MediaManagementService,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val mediaReportService: MediaReportService,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
@Assisted itemId: UUID,
|
||||||
|
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(itemId: UUID): AlbumViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AlbumState.EMPTY)
|
||||||
|
val state: StateFlow<AlbumState> = _state
|
||||||
|
|
||||||
|
val currentMusic = musicService.state
|
||||||
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
val itemDeferred =
|
||||||
|
async {
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
}
|
||||||
|
val songsDeferred = async { getPagerForAlbum(api, itemId) }
|
||||||
|
val album = itemDeferred.await()
|
||||||
|
val songs = songsDeferred.await()
|
||||||
|
val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY)
|
||||||
|
val allArtists =
|
||||||
|
album.data.artists.orEmpty() +
|
||||||
|
album.data.albumArtists
|
||||||
|
?.map { it.name }
|
||||||
|
.orEmpty()
|
||||||
|
val isVariousArtists =
|
||||||
|
allArtists.firstOrNull { it?.lowercase() == "various artists" } != null ||
|
||||||
|
album.data.artists
|
||||||
|
.orEmpty()
|
||||||
|
.size > 1
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
album = album,
|
||||||
|
isVariousArtists = isVariousArtists,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
songs = songs,
|
||||||
|
loading = LoadingState.Success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateBackDrop()
|
||||||
|
if (state.value.similar.isEmpty()) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val similar =
|
||||||
|
api.libraryApi
|
||||||
|
.getSimilarItems(
|
||||||
|
GetSimilarItemsRequest(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
itemId = itemId,
|
||||||
|
excludeArtistIds = album.data.albumArtists?.map { it.id },
|
||||||
|
fields = SlimItemFields,
|
||||||
|
limit = 25,
|
||||||
|
),
|
||||||
|
).content.items
|
||||||
|
.map { BaseItem.from(it, api) }
|
||||||
|
_state.update { it.copy(similar = similar) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
albumIds = listOf(itemId),
|
||||||
|
parentId = null,
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO),
|
||||||
|
)
|
||||||
|
val musicVideos =
|
||||||
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
|
if (musicVideos.isNotEmpty()) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(musicVideos = musicVideos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBackDrop() {
|
||||||
|
state.value.album?.let { album ->
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
getBackdropItemForAlbum(api, album)?.let {
|
||||||
|
backdropService.submit(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFavorite(
|
||||||
|
itemId: UUID,
|
||||||
|
favorite: Boolean,
|
||||||
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
|
val album =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
_state.update { it.copy(album = album) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(
|
||||||
|
shuffled: Boolean,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
) {
|
||||||
|
val songs = state.value.songs as ApiRequestPager<*>
|
||||||
|
play(songs, startIndex, shuffled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getBackdropItemForAlbum(
|
||||||
|
api: ApiClient,
|
||||||
|
album: BaseItem,
|
||||||
|
): BaseItem? {
|
||||||
|
if (album.data.backdropImageTags?.isNotEmpty() == true) {
|
||||||
|
return album
|
||||||
|
} else {
|
||||||
|
val artistIds =
|
||||||
|
album.data.albumArtists
|
||||||
|
?.shuffled()
|
||||||
|
?.take(50)
|
||||||
|
?.map { it.id }
|
||||||
|
return api.itemsApi
|
||||||
|
.getItems(
|
||||||
|
ids = artistIds,
|
||||||
|
imageTypes = listOf(ImageType.BACKDROP),
|
||||||
|
).content.items
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { BaseItem(it, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlbumState(
|
||||||
|
val album: BaseItem?,
|
||||||
|
val isVariousArtists: Boolean,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val songs: List<BaseItem?>,
|
||||||
|
val similar: List<BaseItem>,
|
||||||
|
val loading: LoadingState,
|
||||||
|
val musicVideos: List<BaseItem?> = emptyList(),
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HEADER_ROW = 0
|
||||||
|
private const val SONG_ROW = HEADER_ROW + 1
|
||||||
|
private const val MUSIC_VIDEO_ROW = SONG_ROW + 1
|
||||||
|
private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumDetailsPage(
|
||||||
|
itemId: UUID,
|
||||||
|
preferences: UserPreferences,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AlbumViewModel =
|
||||||
|
hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(itemId) },
|
||||||
|
),
|
||||||
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val currentMusic by viewModel.currentMusic.collectAsState()
|
||||||
|
|
||||||
|
var position by rememberPosition(0, 0)
|
||||||
|
val focusRequesters =
|
||||||
|
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||||
|
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) }
|
||||||
|
val moreDialogActions =
|
||||||
|
remember {
|
||||||
|
MusicMoreDialogActions(
|
||||||
|
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||||
|
onClickPlay = { index, _ -> viewModel.play(false, index) },
|
||||||
|
onClickPlayNext = { _, song -> viewModel.playNext(song) },
|
||||||
|
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
||||||
|
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onClickRemoveFromQueue = {},
|
||||||
|
onClickDelete = { showDeleteDialog = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val loading = state.loading) {
|
||||||
|
is LoadingState.Error -> {
|
||||||
|
ErrorMessage(loading, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Loading,
|
||||||
|
LoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Success -> {
|
||||||
|
val album = state.album!!
|
||||||
|
|
||||||
|
val firstFocusRequester = remember { FocusRequester() }
|
||||||
|
val firstBringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val itemsBefore = 2
|
||||||
|
|
||||||
|
val songFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (position.row == SONG_ROW) {
|
||||||
|
songFocusRequester.tryRequestFocus()
|
||||||
|
} else {
|
||||||
|
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
viewModel.updateBackDrop()
|
||||||
|
}
|
||||||
|
val backHandlerActive by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.firstVisibleItemIndex > itemsBefore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(backHandlerActive) {
|
||||||
|
scope.launch {
|
||||||
|
listState.animateScrollToItem(itemsBefore)
|
||||||
|
firstBringIntoViewRequester.bringIntoView()
|
||||||
|
firstFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
) {
|
||||||
|
AlbumHeader(
|
||||||
|
album = album,
|
||||||
|
imageUrl = state.imageUrl,
|
||||||
|
overviewOnClick = {},
|
||||||
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
MusicExpandableButtons(
|
||||||
|
actions =
|
||||||
|
remember {
|
||||||
|
MusicButtonActions(
|
||||||
|
onClickPlay = { viewModel.play(it, 0) },
|
||||||
|
onClickInstantMix = { viewModel.startInstantMix(album.id) },
|
||||||
|
onClickFavorite = {
|
||||||
|
viewModel.setFavorite(
|
||||||
|
album.id,
|
||||||
|
!album.favorite,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClickMore = {
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
favorite = album.favorite,
|
||||||
|
buttonOnFocusChanged = {
|
||||||
|
if (it.isFocused) {
|
||||||
|
position = RowColumn(HEADER_ROW, 0)
|
||||||
|
scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}.focusRequester(focusRequesters[HEADER_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.songs) + " (${state.songs.size})",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
itemsIndexed(state.songs) { index, song ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
SongListItem(
|
||||||
|
song = song,
|
||||||
|
onClick = {
|
||||||
|
position = RowColumn(SONG_ROW, index)
|
||||||
|
viewModel.play(false, index)
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (song != null) {
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = true,
|
||||||
|
title = song.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = song,
|
||||||
|
index = index,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
song,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClickMore = {
|
||||||
|
if (song != null) {
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = song.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = song,
|
||||||
|
index = index,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
song,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showArtist = state.isVariousArtists,
|
||||||
|
isPlaying = song != null && currentMusic.currentItemId == song.id,
|
||||||
|
isQueued = song != null && song.id in currentMusic.queuedIds,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier
|
||||||
|
.focusRequester(firstFocusRequester)
|
||||||
|
.bringIntoViewRequester(firstBringIntoViewRequester),
|
||||||
|
).ifElse(
|
||||||
|
position.row == SONG_ROW && position.column == index,
|
||||||
|
Modifier.focusRequester(songFocusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.musicVideos.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.music_videos),
|
||||||
|
items = state.musicVideos,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = RowColumn(MUSIC_VIDEO_ROW, index)
|
||||||
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, item ->
|
||||||
|
// TODO
|
||||||
|
},
|
||||||
|
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
BannerCardWithTitle(
|
||||||
|
title = item?.name,
|
||||||
|
subtitle = item?.data?.productionYear?.toString(),
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
aspectRatio = AspectRatios.WIDE,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.similar.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.more_like_this),
|
||||||
|
items = state.similar,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = RowColumn(SIMILAR_ROW, index)
|
||||||
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, item ->
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = true,
|
||||||
|
title = item.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = item,
|
||||||
|
index = index,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
item,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
BannerCardWithTitle(
|
||||||
|
title = item?.name,
|
||||||
|
subtitle = item?.data?.productionYear?.toString(),
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
aspectRatio = AspectRatios.SQUARE,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moreDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { moreDialog = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
PlaylistDialog(
|
||||||
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
state = playlistState,
|
||||||
|
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||||
|
onClick = {
|
||||||
|
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
createEnabled = true,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
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
|
||||||
|
fun AlbumHeader(
|
||||||
|
album: BaseItem,
|
||||||
|
imageUrl: String?,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = modifier.padding(top = 32.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.20f)
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
// Artist
|
||||||
|
Text(
|
||||||
|
text = album.artistsString ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = album.name ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
|
) {
|
||||||
|
QuickDetails(
|
||||||
|
album.ui.quickDetails,
|
||||||
|
null,
|
||||||
|
Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
album.data.genres?.letNotEmpty {
|
||||||
|
GenreText(it, Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
album.data.overview?.let { overview ->
|
||||||
|
OverviewText(
|
||||||
|
overview = overview,
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = overviewOnClick,
|
||||||
|
textBoxHeight = Dp.Unspecified,
|
||||||
|
modifier =
|
||||||
|
Modifier.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,750 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
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.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
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.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
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.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
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.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = ArtistViewModel.Factory::class)
|
||||||
|
class ArtistViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
api: ApiClient,
|
||||||
|
musicService: MusicService,
|
||||||
|
navigationManager: NavigationManager,
|
||||||
|
mediaManagementService: MediaManagementService,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val mediaReportService: MediaReportService,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
@Assisted itemId: UUID,
|
||||||
|
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(itemId: UUID): ArtistViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ArtistState.EMPTY)
|
||||||
|
val state: StateFlow<ArtistState> = _state
|
||||||
|
|
||||||
|
val currentMusic = musicService.state
|
||||||
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
val itemDeferred =
|
||||||
|
async {
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
}
|
||||||
|
val albumsDeferred =
|
||||||
|
async {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = itemId,
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
|
sortBy =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.PREMIERE_DATE,
|
||||||
|
ItemSortBy.SORT_NAME,
|
||||||
|
),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING),
|
||||||
|
)
|
||||||
|
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||||
|
}
|
||||||
|
val artist = itemDeferred.await()
|
||||||
|
val albums = albumsDeferred.await()
|
||||||
|
val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
artist = artist,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
albums = albums,
|
||||||
|
loading = LoadingState.Success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
artistIds = listOf(itemId),
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
|
minCommunityRating = 1.0,
|
||||||
|
sortBy =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.COMMUNITY_RATING,
|
||||||
|
),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
|
limit = 10,
|
||||||
|
)
|
||||||
|
val topSongs =
|
||||||
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
|
if (topSongs.isNotEmpty()) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(topSongs = topSongs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.value.similar.isEmpty()) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val similar =
|
||||||
|
api.libraryApi
|
||||||
|
.getSimilarItems(
|
||||||
|
GetSimilarItemsRequest(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
itemId = itemId,
|
||||||
|
excludeArtistIds = listOf(itemId),
|
||||||
|
fields = SlimItemFields,
|
||||||
|
limit = 25,
|
||||||
|
),
|
||||||
|
).content.items
|
||||||
|
.map { BaseItem.from(it, api) }
|
||||||
|
_state.update { it.copy(similar = similar) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
artistIds = listOf(itemId),
|
||||||
|
parentId = null,
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO),
|
||||||
|
)
|
||||||
|
val musicVideos =
|
||||||
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
|
if (musicVideos.isNotEmpty()) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(musicVideos = musicVideos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
state.value.artist?.let {
|
||||||
|
backdropService.submit(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFavorite(
|
||||||
|
itemId: UUID,
|
||||||
|
favorite: Boolean,
|
||||||
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
|
val artist =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
_state.update { it.copy(artist = artist) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ArtistState(
|
||||||
|
val artist: BaseItem?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val topSongs: List<BaseItem?>,
|
||||||
|
val albums: List<BaseItem?>,
|
||||||
|
val similar: List<BaseItem>,
|
||||||
|
val loading: LoadingState,
|
||||||
|
val musicVideos: List<BaseItem?>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY =
|
||||||
|
ArtistState(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
LoadingState.Pending,
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HEADER_ROW = 0
|
||||||
|
private const val SONG_ROW = HEADER_ROW + 1
|
||||||
|
private const val ALBUM_ROW = SONG_ROW + 1
|
||||||
|
private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1
|
||||||
|
private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistDetailsPage(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
itemId: UUID,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ArtistViewModel =
|
||||||
|
hiltViewModel<ArtistViewModel, ArtistViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(itemId) },
|
||||||
|
),
|
||||||
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val currentMusic by viewModel.currentMusic.collectAsState()
|
||||||
|
var position by rememberPosition(0, 0)
|
||||||
|
val focusRequesters =
|
||||||
|
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||||
|
|
||||||
|
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) }
|
||||||
|
val moreDialogActions =
|
||||||
|
remember {
|
||||||
|
MusicMoreDialogActions(
|
||||||
|
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||||
|
onClickPlay = { index, item -> viewModel.play(item) },
|
||||||
|
onClickPlayNext = { _, item -> viewModel.playNext(item) },
|
||||||
|
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
||||||
|
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onClickRemoveFromQueue = {},
|
||||||
|
onClickDelete = { showDeleteDialog = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val loading = state.loading) {
|
||||||
|
is LoadingState.Error -> {
|
||||||
|
ErrorMessage(loading, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Loading,
|
||||||
|
LoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Success -> {
|
||||||
|
val artist = state.artist!!
|
||||||
|
val songFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (position.row == SONG_ROW) {
|
||||||
|
songFocusRequester.tryRequestFocus()
|
||||||
|
} else {
|
||||||
|
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
viewModel.refresh()
|
||||||
|
}
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
ArtistHeader(
|
||||||
|
artist = artist,
|
||||||
|
imageUrl = state.imageUrl,
|
||||||
|
overviewOnClick = {},
|
||||||
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
MusicExpandableButtons(
|
||||||
|
actions =
|
||||||
|
remember {
|
||||||
|
MusicButtonActions(
|
||||||
|
onClickPlay = { shuffled ->
|
||||||
|
viewModel.play(artist, shuffled = shuffled)
|
||||||
|
},
|
||||||
|
onClickInstantMix = { viewModel.startInstantMix(artist.id) },
|
||||||
|
onClickFavorite = {
|
||||||
|
viewModel.setFavorite(
|
||||||
|
artist.id,
|
||||||
|
!artist.favorite,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClickMore = {
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = artist.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = artist,
|
||||||
|
index = 0,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
artist,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
favorite = artist.favorite,
|
||||||
|
buttonOnFocusChanged = {
|
||||||
|
if (it.isFocused) {
|
||||||
|
position = RowColumn(HEADER_ROW, 0)
|
||||||
|
scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}.focusRequester(focusRequesters[HEADER_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.topSongs.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.popular_songs),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
itemsIndexed(state.topSongs) { index, song ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
SongListItem(
|
||||||
|
song = song,
|
||||||
|
onClick = {
|
||||||
|
position = RowColumn(SONG_ROW, index)
|
||||||
|
song?.let { viewModel.play(it) }
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (song != null) {
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = true,
|
||||||
|
title = song.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = song,
|
||||||
|
index = index,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
song,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClickMore = {
|
||||||
|
if (song != null) {
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = song.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = song,
|
||||||
|
index = index,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
song,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showArtist = false,
|
||||||
|
isPlaying = song != null && currentMusic.currentItemId == song.id,
|
||||||
|
isQueued = song != null && song.id in currentMusic.queuedIds,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.ifElse(
|
||||||
|
position.row == SONG_ROW && position.column == index,
|
||||||
|
Modifier.focusRequester(songFocusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.albums),
|
||||||
|
items = state.albums,
|
||||||
|
onClickItem = { index, album ->
|
||||||
|
position = RowColumn(ALBUM_ROW, index)
|
||||||
|
viewModel.navigationManager.navigateTo(album.destination())
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, album ->
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = true,
|
||||||
|
title = album.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = album,
|
||||||
|
index = index,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
album,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
BannerCardWithTitle(
|
||||||
|
title = album?.name,
|
||||||
|
subtitle = album?.data?.productionYear?.toString(),
|
||||||
|
item = album,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
aspectRatio = AspectRatios.SQUARE,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.musicVideos.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.music_videos),
|
||||||
|
items = state.musicVideos,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = RowColumn(MUSIC_VIDEO_ROW, index)
|
||||||
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, item ->
|
||||||
|
// TODO
|
||||||
|
},
|
||||||
|
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
BannerCardWithTitle(
|
||||||
|
title = item?.name,
|
||||||
|
subtitle = item?.data?.productionYear?.toString(),
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
aspectRatio = AspectRatios.WIDE,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.similar.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.more_like_this),
|
||||||
|
items = state.similar,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = RowColumn(SIMILAR_ROW, index)
|
||||||
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, item ->
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = true,
|
||||||
|
title = item.name ?: "",
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusic(
|
||||||
|
context = context,
|
||||||
|
actions = moreDialogActions,
|
||||||
|
item = item,
|
||||||
|
index = index,
|
||||||
|
canRemove = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
item,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
BannerCardWithTitle(
|
||||||
|
title = item?.name,
|
||||||
|
subtitle = item?.data?.productionYear?.toString(),
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
aspectRatio = AspectRatios.SQUARE,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moreDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { moreDialog = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
PlaylistDialog(
|
||||||
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
state = playlistState,
|
||||||
|
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||||
|
onClick = {
|
||||||
|
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
createEnabled = true,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||||
|
showPlaylistDialog.makeAbsent()
|
||||||
|
},
|
||||||
|
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
|
||||||
|
fun ArtistHeader(
|
||||||
|
artist: BaseItem,
|
||||||
|
imageUrl: String?,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = modifier.padding(top = 32.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.20f)
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
// Artist
|
||||||
|
Text(
|
||||||
|
text = artist.artistsString ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = artist.name ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
|
) {
|
||||||
|
QuickDetails(
|
||||||
|
artist.ui.quickDetails,
|
||||||
|
null,
|
||||||
|
Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
artist.data.genres?.letNotEmpty {
|
||||||
|
GenreText(it, Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
artist.data.overview?.let { overview ->
|
||||||
|
OverviewText(
|
||||||
|
overview = overview,
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = overviewOnClick,
|
||||||
|
textBoxHeight = Dp.Unspecified,
|
||||||
|
modifier =
|
||||||
|
Modifier.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
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.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.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BarVisualizer(
|
||||||
|
data: IntArray,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier.onSizeChanged { size = it },
|
||||||
|
) {
|
||||||
|
val size = with(LocalDensity.current) { DpSize(size.width.toDp(), size.height.toDp()) }
|
||||||
|
val padding = 1.dp
|
||||||
|
val width = size.width / data.size
|
||||||
|
|
||||||
|
data.forEachIndexed { index, data ->
|
||||||
|
val height by animateDpAsState(
|
||||||
|
targetValue = size.height * data / 256f,
|
||||||
|
animationSpec = tween(easing = LinearEasing),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(height)
|
||||||
|
.width(width)
|
||||||
|
.padding(start = if (index == 0) 0.dp else padding)
|
||||||
|
.background(MaterialTheme.colorScheme.border),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
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.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.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll
|
||||||
|
import org.jellyfin.sdk.model.api.LyricDto
|
||||||
|
import org.jellyfin.sdk.model.api.LyricLine
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LyricsContent(
|
||||||
|
lyricsHaveFocus: Boolean,
|
||||||
|
lyrics: LyricDto?,
|
||||||
|
currentLyricPosition: Int?,
|
||||||
|
onClick: (LyricLine) -> Unit,
|
||||||
|
onFocusLyrics: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequesters =
|
||||||
|
remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } }
|
||||||
|
val listState = rememberLazyListState(currentLyricPosition ?: 0)
|
||||||
|
|
||||||
|
val scrollConnection = rememberDelayedNestedScroll(yDelay = .66f)
|
||||||
|
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
if (!lyricsHaveFocus) {
|
||||||
|
LaunchedEffect(currentLyricPosition) {
|
||||||
|
if (currentLyricPosition != null) {
|
||||||
|
listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let {
|
||||||
|
if (currentLyricPosition !in listState.firstVisibleItemIndex..it) {
|
||||||
|
listState.animateScrollToItem(currentLyricPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.nestedScroll(scrollConnection),
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusProperties {
|
||||||
|
onEnter = {
|
||||||
|
if (currentLyricPosition != null) {
|
||||||
|
currentLyricPosition.let {
|
||||||
|
focusRequesters
|
||||||
|
.getOrNull(currentLyricPosition)
|
||||||
|
?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
focusRequesters.getOrNull(0)?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFocusChanged {
|
||||||
|
onFocusLyrics.invoke(it.hasFocus)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (lyrics?.lyrics?.isNotEmpty() == true) {
|
||||||
|
itemsIndexed(lyrics.lyrics) { index, lyric ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val color by animateColorAsState(
|
||||||
|
if (index == currentLyricPosition || currentLyricPosition == null) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = .4f)
|
||||||
|
},
|
||||||
|
animationSpec = tween(durationMillis = 500, easing = LinearEasing),
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
onClick = { onClick.invoke(lyric) },
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.border.copy(alpha = .33f),
|
||||||
|
),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
|
||||||
|
scale =
|
||||||
|
ClickableSurfaceDefaults.scale(
|
||||||
|
focusedScale = 1f,
|
||||||
|
pressedScale = .9f,
|
||||||
|
),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequesters[index]),
|
||||||
|
) {
|
||||||
|
val text =
|
||||||
|
remember(lyric.text) { lyric.text.ifBlank { " " } }
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (focused) MaterialTheme.colorScheme.onSurface else color,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.ifElse(
|
||||||
|
index == currentLyricPosition,
|
||||||
|
Modifier.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
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.FocusState
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
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.ExpandableFaButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MusicExpandableButtons(
|
||||||
|
actions: MusicButtonActions,
|
||||||
|
favorite: Boolean,
|
||||||
|
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.focusGroup()
|
||||||
|
.focusRestorer(firstFocus),
|
||||||
|
) {
|
||||||
|
item("play") {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.play,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.PlayArrow,
|
||||||
|
onClick = { actions.onClickPlay.invoke(false) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(firstFocus)
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("shuffle") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.shuffle,
|
||||||
|
iconStringRes = R.string.fa_shuffle,
|
||||||
|
onClick = { actions.onClickPlay.invoke(true) },
|
||||||
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("instant_mix") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.instant_mix,
|
||||||
|
iconStringRes = R.string.fa_compass,
|
||||||
|
onClick = actions.onClickInstantMix,
|
||||||
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("favorite") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||||
|
iconStringRes = R.string.fa_heart,
|
||||||
|
onClick = actions.onClickFavorite,
|
||||||
|
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||||
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("more") {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.more,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.MoreVert,
|
||||||
|
onClick = { actions.onClickMore.invoke() },
|
||||||
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MusicButtonActions(
|
||||||
|
val onClickPlay: (shuffle: Boolean) -> Unit,
|
||||||
|
val onClickInstantMix: () -> Unit,
|
||||||
|
val onClickFavorite: () -> Unit,
|
||||||
|
val onClickMore: () -> Unit,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||||
|
|
||||||
|
fun getMusicPreferences() =
|
||||||
|
listOf(
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.show_album_cover,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.musicPreferences.showAlbumArt },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateMusicPreferences { showAlbumArt = value }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.show_visualizer,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.musicPreferences.showVisualizer },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateMusicPreferences { showVisualizer = value }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.show_backdrop,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.musicPreferences.showBackdrop },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateMusicPreferences { showBackdrop = value }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.show_lyrics,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.musicPreferences.showLyrics },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateMusicPreferences { showLyrics = value }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.BlockingList
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
abstract class MusicViewModel(
|
||||||
|
internal val itemId: UUID,
|
||||||
|
internal val context: Context,
|
||||||
|
internal val api: ApiClient,
|
||||||
|
internal val musicService: MusicService,
|
||||||
|
internal val navigationManager: NavigationManager,
|
||||||
|
internal val mediaManagementService: MediaManagementService,
|
||||||
|
) : ViewModel() {
|
||||||
|
fun play(
|
||||||
|
pager: ApiRequestPager<*>,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
shuffled: Boolean = false,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
musicService.setQueue(pager, startIndex, shuffled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(
|
||||||
|
item: BaseItem,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
shuffled: Boolean = false,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
Timber.v("Playing %s %s", item.type, item.id)
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.AUDIO -> {
|
||||||
|
musicService.setQueue(listOf(item), shuffled)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ALBUM -> {
|
||||||
|
val pager = getPagerForAlbum(api, item.id)
|
||||||
|
musicService.setQueue(pager, startIndex, shuffled)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ARTIST -> {
|
||||||
|
val pager = getPagerForArtist(api, item.id)
|
||||||
|
musicService.setQueue(pager, startIndex, shuffled)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.PLAYLIST -> {
|
||||||
|
val pager = getPagerForPlaylist(api, item.id)
|
||||||
|
musicService.setQueue(pager, startIndex, shuffled)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Timber.w("Unknown item type to play for music: %s", item.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playNext(song: BaseItem) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.playNext(song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToQueue(
|
||||||
|
item: BaseItem,
|
||||||
|
index: Int,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
Timber.v("addToQueue %s %s", item.type, item.id)
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.AUDIO -> {
|
||||||
|
musicService.addAllToQueue(BlockingList.of(listOf(item)), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ALBUM -> {
|
||||||
|
val pager = getPagerForAlbum(api, item.id)
|
||||||
|
musicService.addAllToQueue(pager, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ARTIST -> {
|
||||||
|
val pager = getPagerForArtist(api, item.id)
|
||||||
|
musicService.addAllToQueue(pager, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.PLAYLIST -> {
|
||||||
|
val pager = getPagerForPlaylist(api, item.id)
|
||||||
|
musicService.addAllToQueue(pager, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Timber.w("Unknown item type to queue for music: %s", item.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startInstantMix(itemId: UUID) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
Timber.v("Starting instant mix for %s", itemId)
|
||||||
|
musicService.startInstantMix(itemId)
|
||||||
|
}
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
// TODO better way to wait for query above to start
|
||||||
|
delay(250)
|
||||||
|
navigationManager.navigateTo(Destination.NowPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canDelete(
|
||||||
|
item: BaseItem,
|
||||||
|
appPreferences: AppPreferences,
|
||||||
|
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||||
|
|
||||||
|
fun deleteItem(item: BaseItem) {
|
||||||
|
deleteItem(context, mediaManagementService, item) {
|
||||||
|
if (item.id == itemId) {
|
||||||
|
navigationManager.goBack()
|
||||||
|
} else {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal abstract fun init()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.view.Gravity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MusicViewOptionsDialog(
|
||||||
|
appPreferences: AppPreferences,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onViewOptionsChange: (AppPreferences) -> Unit,
|
||||||
|
onEnableVisualizer: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
val columnState = rememberLazyListState()
|
||||||
|
val items = remember { getMusicPreferences() }
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties =
|
||||||
|
DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||||
|
dialogWindowProvider?.window?.let { window ->
|
||||||
|
window.setGravity(Gravity.END)
|
||||||
|
window.setDimAmount(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.view_options),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
state = columnState,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(256.dp)
|
||||||
|
.heightIn(max = 380.dp)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
items(items) { pref ->
|
||||||
|
pref as AppPreference<AppPreferences, Any>
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val value = pref.getter.invoke(appPreferences)
|
||||||
|
// Using title is a bit hacky
|
||||||
|
when (pref.title) {
|
||||||
|
R.string.show_visualizer -> {
|
||||||
|
ComposablePreference(
|
||||||
|
preference = pref,
|
||||||
|
value = value,
|
||||||
|
onNavigate = {},
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
newValue as Boolean
|
||||||
|
if (newValue) {
|
||||||
|
onEnableVisualizer.invoke()
|
||||||
|
} else {
|
||||||
|
onViewOptionsChange.invoke(
|
||||||
|
pref.setter(
|
||||||
|
appPreferences,
|
||||||
|
newValue,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = Modifier,
|
||||||
|
onClickPreference = { pref ->
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ComposablePreference(
|
||||||
|
preference = pref,
|
||||||
|
value = value,
|
||||||
|
onNavigate = {},
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
onViewOptionsChange.invoke(pref.setter(appPreferences, newValue))
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = Modifier,
|
||||||
|
onClickPreference = { pref ->
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
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
|
||||||
|
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.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
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.ui.compose.state.rememberNextButtonState
|
||||||
|
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
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
|
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.theme.PreviewInteractionSource
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun NowPlayingButtons(
|
||||||
|
player: Player,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
initialFocusRequester: FocusRequester,
|
||||||
|
onClickMore: () -> Unit,
|
||||||
|
onClickStop: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val playPauseState = rememberPlayPauseButtonState(player)
|
||||||
|
val previousState = rememberPreviousButtonState(player)
|
||||||
|
val nextState = rememberNextButtonState(player)
|
||||||
|
val shuffleState = rememberShuffleButtonState(player)
|
||||||
|
val repeatState = rememberRepeatButtonState(player)
|
||||||
|
|
||||||
|
val onControllerInteraction = remember { { controllerViewState.pulseControls() } }
|
||||||
|
Box(
|
||||||
|
modifier = modifier.focusGroup(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
|
) {
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.baseline_more_vert_96,
|
||||||
|
onClick = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
onClickMore.invoke()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
PlaybackButton(
|
||||||
|
iconRes = R.drawable.baseline_stop_24,
|
||||||
|
onClick = {
|
||||||
|
onClickStop.invoke()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PlaybackButtons(
|
||||||
|
player = player,
|
||||||
|
initialFocusRequester = initialFocusRequester,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
onPlaybackActionClick = {
|
||||||
|
when (it) {
|
||||||
|
PlaybackAction.Next -> {
|
||||||
|
nextState.onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.Previous -> {
|
||||||
|
previousState.onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.ToggleCaptions -> {
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showPlay = playPauseState.showPlay,
|
||||||
|
previousEnabled = previousState.isEnabled,
|
||||||
|
nextEnabled = nextState.isEnabled,
|
||||||
|
seekBack = 10.seconds,
|
||||||
|
skipBackOnResume = null,
|
||||||
|
seekForward = 30.seconds, // TODO
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd),
|
||||||
|
) {
|
||||||
|
ShuffleButton(
|
||||||
|
active = shuffleState.shuffleOn,
|
||||||
|
enabled = shuffleState.isEnabled,
|
||||||
|
onClick = {
|
||||||
|
shuffleState.onClick()
|
||||||
|
},
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
RepeatButton(
|
||||||
|
repeatMode = repeatState.repeatModeState,
|
||||||
|
enabled = repeatState.isEnabled,
|
||||||
|
onClick = {
|
||||||
|
repeatState.onClick()
|
||||||
|
},
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShuffleButton(
|
||||||
|
active: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onControllerInteraction: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val selectedColor = MaterialTheme.colorScheme.border
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
Button(
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = onClick,
|
||||||
|
// shape = ButtonDefaults.shape(CircleShape),
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = AppColors.TransparentBlack25,
|
||||||
|
focusedContainerColor = selectedColor,
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(4.dp),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.size(36.dp, 36.dp)
|
||||||
|
.onFocusChanged { onControllerInteraction.invoke() },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_shuffle),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color =
|
||||||
|
when {
|
||||||
|
focused && active -> MaterialTheme.colorScheme.onSurface
|
||||||
|
focused && !active -> MaterialTheme.colorScheme.surface
|
||||||
|
!focused && active -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RepeatButton(
|
||||||
|
repeatMode: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onControllerInteraction: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val selectedColor = MaterialTheme.colorScheme.border
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
Button(
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = onClick,
|
||||||
|
// shape = ButtonDefaults.shape(CircleShape),
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = AppColors.TransparentBlack25,
|
||||||
|
focusedContainerColor = selectedColor,
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(4.dp),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.size(36.dp, 36.dp)
|
||||||
|
.onFocusChanged { onControllerInteraction.invoke() },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_repeat),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color =
|
||||||
|
if (focused) {
|
||||||
|
when (repeatMode) {
|
||||||
|
Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface
|
||||||
|
Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (repeatMode) {
|
||||||
|
Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface
|
||||||
|
Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
if (repeatMode == Player.REPEAT_MODE_ONE) {
|
||||||
|
Text(
|
||||||
|
text = "1",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.offset(x = 4.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
shape = RoundedCornerShape(1.dp),
|
||||||
|
).align(Alignment.BottomStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
fun ShuffleButtonPreview() {
|
||||||
|
val source = remember { PreviewInteractionSource() }
|
||||||
|
WholphinTheme {
|
||||||
|
Column {
|
||||||
|
Row {
|
||||||
|
RepeatButton(
|
||||||
|
repeatMode = Player.REPEAT_MODE_OFF,
|
||||||
|
onClick = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
)
|
||||||
|
RepeatButton(
|
||||||
|
repeatMode = Player.REPEAT_MODE_ALL,
|
||||||
|
onClick = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
)
|
||||||
|
RepeatButton(
|
||||||
|
repeatMode = Player.REPEAT_MODE_ONE,
|
||||||
|
onClick = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
RepeatButton(
|
||||||
|
repeatMode = Player.REPEAT_MODE_OFF,
|
||||||
|
onClick = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
interactionSource = source,
|
||||||
|
)
|
||||||
|
RepeatButton(
|
||||||
|
repeatMode = Player.REPEAT_MODE_ALL,
|
||||||
|
onClick = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
interactionSource = source,
|
||||||
|
)
|
||||||
|
RepeatButton(
|
||||||
|
repeatMode = Player.REPEAT_MODE_ONE,
|
||||||
|
onClick = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
interactionSource = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
|
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.preferences.MoveButton
|
||||||
|
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun NowPlayingOverlay(
|
||||||
|
state: NowPlayingState,
|
||||||
|
player: Player,
|
||||||
|
current: AudioItem?,
|
||||||
|
queue: List<AudioItem>,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
onClickSong: (Int, AudioItem) -> Unit,
|
||||||
|
onLongClickSong: (Int, AudioItem) -> Unit,
|
||||||
|
onClickMore: () -> Unit,
|
||||||
|
onMoveQueue: (Int, MoveDirection) -> Unit,
|
||||||
|
onClickMoreItem: (Int, AudioItem) -> Unit,
|
||||||
|
onClickStop: () -> Unit,
|
||||||
|
lyricsFocusRequester: FocusRequester,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
|
||||||
|
var queueHasFocus by remember { mutableStateOf(false) }
|
||||||
|
val height by animateFloatAsState(
|
||||||
|
if (queueHasFocus) {
|
||||||
|
1f
|
||||||
|
} else {
|
||||||
|
.33f
|
||||||
|
},
|
||||||
|
animationSpec = tween(durationMillis = 500),
|
||||||
|
)
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
var showButtons by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val firstFocusRequester = remember { FocusRequester() }
|
||||||
|
BackHandler(!showButtons) {
|
||||||
|
scope.launch {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
firstFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxHeight(height),
|
||||||
|
) {
|
||||||
|
SeekBar(
|
||||||
|
player = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
onSeekProgress = {},
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
isEnabled = false,
|
||||||
|
intervals = 0,
|
||||||
|
seekBack = Duration.ZERO,
|
||||||
|
seekForward = Duration.ZERO,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.fillMaxWidth(.95f),
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showButtons,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
NowPlayingButtons(
|
||||||
|
player = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
initialFocusRequester = focusRequester,
|
||||||
|
onClickMore = onClickMore,
|
||||||
|
onClickStop = onClickStop,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
Text("No items")
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.queue),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onFocusChanged {
|
||||||
|
queueHasFocus = it.hasFocus
|
||||||
|
}.focusProperties {
|
||||||
|
onExit = {
|
||||||
|
if (requestedFocusDirection == FocusDirection.Up) focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
itemsIndexed(queue, key = { _, song -> song.key }) { index, song ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = .5f),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
).padding(end = 8.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) showButtons = index < 3
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}.animateItem(),
|
||||||
|
) {
|
||||||
|
SongListItem(
|
||||||
|
title = song.title,
|
||||||
|
artist = song.artistNames,
|
||||||
|
indexNumber = index + 1,
|
||||||
|
runtime = song.runtime?.roundSeconds,
|
||||||
|
showArtist = true,
|
||||||
|
isPlaying = current?.id == song.id,
|
||||||
|
onClick = { onClickSong.invoke(index, song) },
|
||||||
|
onLongClick = { onLongClickSong.invoke(index, song) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.focusRequester(firstFocusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.wrapContentWidth(),
|
||||||
|
) {
|
||||||
|
MoveButton(
|
||||||
|
icon = R.string.fa_caret_up,
|
||||||
|
enabled = index > 0,
|
||||||
|
onClick = { onMoveQueue.invoke(index, MoveDirection.UP) },
|
||||||
|
)
|
||||||
|
MoveButton(
|
||||||
|
icon = R.string.fa_caret_down,
|
||||||
|
enabled = index < queue.lastIndex,
|
||||||
|
onClick = { onMoveQueue.invoke(index, MoveDirection.DOWN) },
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { onClickMoreItem.invoke(index, song) },
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.more),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,480 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.view.Gravity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
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.AudioItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
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.LoadingPage
|
||||||
|
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.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun NowPlayingPage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: NowPlayingViewModel =
|
||||||
|
hiltViewModel<NowPlayingViewModel, NowPlayingViewModel.Factory>(
|
||||||
|
creationCallback = { it.create() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val player = viewModel.player
|
||||||
|
val queue =
|
||||||
|
rememberQueue(
|
||||||
|
player,
|
||||||
|
state.musicServiceState.queueVersion,
|
||||||
|
state.musicServiceState.queueSize,
|
||||||
|
)
|
||||||
|
val current = queue.getOrNull(state.musicServiceState.currentIndex)
|
||||||
|
val viz by viewModel.viz.collectAsState()
|
||||||
|
|
||||||
|
val controllerViewState = viewModel.controllerViewState
|
||||||
|
val preferences =
|
||||||
|
viewModel.userPreferencesService.flow
|
||||||
|
.collectAsState(
|
||||||
|
UserPreferences(
|
||||||
|
AppPreferences.getDefaultInstance(),
|
||||||
|
),
|
||||||
|
).value.appPreferences
|
||||||
|
val musicPrefs = preferences.musicPreferences
|
||||||
|
|
||||||
|
val keyHandler =
|
||||||
|
remember(preferences) {
|
||||||
|
PlaybackKeyHandler(
|
||||||
|
player = player,
|
||||||
|
controlsEnabled = true,
|
||||||
|
skipWithLeftRight = false,
|
||||||
|
seekForward = 30.seconds,
|
||||||
|
seekBack = 10.seconds,
|
||||||
|
// seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
|
// seekBack = preferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
updateSkipIndicator = {},
|
||||||
|
skipBackOnResume = null,
|
||||||
|
// skipBackOnResume = preferences.playbackPreferences.skipBackOnResume,
|
||||||
|
onInteraction = viewModel::reportInteraction,
|
||||||
|
oneClickPause = preferences.playbackPreferences.oneClickPause,
|
||||||
|
onStop = {
|
||||||
|
viewModel.stop()
|
||||||
|
},
|
||||||
|
onPlaybackDialogTypeClick = { },
|
||||||
|
getDurationMs = { player.duration },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 lyricsHaveFocus by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
val lyricsFocusRequester = remember { FocusRequester() }
|
||||||
|
val hasLyrics = musicPrefs.showLyrics && state.hasLyrics
|
||||||
|
|
||||||
|
LaunchedEffect(lyricsHaveFocus) {
|
||||||
|
if (lyricsHaveFocus) {
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(lyricsHaveFocus) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
var showRationaleDialog by remember { mutableStateOf(false) }
|
||||||
|
val launcher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { isGranted ->
|
||||||
|
viewModel.startVisualizer(isGranted, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier) {
|
||||||
|
Backdrop(
|
||||||
|
backdrop = state.backdropResult,
|
||||||
|
drawerIsOpen = false,
|
||||||
|
backdropStyle = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
enableTopScrim = false,
|
||||||
|
useExistingImageAsPlaceholder = true,
|
||||||
|
crossfadeDuration = 1.5.seconds,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onPreviewKeyEvent { key ->
|
||||||
|
if (!controllerViewState.controlsVisible &&
|
||||||
|
key.type == KeyEventType.KeyUp &&
|
||||||
|
isUp(key) &&
|
||||||
|
hasLyrics
|
||||||
|
) {
|
||||||
|
lyricsFocusRequester.tryRequestFocus()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
keyHandler.onKeyEvent(key)
|
||||||
|
}
|
||||||
|
}.focusRequester(focusRequester)
|
||||||
|
.focusable(),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.wrapContentWidth(),
|
||||||
|
) {
|
||||||
|
val enter =
|
||||||
|
remember {
|
||||||
|
expandHorizontally(expandFrom = Alignment.Start) + fadeIn()
|
||||||
|
}
|
||||||
|
val exit =
|
||||||
|
remember {
|
||||||
|
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
|
||||||
|
}
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = musicPrefs.showAlbumArt,
|
||||||
|
enter = enter,
|
||||||
|
exit = exit,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(32.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(.5f),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
contentDescription = null,
|
||||||
|
model = current?.imageUrl,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(240.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
|
)
|
||||||
|
current?.title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
current?.albumTitle?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
current?.artistNames?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = musicPrefs.showVisualizer,
|
||||||
|
enter = enter,
|
||||||
|
exit = exit,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.align(Alignment.CenterStart),
|
||||||
|
) {
|
||||||
|
val visualizerWidth by animateFloatAsState(if (musicPrefs.showLyrics && state.hasLyrics) .5f else 1f)
|
||||||
|
BarVisualizer(
|
||||||
|
data = viz,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight(.75f)
|
||||||
|
.fillMaxWidth(visualizerWidth)
|
||||||
|
.align(Alignment.CenterStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = musicPrefs.showLyrics && state.hasLyrics,
|
||||||
|
enter = expandHorizontally(expandFrom = Alignment.End),
|
||||||
|
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(lyricsFocusRequester),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 32.dp, vertical = 100.dp)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
) {
|
||||||
|
LyricsContent(
|
||||||
|
lyrics = state.lyrics,
|
||||||
|
currentLyricPosition = state.currentLyricIndex,
|
||||||
|
lyricsHaveFocus = lyricsHaveFocus,
|
||||||
|
onFocusLyrics = { lyricsHaveFocus = it },
|
||||||
|
onClick = {
|
||||||
|
it.start
|
||||||
|
?.ticks
|
||||||
|
?.inWholeMilliseconds
|
||||||
|
?.let { player.seekTo(it) }
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
// .width(360.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val showContextForItem =
|
||||||
|
remember {
|
||||||
|
{ fromLongClick: Boolean, index: Int, song: AudioItem ->
|
||||||
|
itemMoreDialog =
|
||||||
|
DialogParams(
|
||||||
|
title = song.title ?: "",
|
||||||
|
fromLongClick = fromLongClick,
|
||||||
|
items =
|
||||||
|
buildMoreDialogForMusicQueue(
|
||||||
|
context = context,
|
||||||
|
actions = actions,
|
||||||
|
item = song,
|
||||||
|
index = index,
|
||||||
|
canRemove = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(controllerViewState.controlsVisible) {
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerViewState.controlsVisible,
|
||||||
|
enter = slideInVertically { it },
|
||||||
|
exit = slideOutVertically { it },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter),
|
||||||
|
) {
|
||||||
|
NowPlayingOverlay(
|
||||||
|
state = state,
|
||||||
|
player = player,
|
||||||
|
current = current,
|
||||||
|
queue = queue,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) },
|
||||||
|
onClickMore = { showViewOptionsDialog = true },
|
||||||
|
onClickSong = { index, _ -> viewModel.play(index) },
|
||||||
|
onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) },
|
||||||
|
onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) },
|
||||||
|
onClickStop = { viewModel.stop() },
|
||||||
|
lyricsFocusRequester = lyricsFocusRequester,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.drawBehind {
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(Color.Transparent, Color.Black),
|
||||||
|
startY = 0f,
|
||||||
|
endY = size.height,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.musicServiceState.loadingState is LoadingState.Loading) {
|
||||||
|
LoadingPage(focusEnabled = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemMoreDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { itemMoreDialog = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showViewOptionsDialog) {
|
||||||
|
MusicViewOptionsDialog(
|
||||||
|
appPreferences = preferences,
|
||||||
|
onDismissRequest = { showViewOptionsDialog = false },
|
||||||
|
onViewOptionsChange = { viewModel.updatePreferences(it) },
|
||||||
|
onEnableVisualizer = {
|
||||||
|
val showRationale =
|
||||||
|
context
|
||||||
|
.findActivity()
|
||||||
|
?.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) == true
|
||||||
|
when {
|
||||||
|
!state.visualizerPermissions && showRationale -> {
|
||||||
|
showRationaleDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
!state.visualizerPermissions -> {
|
||||||
|
launcher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
viewModel.startVisualizer(true, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showRationaleDialog) {
|
||||||
|
RecordAudioRationaleDialog(
|
||||||
|
onDismissRequest = { showRationaleDialog = false },
|
||||||
|
onClick = {
|
||||||
|
showRationaleDialog = false
|
||||||
|
launcher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NowPlayingBottomDialog(
|
||||||
|
showDebugInfo: Boolean,
|
||||||
|
lyricsActive: Boolean,
|
||||||
|
songHasLyrics: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onClickShowDebug: () -> Unit,
|
||||||
|
onClickLyrics: () -> Unit,
|
||||||
|
) {
|
||||||
|
val choices =
|
||||||
|
mapOf(
|
||||||
|
BottomDialogItem(
|
||||||
|
data = 0,
|
||||||
|
headline = stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
|
||||||
|
supporting = null,
|
||||||
|
) to onClickShowDebug,
|
||||||
|
BottomDialogItem(
|
||||||
|
data = 0,
|
||||||
|
headline = stringResource(if (lyricsActive) R.string.hide_lyrics else R.string.show_lyrics),
|
||||||
|
supporting = if (songHasLyrics) stringResource(R.string.song_has_lyrics) else null,
|
||||||
|
) to onClickLyrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
BottomDialog(
|
||||||
|
choices = choices.keys.toList(),
|
||||||
|
onDismissRequest = {
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
},
|
||||||
|
onSelectChoice = { _, choice ->
|
||||||
|
choices[choice]?.invoke()
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
},
|
||||||
|
gravity = Gravity.START,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordAudioRationaleDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
BasicDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.visualizer_rationale),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.continue_string),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropResult
|
||||||
|
import com.github.damontecres.wholphin.services.MusicServiceState
|
||||||
|
import org.jellyfin.sdk.model.api.LyricDto
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class NowPlayingState(
|
||||||
|
val musicServiceState: MusicServiceState,
|
||||||
|
val lyrics: LyricDto? = null,
|
||||||
|
val currentLyricIndex: Int? = null,
|
||||||
|
val visualizerPermissions: Boolean = false,
|
||||||
|
val backdropResult: BackdropResult = BackdropResult.NONE,
|
||||||
|
) {
|
||||||
|
val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.audiofx.Visualizer
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropResult
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.NowPlayingStatus
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.mayakapps.kache.InMemoryKache
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.lyricsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.LyricDto
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
|
||||||
|
class NowPlayingViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val musicService: MusicService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
|
val navigationManager: NavigationManager,
|
||||||
|
val userPreferencesService: UserPreferencesService,
|
||||||
|
) : ViewModel(),
|
||||||
|
Visualizer.OnDataCaptureListener,
|
||||||
|
Player.Listener {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(): NowPlayingViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val visualizerMutex = Mutex()
|
||||||
|
private var visualizer: Visualizer? = null
|
||||||
|
|
||||||
|
val controllerViewState =
|
||||||
|
ControllerViewState(
|
||||||
|
AppPreference.ControllerTimeout.defaultValue,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = MutableStateFlow(NowPlayingState(musicService.state.value))
|
||||||
|
val player get() = musicService.player
|
||||||
|
|
||||||
|
val viz = MutableStateFlow<IntArray>(IntArray(0))
|
||||||
|
|
||||||
|
private val lyricCache =
|
||||||
|
InMemoryKache<UUID, LyricDto>(20) {
|
||||||
|
creationScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
player.addListener(this)
|
||||||
|
addCloseable {
|
||||||
|
player.removeListener(this)
|
||||||
|
visualizer?.release()
|
||||||
|
}
|
||||||
|
val visualizerPermissions =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
startVisualizer(visualizerPermissions, false)
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.state.collectLatest { musicServiceState ->
|
||||||
|
if (musicServiceState.status != NowPlayingStatus.IDLE) {
|
||||||
|
visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING
|
||||||
|
}
|
||||||
|
|
||||||
|
state.update { it.copy(musicServiceState = musicServiceState) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
viewModelScope
|
||||||
|
.launchDefault {
|
||||||
|
controllerViewState.observe()
|
||||||
|
}.join()
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
updateBackdrop(getCurrent())
|
||||||
|
}
|
||||||
|
playbackLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportInteraction() {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrent(): AudioItem? {
|
||||||
|
val mediaItem =
|
||||||
|
onMain {
|
||||||
|
player.currentMediaItemIndex
|
||||||
|
.takeIf { it in 0..<player.mediaItemCount }
|
||||||
|
?.let { player.getMediaItemAt(it) }
|
||||||
|
}
|
||||||
|
return mediaItem?.localConfiguration?.tag as? AudioItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playbackLoop() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
while (isActive) {
|
||||||
|
val position = onMain { player.currentPosition }.milliseconds
|
||||||
|
// Timber.v("playbackLoop: %s", position)
|
||||||
|
getCurrent()?.let { audio ->
|
||||||
|
// Timber.v("Got current %s", audio.id)
|
||||||
|
if (audio.hasLyrics) {
|
||||||
|
val lyrics =
|
||||||
|
lyricCache.getOrPut(audio.id) {
|
||||||
|
// TODO remote lyrics?
|
||||||
|
api.lyricsApi.getLyrics(audio.id).content
|
||||||
|
}
|
||||||
|
val lyricIndex =
|
||||||
|
if (lyrics != null) {
|
||||||
|
val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO
|
||||||
|
val lyricPosition = offset + position
|
||||||
|
lyrics.lyrics
|
||||||
|
.indexOfLast {
|
||||||
|
it.start?.ticks?.let { lyricPosition >= it } == true
|
||||||
|
}.takeIf { it >= 0 }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// Timber.v("lyricIndex=$lyricIndex")
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
lyrics = lyrics,
|
||||||
|
currentLyricIndex = lyricIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaItemTransition(
|
||||||
|
mediaItem: MediaItem?,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
val audio = mediaItem?.localConfiguration?.tag as? AudioItem
|
||||||
|
Timber.v("onMediaItemTransition to %s", audio?.id)
|
||||||
|
updateBackdrop(audio)
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
lyrics = null,
|
||||||
|
currentLyricIndex = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
audio?.let { audio ->
|
||||||
|
if (audio.hasLyrics) {
|
||||||
|
val lyrics =
|
||||||
|
lyricCache.getOrPut(audio.id) {
|
||||||
|
// TODO remote lyrics?
|
||||||
|
api.lyricsApi.getLyrics(audio.id).content
|
||||||
|
}
|
||||||
|
Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null)
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
lyrics = lyrics,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backDropJob: Job? = null
|
||||||
|
|
||||||
|
private fun updateBackdrop(audio: AudioItem?) {
|
||||||
|
backDropJob?.cancel()
|
||||||
|
backDropJob =
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val showBackdrop =
|
||||||
|
userPreferencesService
|
||||||
|
.getCurrent()
|
||||||
|
.appPreferences.musicPreferences.showBackdrop
|
||||||
|
if (showBackdrop) {
|
||||||
|
var backdropItem: BaseItem? = null
|
||||||
|
try {
|
||||||
|
if (audio?.artistId != null) {
|
||||||
|
api.userLibraryApi.getItem(audio.artistId).content.let {
|
||||||
|
if (it.backdropImageTags?.isNotEmpty() == true) {
|
||||||
|
backdropItem = BaseItem(it, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (backdropItem == null && audio?.albumId != null) {
|
||||||
|
api.userLibraryApi.getItem(audio.albumId).content.let {
|
||||||
|
backdropItem =
|
||||||
|
getBackdropItemForAlbum(api, BaseItem(it, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (backdropItem != null) {
|
||||||
|
doUpdateBackdrop(backdropItem)
|
||||||
|
} else {
|
||||||
|
doUpdateBackdropRandom()
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching backdrop")
|
||||||
|
doUpdateBackdropRandom()
|
||||||
|
}
|
||||||
|
delay(60.seconds)
|
||||||
|
doUpdateBackdropRandom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doUpdateBackdropRandom() {
|
||||||
|
val randomArtist =
|
||||||
|
api.itemsApi
|
||||||
|
.getItems(
|
||||||
|
recursive = true,
|
||||||
|
imageTypes = listOf(ImageType.BACKDROP),
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
|
||||||
|
sortBy = listOf(ItemSortBy.RANDOM),
|
||||||
|
limit = 1,
|
||||||
|
).content.items
|
||||||
|
.firstOrNull()
|
||||||
|
if (randomArtist != null) {
|
||||||
|
doUpdateBackdrop(BaseItem(randomArtist))
|
||||||
|
} else {
|
||||||
|
clearBackdrop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doUpdateBackdrop(item: BaseItem) {
|
||||||
|
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||||
|
val (primaryColor, secondaryColor, tertiaryColor) =
|
||||||
|
backdropService.extractColorsFromBackdrop(
|
||||||
|
imageUrl,
|
||||||
|
)
|
||||||
|
val backdropResult =
|
||||||
|
BackdropResult(
|
||||||
|
itemId = item.id.toString(),
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
primaryColor = primaryColor,
|
||||||
|
secondaryColor = secondaryColor,
|
||||||
|
tertiaryColor = tertiaryColor,
|
||||||
|
)
|
||||||
|
state.update { it.copy(backdropResult = backdropResult) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearBackdrop() {
|
||||||
|
state.update { it.copy(backdropResult = BackdropResult.NONE) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveQueue(
|
||||||
|
index: Int,
|
||||||
|
direction: MoveDirection,
|
||||||
|
) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) }
|
||||||
|
|
||||||
|
fun play(index: Int) = viewModelScope.launchDefault { musicService.playIndex(index) }
|
||||||
|
|
||||||
|
fun playNext(index: Int) = viewModelScope.launchDefault { musicService.moveQueue(index, 1) }
|
||||||
|
|
||||||
|
fun removeFromQueue(index: Int) = viewModelScope.launchDefault { musicService.removeFromQueue(index) }
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.stop()
|
||||||
|
navigationManager.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFftDataCapture(
|
||||||
|
visualizer: Visualizer,
|
||||||
|
fft: ByteArray,
|
||||||
|
samplingRate: Int,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWaveFormDataCapture(
|
||||||
|
visualizer: Visualizer,
|
||||||
|
waveform: ByteArray,
|
||||||
|
samplingRate: Int,
|
||||||
|
) {
|
||||||
|
val resolution = 96
|
||||||
|
val captureSize =
|
||||||
|
Visualizer.getCaptureSizeRange()[1]
|
||||||
|
val groupSize = (captureSize / resolution.toFloat()).toInt()
|
||||||
|
val processed =
|
||||||
|
waveform
|
||||||
|
.toList()
|
||||||
|
.chunked(groupSize)
|
||||||
|
.map { it.average().toInt() + 128 }
|
||||||
|
.toIntArray()
|
||||||
|
viz.update { processed }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePreferences(prefs: AppPreferences) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
var backdropChanged = false
|
||||||
|
preferencesDataStore.updateData {
|
||||||
|
backdropChanged =
|
||||||
|
it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop
|
||||||
|
prefs
|
||||||
|
}
|
||||||
|
if (backdropChanged) {
|
||||||
|
if (prefs.musicPreferences.showBackdrop) {
|
||||||
|
updateBackdrop(getCurrent())
|
||||||
|
} else {
|
||||||
|
clearBackdrop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initVisualizer() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
visualizerMutex.withLock {
|
||||||
|
val prefs = preferencesDataStore.data.first()
|
||||||
|
if (visualizer == null &&
|
||||||
|
state.value.visualizerPermissions &&
|
||||||
|
prefs.musicPreferences.showVisualizer
|
||||||
|
) {
|
||||||
|
Timber.v("Creating visualizer")
|
||||||
|
visualizer =
|
||||||
|
Visualizer(onMain { player.audioSessionId }).apply {
|
||||||
|
captureSize = Visualizer.getCaptureSizeRange()[1]
|
||||||
|
setDataCaptureListener(
|
||||||
|
this@NowPlayingViewModel,
|
||||||
|
Visualizer.getMaxCaptureRate() / 3,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startVisualizer(
|
||||||
|
permissionGranted: Boolean,
|
||||||
|
updatePreferences: Boolean,
|
||||||
|
) {
|
||||||
|
Timber.v("startVisualizer: permissionGranted=%s", permissionGranted)
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
visualizerPermissions = permissionGranted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
if (updatePreferences || !permissionGranted) {
|
||||||
|
preferencesDataStore.updateData {
|
||||||
|
it.updateMusicPreferences { showVisualizer = permissionGranted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initVisualizer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
|
import androidx.tv.material3.ListItemDefaults
|
||||||
|
import androidx.tv.material3.LocalContentColor
|
||||||
|
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.PreviewTvSpec
|
||||||
|
import com.github.damontecres.wholphin.ui.components.Button
|
||||||
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SongListItem(
|
||||||
|
song: BaseItem?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onClickMore: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showArtist: Boolean = false,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
|
isQueued: Boolean = false,
|
||||||
|
) = SongListItem(
|
||||||
|
title = song?.title,
|
||||||
|
artist = if (showArtist) song?.data?.albumArtist else null,
|
||||||
|
indexNumber = song?.data?.indexNumber,
|
||||||
|
runtime =
|
||||||
|
remember(song) {
|
||||||
|
song
|
||||||
|
?.data
|
||||||
|
?.runTimeTicks
|
||||||
|
?.ticks
|
||||||
|
?.roundSeconds
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = modifier,
|
||||||
|
showArtist = showArtist,
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
isQueued = isQueued,
|
||||||
|
showMoreButton = true,
|
||||||
|
onClickMore = onClickMore,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SongListItem(
|
||||||
|
title: String?,
|
||||||
|
artist: String?,
|
||||||
|
indexNumber: Int?,
|
||||||
|
runtime: Duration?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showArtist: Boolean = false,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
|
isQueued: Boolean = false,
|
||||||
|
showMoreButton: Boolean = false,
|
||||||
|
onClickMore: () -> Unit = {},
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val leadingContent: @Composable (BoxScope.() -> Unit) = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = indexNumber?.toString() ?: "",
|
||||||
|
)
|
||||||
|
MusicQueueMarker(
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
isQueued = isQueued,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val headlineContent = @Composable {
|
||||||
|
Text(
|
||||||
|
text = title ?: "",
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.enableMarquee(focused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val trailingContent = @Composable {
|
||||||
|
Text(
|
||||||
|
text = runtime.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showArtist) {
|
||||||
|
// TODO use dense?
|
||||||
|
ListItem(
|
||||||
|
selected = isPlaying,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
leadingContent = leadingContent,
|
||||||
|
headlineContent = headlineContent,
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = artist ?: "",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = trailingContent,
|
||||||
|
scale = ListItemDefaults.scale(1f, 1f, .95f),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ListItem(
|
||||||
|
selected = isPlaying,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
leadingContent = leadingContent,
|
||||||
|
headlineContent = headlineContent,
|
||||||
|
supportingContent = null,
|
||||||
|
trailingContent = trailingContent,
|
||||||
|
scale = ListItemDefaults.scale(1f, 1f, .95f),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showMoreButton) {
|
||||||
|
Button(
|
||||||
|
onClick = onClickMore,
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.more),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an indicator for if the item is currently playing or queued
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MusicQueueMarker(
|
||||||
|
isPlaying: Boolean,
|
||||||
|
isQueued: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (isPlaying) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else if (isQueued) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(LocalContentColor.current)
|
||||||
|
.size(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
fun SongListItemPreview() {
|
||||||
|
WholphinTheme {
|
||||||
|
Column {
|
||||||
|
SongListItem(
|
||||||
|
title = "Song title",
|
||||||
|
artist = "Artists",
|
||||||
|
indexNumber = 1,
|
||||||
|
runtime = 2.minutes + 30.seconds,
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = { },
|
||||||
|
modifier = Modifier,
|
||||||
|
showArtist = false,
|
||||||
|
)
|
||||||
|
SongListItem(
|
||||||
|
title = "Song title",
|
||||||
|
artist = "Artists",
|
||||||
|
indexNumber = 1,
|
||||||
|
runtime = 2.minutes + 30.seconds,
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = { },
|
||||||
|
modifier = Modifier,
|
||||||
|
showArtist = false,
|
||||||
|
isQueued = true,
|
||||||
|
showMoreButton = true,
|
||||||
|
)
|
||||||
|
SongListItem(
|
||||||
|
title = "Song title",
|
||||||
|
artist = "Artists",
|
||||||
|
indexNumber = 2,
|
||||||
|
runtime = 2.minutes + 30.seconds,
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = { },
|
||||||
|
modifier = Modifier,
|
||||||
|
showArtist = true,
|
||||||
|
isPlaying = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
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
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
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,
|
||||||
|
): ApiRequestPager<GetItemsRequest> {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = albumId,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
sortBy =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.PARENT_INDEX_NUMBER,
|
||||||
|
ItemSortBy.INDEX_NUMBER,
|
||||||
|
ItemSortBy.SORT_NAME,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ViewModel.getPagerForArtist(
|
||||||
|
api: ApiClient,
|
||||||
|
artistId: UUID,
|
||||||
|
): ApiRequestPager<GetItemsRequest> {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
artistIds = listOf(artistId),
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
// TODO better sort
|
||||||
|
sortBy =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.PARENT_INDEX_NUMBER,
|
||||||
|
ItemSortBy.INDEX_NUMBER,
|
||||||
|
ItemSortBy.SORT_NAME,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ViewModel.getPagerForPlaylist(
|
||||||
|
api: ApiClient,
|
||||||
|
playlistId: UUID,
|
||||||
|
): ApiRequestPager<GetItemsRequest> {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = playlistId,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
sortBy =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.DEFAULT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||||
|
}
|
||||||
|
|
@ -132,6 +132,14 @@ fun SeriesDetails(
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
|
|
||||||
|
LifecycleResumeEffect(destination.itemId) {
|
||||||
|
viewModel.refresh()
|
||||||
|
|
||||||
|
onPauseOrDispose {
|
||||||
|
viewModel.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(state, modifier)
|
||||||
|
|
@ -148,9 +156,7 @@ fun SeriesDetails(
|
||||||
LifecycleResumeEffect(destination.itemId) {
|
LifecycleResumeEffect(destination.itemId) {
|
||||||
viewModel.onResumePage()
|
viewModel.onResumePage()
|
||||||
|
|
||||||
onPauseOrDispose {
|
onPauseOrDispose {}
|
||||||
viewModel.release()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val played = item.data.userData?.played ?: false
|
val played = item.data.userData?.played ?: false
|
||||||
|
|
|
||||||
|
|
@ -271,9 +271,9 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onResumePage() {
|
fun onResumePage() {
|
||||||
viewModelScope.launchIO {
|
item.value?.let { item ->
|
||||||
item.value?.let {
|
viewModelScope.launchDefault { backdropService.submit(item) }
|
||||||
backdropService.submit(it)
|
viewModelScope.launchIO {
|
||||||
val playThemeSongs =
|
val playThemeSongs =
|
||||||
userPreferencesService
|
userPreferencesService
|
||||||
.getCurrent()
|
.getCurrent()
|
||||||
|
|
@ -283,6 +283,17 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
item.value?.let { item ->
|
||||||
|
if (loading.value == LoadingState.Success) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val seasons = getSeasons(item, null).await()
|
||||||
|
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
themeSongPlayer.stop()
|
themeSongPlayer.stop()
|
||||||
}
|
}
|
||||||
|
|
@ -292,6 +303,7 @@ class SeriesViewModel
|
||||||
seasonNum: Int?,
|
seasonNum: Int?,
|
||||||
): Deferred<List<BaseItem?>> =
|
): Deferred<List<BaseItem?>> =
|
||||||
viewModelScope.async(Dispatchers.IO) {
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
|
Timber.v("getSeasons for %s", series.id)
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = series.id,
|
parentId = series.id,
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,15 @@ fun HomePageContent(
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
takeFocus: Boolean = true,
|
takeFocus: Boolean = true,
|
||||||
showEmptyRows: Boolean = false,
|
showEmptyRows: Boolean = false,
|
||||||
|
headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem ->
|
||||||
|
HomePageHeader(
|
||||||
|
item = focusedItem,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
||||||
|
.fillMaxHeight(.33f),
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
val focusedItem =
|
val focusedItem =
|
||||||
position.let {
|
position.let {
|
||||||
|
|
@ -266,13 +275,8 @@ fun HomePageContent(
|
||||||
}
|
}
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
HomePageHeader(
|
headerComposable.invoke(focusedItem)
|
||||||
item = focusedItem,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
|
||||||
.fillMaxHeight(.33f),
|
|
||||||
)
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val spaceAbovePx =
|
val spaceAbovePx =
|
||||||
with(density) {
|
with(density) {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.SeerrService
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
|
|
@ -76,6 +77,8 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -100,10 +103,15 @@ class SearchViewModel
|
||||||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
val albums = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
|
||||||
private var currentQuery: String? = null
|
private var currentQuery: String? = null
|
||||||
|
|
||||||
|
private val semaphore = Semaphore(4)
|
||||||
|
|
||||||
fun search(query: String?) {
|
fun search(query: String?) {
|
||||||
if (currentQuery == query) {
|
if (currentQuery == query) {
|
||||||
return
|
return
|
||||||
|
|
@ -117,6 +125,9 @@ class SearchViewModel
|
||||||
searchInternal(query, BaseItemKind.MOVIE, movies)
|
searchInternal(query, BaseItemKind.MOVIE, movies)
|
||||||
searchInternal(query, BaseItemKind.SERIES, series)
|
searchInternal(query, BaseItemKind.SERIES, series)
|
||||||
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
||||||
|
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
|
||||||
|
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
|
||||||
|
searchInternal(query, BaseItemKind.AUDIO, songs)
|
||||||
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
||||||
searchSeerr(query)
|
searchSeerr(query)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -135,19 +146,21 @@ class SearchViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val request =
|
semaphore.withPermit {
|
||||||
GetItemsRequest(
|
val request =
|
||||||
searchTerm = query,
|
GetItemsRequest(
|
||||||
recursive = true,
|
searchTerm = query,
|
||||||
includeItemTypes = listOf(type),
|
recursive = true,
|
||||||
fields = SlimItemFields,
|
includeItemTypes = listOf(type),
|
||||||
limit = 25,
|
fields = SlimItemFields,
|
||||||
)
|
limit = 25,
|
||||||
val pager =
|
)
|
||||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
val pager =
|
||||||
pager.init()
|
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
||||||
withContext(Dispatchers.Main) {
|
pager.init()
|
||||||
target.value = SearchResult.Success(pager)
|
withContext(Dispatchers.Main) {
|
||||||
|
target.value = SearchResult.Success(pager)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception searching for $type")
|
Timber.e(ex, "Exception searching for $type")
|
||||||
|
|
@ -202,10 +215,13 @@ sealed interface SearchResult {
|
||||||
|
|
||||||
private const val SEARCH_ROW = 0
|
private const val SEARCH_ROW = 0
|
||||||
private const val MOVIE_ROW = SEARCH_ROW + 1
|
private const val MOVIE_ROW = SEARCH_ROW + 1
|
||||||
private const val COLLECTION_ROW = MOVIE_ROW + 1
|
private const val SERIES_ROW = MOVIE_ROW + 1
|
||||||
private const val SERIES_ROW = COLLECTION_ROW + 1
|
|
||||||
private const val EPISODE_ROW = SERIES_ROW + 1
|
private const val EPISODE_ROW = SERIES_ROW + 1
|
||||||
private const val SEERR_ROW = EPISODE_ROW + 1
|
private const val ALBUM_ROW = EPISODE_ROW + 1
|
||||||
|
private const val ARTIST_ROW = ALBUM_ROW + 1
|
||||||
|
private const val SONG_ROW = ARTIST_ROW + 1
|
||||||
|
private const val COLLECTION_ROW = SONG_ROW + 1
|
||||||
|
private const val SEERR_ROW = COLLECTION_ROW + 1
|
||||||
|
|
||||||
/** Delay for focus to settle after voice search dialog dismisses. */
|
/** Delay for focus to settle after voice search dialog dismisses. */
|
||||||
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
|
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
|
||||||
|
|
@ -223,6 +239,9 @@ fun SearchPage(
|
||||||
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
||||||
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
||||||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
||||||
|
val albums by viewModel.albums.observeAsState(SearchResult.NoQuery)
|
||||||
|
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
|
||||||
|
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
|
||||||
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
||||||
|
|
||||||
// val query = rememberTextFieldState()
|
// val query = rememberTextFieldState()
|
||||||
|
|
@ -367,16 +386,6 @@ fun SearchPage(
|
||||||
onClickPosition = { position = it },
|
onClickPosition = { position = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
searchResultRow(
|
|
||||||
title = context.getString(R.string.collections),
|
|
||||||
result = collections,
|
|
||||||
rowIndex = COLLECTION_ROW,
|
|
||||||
position = position,
|
|
||||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
|
||||||
onClickItem = onClickItem,
|
|
||||||
onClickPosition = { position = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = context.getString(R.string.tv_shows),
|
title = context.getString(R.string.tv_shows),
|
||||||
result = series,
|
result = series,
|
||||||
|
|
@ -409,6 +418,85 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
searchResultRow(
|
||||||
|
title = context.getString(R.string.albums),
|
||||||
|
result = albums,
|
||||||
|
rowIndex = ALBUM_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequesters[ALBUM_ROW],
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
|
SeasonCard(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
position = RowColumn(ALBUM_ROW, index)
|
||||||
|
onClick.invoke()
|
||||||
|
},
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
imageHeight = Cards.heightEpisode,
|
||||||
|
aspectRatio = AspectRatios.SQUARE,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
searchResultRow(
|
||||||
|
title = context.getString(R.string.artists),
|
||||||
|
result = artists,
|
||||||
|
rowIndex = COLLECTION_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
|
SeasonCard(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
position = RowColumn(ALBUM_ROW, index)
|
||||||
|
onClick.invoke()
|
||||||
|
},
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
imageHeight = Cards.heightEpisode,
|
||||||
|
aspectRatio = AspectRatios.SQUARE,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
searchResultRow(
|
||||||
|
title = context.getString(R.string.songs),
|
||||||
|
result = songs,
|
||||||
|
rowIndex = SONG_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequesters[SONG_ROW],
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
|
SeasonCard(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
position = RowColumn(ALBUM_ROW, index)
|
||||||
|
onClick.invoke()
|
||||||
|
},
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
imageHeight = Cards.heightEpisode,
|
||||||
|
aspectRatio = AspectRatios.SQUARE,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
searchResultRow(
|
||||||
|
title = context.getString(R.string.collections),
|
||||||
|
result = collections,
|
||||||
|
rowIndex = COLLECTION_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = context.getString(R.string.discover),
|
title = context.getString(R.string.discover),
|
||||||
result = seerrResults,
|
result = seerrResults,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ data class HomeRowPresets(
|
||||||
val tvLibrary: HomeRowViewOptions,
|
val tvLibrary: HomeRowViewOptions,
|
||||||
val videoLibrary: HomeRowViewOptions,
|
val videoLibrary: HomeRowViewOptions,
|
||||||
val photoLibrary: HomeRowViewOptions,
|
val photoLibrary: HomeRowViewOptions,
|
||||||
|
val musicLibrary: HomeRowViewOptions,
|
||||||
val playlist: HomeRowViewOptions,
|
val playlist: HomeRowViewOptions,
|
||||||
val liveTv: HomeRowViewOptions,
|
val liveTv: HomeRowViewOptions,
|
||||||
val genreSize: Int,
|
val genreSize: Int,
|
||||||
|
|
@ -51,8 +52,9 @@ data class HomeRowPresets(
|
||||||
|
|
||||||
CollectionType.LIVETV -> liveTv
|
CollectionType.LIVETV -> liveTv
|
||||||
|
|
||||||
|
CollectionType.MUSIC -> musicLibrary
|
||||||
|
|
||||||
CollectionType.UNKNOWN,
|
CollectionType.UNKNOWN,
|
||||||
CollectionType.MUSIC,
|
|
||||||
CollectionType.BOOKS,
|
CollectionType.BOOKS,
|
||||||
CollectionType.PLAYLISTS,
|
CollectionType.PLAYLISTS,
|
||||||
CollectionType.FOLDERS,
|
CollectionType.FOLDERS,
|
||||||
|
|
@ -74,6 +76,10 @@ data class HomeRowPresets(
|
||||||
aspectRatio = AspectRatio.WIDE,
|
aspectRatio = AspectRatio.WIDE,
|
||||||
contentScale = PrefContentScale.CROP,
|
contentScale = PrefContentScale.CROP,
|
||||||
),
|
),
|
||||||
|
musicLibrary =
|
||||||
|
HomeRowViewOptions(
|
||||||
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
),
|
||||||
playlist =
|
playlist =
|
||||||
HomeRowViewOptions(
|
HomeRowViewOptions(
|
||||||
aspectRatio = AspectRatio.SQUARE,
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
|
@ -111,6 +117,11 @@ data class HomeRowPresets(
|
||||||
aspectRatio = AspectRatio.WIDE,
|
aspectRatio = AspectRatio.WIDE,
|
||||||
contentScale = PrefContentScale.CROP,
|
contentScale = PrefContentScale.CROP,
|
||||||
),
|
),
|
||||||
|
musicLibrary =
|
||||||
|
HomeRowViewOptions(
|
||||||
|
heightDp = epHeight,
|
||||||
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
),
|
||||||
playlist =
|
playlist =
|
||||||
HomeRowViewOptions(
|
HomeRowViewOptions(
|
||||||
heightDp = epHeight,
|
heightDp = epHeight,
|
||||||
|
|
@ -154,6 +165,11 @@ data class HomeRowPresets(
|
||||||
aspectRatio = AspectRatio.WIDE,
|
aspectRatio = AspectRatio.WIDE,
|
||||||
contentScale = PrefContentScale.CROP,
|
contentScale = PrefContentScale.CROP,
|
||||||
),
|
),
|
||||||
|
musicLibrary =
|
||||||
|
HomeRowViewOptions(
|
||||||
|
heightDp = epHeight,
|
||||||
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
),
|
||||||
playlist =
|
playlist =
|
||||||
HomeRowViewOptions(
|
HomeRowViewOptions(
|
||||||
heightDp = epHeight,
|
heightDp = epHeight,
|
||||||
|
|
@ -198,6 +214,11 @@ data class HomeRowPresets(
|
||||||
aspectRatio = AspectRatio.WIDE,
|
aspectRatio = AspectRatio.WIDE,
|
||||||
contentScale = PrefContentScale.CROP,
|
contentScale = PrefContentScale.CROP,
|
||||||
),
|
),
|
||||||
|
musicLibrary =
|
||||||
|
HomeRowViewOptions(
|
||||||
|
heightDp = epHeight,
|
||||||
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
),
|
||||||
playlist =
|
playlist =
|
||||||
HomeRowViewOptions(
|
HomeRowViewOptions(
|
||||||
heightDp = epHeight,
|
heightDp = epHeight,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ val favoriteOptions by lazy {
|
||||||
BaseItemKind.VIDEO to R.string.videos,
|
BaseItemKind.VIDEO to R.string.videos,
|
||||||
BaseItemKind.PLAYLIST to R.string.playlists,
|
BaseItemKind.PLAYLIST to R.string.playlists,
|
||||||
BaseItemKind.PERSON to R.string.people,
|
BaseItemKind.PERSON to R.string.people,
|
||||||
|
BaseItemKind.MUSIC_ARTIST to R.string.artists,
|
||||||
|
BaseItemKind.MUSIC_ALBUM to R.string.albums,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
|
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionEx
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
import com.github.damontecres.wholphin.services.tvAccess
|
import com.github.damontecres.wholphin.services.tvAccess
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
|
@ -122,19 +123,23 @@ class HomeSettingsViewModel
|
||||||
state.value
|
state.value
|
||||||
.let { state ->
|
.let { state ->
|
||||||
state.rows
|
state.rows
|
||||||
.map { it.config }
|
|
||||||
.map { row ->
|
.map { row ->
|
||||||
viewModelScope.async(Dispatchers.IO) {
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
homeSettingsService.fetchDataForRow(
|
try {
|
||||||
row = row,
|
homeSettingsService.fetchDataForRow(
|
||||||
scope = viewModelScope,
|
row = row.config,
|
||||||
prefs = prefs,
|
scope = viewModelScope,
|
||||||
userDto = userDto,
|
prefs = prefs,
|
||||||
libraries = state.libraries,
|
userDto = userDto,
|
||||||
limit = limit,
|
libraries = state.libraries,
|
||||||
isRefresh = false,
|
limit = limit,
|
||||||
)
|
isRefresh = false,
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error on row %s", row)
|
||||||
|
HomeRowLoadingState.Error(row.title, exception = ex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +200,12 @@ class HomeSettingsViewModel
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
updateState {
|
updateState {
|
||||||
val rows = it.rows.toMutableList().apply { removeAt(index) }
|
val rows = it.rows.toMutableList().apply { removeAt(index) }
|
||||||
val rowData = it.rowData.toMutableList().apply { removeAt(index) }
|
val rowData =
|
||||||
|
if (index in it.rowData.indices) {
|
||||||
|
it.rowData.toMutableList().apply { removeAt(index) }
|
||||||
|
} else {
|
||||||
|
it.rowData
|
||||||
|
}
|
||||||
it.copy(
|
it.copy(
|
||||||
rows = rows,
|
rows = rows,
|
||||||
rowData = rowData,
|
rowData = rowData,
|
||||||
|
|
@ -258,6 +268,24 @@ class HomeSettingsViewModel
|
||||||
rowType: LibraryRowType,
|
rowType: LibraryRowType,
|
||||||
): Job =
|
): Job =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val viewOptions =
|
||||||
|
when (library.collectionType) {
|
||||||
|
CollectionType.MUSIC -> {
|
||||||
|
HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.HOMEVIDEOS,
|
||||||
|
CollectionType.MUSICVIDEOS,
|
||||||
|
-> {
|
||||||
|
HomeRowViewOptions(
|
||||||
|
aspectRatio = AspectRatio.WIDE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
HomeRowViewOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
val id = idCounter++
|
val id = idCounter++
|
||||||
val newRow =
|
val newRow =
|
||||||
when (rowType) {
|
when (rowType) {
|
||||||
|
|
@ -267,7 +295,7 @@ class HomeSettingsViewModel
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
config = RecentlyAdded(library.itemId),
|
config = RecentlyAdded(library.itemId, viewOptions),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,7 +310,7 @@ class HomeSettingsViewModel
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
config = RecentlyReleased(library.itemId),
|
config = RecentlyReleased(library.itemId, viewOptions),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,7 +329,7 @@ class HomeSettingsViewModel
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
config = Suggestions(library.itemId),
|
config = Suggestions(library.itemId, viewOptions),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -662,6 +690,7 @@ class HomeSettingsViewModel
|
||||||
BaseItemKind.VIDEO -> preset.videoLibrary
|
BaseItemKind.VIDEO -> preset.videoLibrary
|
||||||
BaseItemKind.PLAYLIST -> preset.playlist
|
BaseItemKind.PLAYLIST -> preset.playlist
|
||||||
BaseItemKind.PERSON -> preset.movieLibrary
|
BaseItemKind.PERSON -> preset.movieLibrary
|
||||||
|
BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary
|
||||||
else -> preset.movieLibrary
|
else -> preset.movieLibrary
|
||||||
}
|
}
|
||||||
it.config.updateViewOptions(viewOptions)
|
it.config.updateViewOptions(viewOptions)
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,28 @@
|
||||||
package com.github.damontecres.wholphin.ui.nav
|
package com.github.damontecres.wholphin.ui.nav
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.saveable.rememberSerializable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.CompositingStrategy
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.NavKey
|
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
|
|
||||||
import androidx.navigation3.runtime.serialization.NavKeySerializer
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import androidx.tv.material3.DrawerValue
|
import androidx.tv.material3.DrawerValue
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.rememberDrawerState
|
import androidx.tv.material3.rememberDrawerState
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import coil3.request.transitionFactory
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
// Top scrim configuration for text readability (clock, season tabs)
|
// Top scrim configuration for text readability (clock, season tabs)
|
||||||
const val TOP_SCRIM_ALPHA = 0.55f
|
const val TOP_SCRIM_ALPHA = 0.55f
|
||||||
|
|
@ -78,160 +48,23 @@ class ApplicationContentViewModel
|
||||||
fun ApplicationContent(
|
fun ApplicationContent(
|
||||||
server: JellyfinServer,
|
server: JellyfinServer,
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
startDestination: Destination,
|
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
enableTopScrim: Boolean = true,
|
enableTopScrim: Boolean = true,
|
||||||
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val backStack: MutableList<NavKey> =
|
|
||||||
rememberSerializable(
|
|
||||||
server,
|
|
||||||
user,
|
|
||||||
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
|
|
||||||
) {
|
|
||||||
NavBackStack(startDestination)
|
|
||||||
}
|
|
||||||
navigationManager.backStack = backStack
|
|
||||||
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
|
||||||
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
val baseBackgroundColor = MaterialTheme.colorScheme.background
|
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
|
||||||
if (backdrop.hasColors &&
|
Backdrop(
|
||||||
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
|
drawerIsOpen = drawerState.isOpen,
|
||||||
) {
|
backdropStyle = backdropStyle,
|
||||||
val animPrimary by animateColorAsState(
|
enableTopScrim = enableTopScrim,
|
||||||
backdrop.primaryColor,
|
viewModel = viewModel,
|
||||||
animationSpec = tween(1250),
|
)
|
||||||
label = "dynamic_backdrop_primary",
|
|
||||||
)
|
|
||||||
val animSecondary by animateColorAsState(
|
|
||||||
backdrop.secondaryColor,
|
|
||||||
animationSpec = tween(1250),
|
|
||||||
label = "dynamic_backdrop_secondary",
|
|
||||||
)
|
|
||||||
val animTertiary by animateColorAsState(
|
|
||||||
backdrop.tertiaryColor,
|
|
||||||
animationSpec = tween(1250),
|
|
||||||
label = "dynamic_backdrop_tertiary",
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.drawBehind {
|
|
||||||
drawRect(color = baseBackgroundColor)
|
|
||||||
// Top Left (Vibrant/Muted)
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(animSecondary, Color.Transparent),
|
|
||||||
center = Offset(0f, 0f),
|
|
||||||
radius = size.width * 0.8f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Bottom Right (DarkVibrant/DarkMuted)
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(animPrimary, Color.Transparent),
|
|
||||||
center = Offset(size.width, size.height),
|
|
||||||
radius = size.width * 0.8f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Bottom Left (Dark / Bridge)
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors =
|
|
||||||
listOf(
|
|
||||||
baseBackgroundColor,
|
|
||||||
Color.Transparent,
|
|
||||||
),
|
|
||||||
center = Offset(0f, size.height),
|
|
||||||
radius = size.width * 0.8f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Top Right (Under Image - Vibrant/Bright)
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(animTertiary, Color.Transparent),
|
|
||||||
center = Offset(size.width, 0f),
|
|
||||||
radius = size.width * 0.8f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model =
|
|
||||||
ImageRequest
|
|
||||||
.Builder(LocalContext.current)
|
|
||||||
.data(backdrop.imageUrl)
|
|
||||||
.transitionFactory(CrossFadeFactory(800.milliseconds))
|
|
||||||
.build(),
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
alignment = Alignment.TopEnd,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.fillMaxHeight(.7f)
|
|
||||||
.fillMaxWidth(.7f)
|
|
||||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
|
||||||
.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
if (drawerState.isOpen) {
|
|
||||||
drawRect(
|
|
||||||
brush = SolidColor(Color.Black),
|
|
||||||
alpha = .75f,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Subtle top scrim for system UI readability (clock, tabs)
|
|
||||||
if (enableTopScrim) {
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colorStops =
|
|
||||||
arrayOf(
|
|
||||||
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
|
|
||||||
TOP_SCRIM_END_FRACTION to Color.Transparent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
blendMode = BlendMode.Multiply,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.horizontalGradient(
|
|
||||||
colors = listOf(Color.Transparent, Color.Black),
|
|
||||||
startX = 0f,
|
|
||||||
endX = size.width * 0.6f,
|
|
||||||
),
|
|
||||||
blendMode = BlendMode.DstIn,
|
|
||||||
)
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(Color.Black, Color.Transparent),
|
|
||||||
startY = 0f,
|
|
||||||
endY = size.height,
|
|
||||||
),
|
|
||||||
blendMode = BlendMode.DstIn,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val navDrawerListState = rememberLazyListState()
|
val navDrawerListState = rememberLazyListState()
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = navigationManager.backStack,
|
backStack = navigationManager.backStack,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
@file:OptIn(ExperimentalCoilApi::class)
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.ui.nav
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.CompositingStrategy
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import coil3.annotation.ExperimentalCoilApi
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.useExistingImageAsPlaceholder
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.transitionFactory
|
||||||
|
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropResult
|
||||||
|
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Backdrop(
|
||||||
|
drawerIsOpen: Boolean,
|
||||||
|
backdropStyle: BackdropStyle,
|
||||||
|
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enableTopScrim: Boolean = true,
|
||||||
|
useExistingImageAsPlaceholder: Boolean = false,
|
||||||
|
crossfadeDuration: Duration = 800.milliseconds,
|
||||||
|
) {
|
||||||
|
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
||||||
|
Backdrop(
|
||||||
|
backdrop = backdrop,
|
||||||
|
drawerIsOpen = drawerIsOpen,
|
||||||
|
backdropStyle = backdropStyle,
|
||||||
|
modifier = modifier,
|
||||||
|
enableTopScrim = enableTopScrim,
|
||||||
|
useExistingImageAsPlaceholder = useExistingImageAsPlaceholder,
|
||||||
|
crossfadeDuration = crossfadeDuration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Backdrop(
|
||||||
|
backdrop: BackdropResult,
|
||||||
|
drawerIsOpen: Boolean,
|
||||||
|
backdropStyle: BackdropStyle,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enableTopScrim: Boolean = true,
|
||||||
|
useExistingImageAsPlaceholder: Boolean = false,
|
||||||
|
crossfadeDuration: Duration = 800.milliseconds,
|
||||||
|
) {
|
||||||
|
val baseBackgroundColor = MaterialTheme.colorScheme.background
|
||||||
|
if (backdrop.hasColors &&
|
||||||
|
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
|
||||||
|
) {
|
||||||
|
val animPrimary by animateColorAsState(
|
||||||
|
backdrop.primaryColor,
|
||||||
|
animationSpec = tween(1250),
|
||||||
|
label = "dynamic_backdrop_primary",
|
||||||
|
)
|
||||||
|
val animSecondary by animateColorAsState(
|
||||||
|
backdrop.secondaryColor,
|
||||||
|
animationSpec = tween(1250),
|
||||||
|
label = "dynamic_backdrop_secondary",
|
||||||
|
)
|
||||||
|
val animTertiary by animateColorAsState(
|
||||||
|
backdrop.tertiaryColor,
|
||||||
|
animationSpec = tween(1250),
|
||||||
|
label = "dynamic_backdrop_tertiary",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.drawBehind {
|
||||||
|
drawRect(color = baseBackgroundColor)
|
||||||
|
// Top Left (Vibrant/Muted)
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(animSecondary, Color.Transparent),
|
||||||
|
center = Offset(0f, 0f),
|
||||||
|
radius = size.width * 0.8f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Bottom Right (DarkVibrant/DarkMuted)
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(animPrimary, Color.Transparent),
|
||||||
|
center = Offset(size.width, size.height),
|
||||||
|
radius = size.width * 0.8f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Bottom Left (Dark / Bridge)
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors =
|
||||||
|
listOf(
|
||||||
|
baseBackgroundColor,
|
||||||
|
Color.Transparent,
|
||||||
|
),
|
||||||
|
center = Offset(0f, size.height),
|
||||||
|
radius = size.width * 0.8f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Top Right (Under Image - Vibrant/Bright)
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(animTertiary, Color.Transparent),
|
||||||
|
center = Offset(size.width, 0f),
|
||||||
|
radius = size.width * 0.8f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(LocalContext.current)
|
||||||
|
.data(backdrop.imageUrl)
|
||||||
|
.useExistingImageAsPlaceholder(useExistingImageAsPlaceholder)
|
||||||
|
.transitionFactory(CrossFadeFactory(crossfadeDuration))
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
alignment = Alignment.TopEnd,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.fillMaxHeight(.7f)
|
||||||
|
.fillMaxWidth(.7f)
|
||||||
|
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||||
|
.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
if (drawerIsOpen) {
|
||||||
|
drawRect(
|
||||||
|
brush = SolidColor(Color.Black),
|
||||||
|
alpha = .75f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Subtle top scrim for system UI readability (clock, tabs)
|
||||||
|
if (enableTopScrim) {
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colorStops =
|
||||||
|
arrayOf(
|
||||||
|
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
|
||||||
|
TOP_SCRIM_END_FRACTION to Color.Transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
blendMode = BlendMode.Multiply,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
colors = listOf(Color.Transparent, Color.Black),
|
||||||
|
startX = 0f,
|
||||||
|
endX = size.width * 0.6f,
|
||||||
|
),
|
||||||
|
blendMode = BlendMode.DstIn,
|
||||||
|
)
|
||||||
|
drawRect(
|
||||||
|
brush =
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(Color.Black, Color.Transparent),
|
||||||
|
startY = 0f,
|
||||||
|
endY = size.height,
|
||||||
|
),
|
||||||
|
blendMode = BlendMode.DstIn,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,9 @@ sealed class Destination(
|
||||||
val item: DiscoverItem,
|
val item: DiscoverItem,
|
||||||
) : Destination(false)
|
) : Destination(false)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object NowPlaying : Destination(true)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object UpdateApp : Destination(true)
|
data object UpdateApp : Destination(true)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.ItemGrid
|
import com.github.damontecres.wholphin.ui.components.ItemGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
|
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
|
||||||
|
|
@ -23,11 +23,15 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
|
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PersonPage
|
import com.github.damontecres.wholphin.ui.detail.PersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||||
|
|
@ -142,11 +146,9 @@ fun DestinationContent(
|
||||||
|
|
||||||
BaseItemKind.BOX_SET -> {
|
BaseItemKind.BOX_SET -> {
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolderBoxSet(
|
CollectionDetails(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
recursive = false,
|
|
||||||
playEnabled = true,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +156,7 @@ fun DestinationContent(
|
||||||
BaseItemKind.PLAYLIST -> {
|
BaseItemKind.PLAYLIST -> {
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
PlaylistDetails(
|
PlaylistDetails(
|
||||||
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
@ -214,6 +217,24 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ALBUM -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
|
AlbumDetailsPage(
|
||||||
|
preferences = preferences,
|
||||||
|
itemId = destination.itemId,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ARTIST -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
|
ArtistDetailsPage(
|
||||||
|
preferences = preferences,
|
||||||
|
itemId = destination.itemId,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unsupported item type: ${destination.type}")
|
Timber.w("Unsupported item type: ${destination.type}")
|
||||||
Text("Unsupported item type: ${destination.type}", modifier)
|
Text("Unsupported item type: ${destination.type}", modifier)
|
||||||
|
|
@ -267,6 +288,10 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Destination.NowPlaying -> {
|
||||||
|
NowPlayingPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
Destination.UpdateApp -> {
|
Destination.UpdateApp -> {
|
||||||
InstallUpdatePage(preferences, modifier)
|
InstallUpdatePage(preferences, modifier)
|
||||||
}
|
}
|
||||||
|
|
@ -386,6 +411,14 @@ fun CollectionFolder(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CollectionType.MUSIC -> {
|
||||||
|
CollectionFolderMusic(
|
||||||
|
preferences,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
CollectionType.HOMEVIDEOS,
|
CollectionType.HOMEVIDEOS,
|
||||||
CollectionType.PHOTOS,
|
CollectionType.PHOTOS,
|
||||||
-> {
|
-> {
|
||||||
|
|
@ -398,7 +431,6 @@ fun CollectionFolder(
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionType.MUSICVIDEOS,
|
CollectionType.MUSICVIDEOS,
|
||||||
CollectionType.MUSIC,
|
|
||||||
CollectionType.BOOKS,
|
CollectionType.BOOKS,
|
||||||
-> {
|
-> {
|
||||||
CollectionFolderGeneric(
|
CollectionFolderGeneric(
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.VisibilityThreshold
|
import androidx.compose.animation.core.VisibilityThreshold
|
||||||
import androidx.compose.animation.core.animateIntOffsetAsState
|
import androidx.compose.animation.core.animateIntOffsetAsState
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
|
@ -24,6 +27,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
|
@ -104,6 +109,7 @@ class NavDrawerViewModel
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val setupNavigationManager: SetupNavigationManager,
|
val setupNavigationManager: SetupNavigationManager,
|
||||||
val backdropService: BackdropService,
|
val backdropService: BackdropService,
|
||||||
|
private val musicService: MusicService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val state = navDrawerService.state
|
val state = navDrawerService.state
|
||||||
|
|
||||||
|
|
@ -178,9 +184,11 @@ class NavDrawerViewModel
|
||||||
if (key is Destination) {
|
if (key is Destination) {
|
||||||
val index =
|
val index =
|
||||||
if (key is Destination.Home) {
|
if (key is Destination.Home) {
|
||||||
-1
|
HOME_INDEX
|
||||||
} else if (key is Destination.Search) {
|
} else if (key is Destination.Search) {
|
||||||
-2
|
SEARCH_INDEX
|
||||||
|
} else if (key is Destination.NowPlaying) {
|
||||||
|
NOW_PLAYING_INDEX
|
||||||
} else {
|
} else {
|
||||||
val idx = asDestinations.indexOf(key)
|
val idx = asDestinations.indexOf(key)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
|
@ -198,6 +206,13 @@ class NavDrawerViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun navigateToSetup(userList: SetupDestination) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.stop()
|
||||||
|
setupNavigationManager.navigateTo(userList)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface NavDrawerItem {
|
sealed interface NavDrawerItem {
|
||||||
|
|
@ -242,6 +257,10 @@ data class ServerNavDrawerItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val HOME_INDEX = -1
|
||||||
|
private const val SEARCH_INDEX = -2
|
||||||
|
private const val NOW_PLAYING_INDEX = -3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the left side navigation drawer with [DestinationContent] on the right
|
* Display the left side navigation drawer with [DestinationContent] on the right
|
||||||
*/
|
*/
|
||||||
|
|
@ -324,12 +343,37 @@ fun NavDrawer(
|
||||||
drawerOpen = isOpen,
|
drawerOpen = isOpen,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setupNavigationManager.navigateTo(
|
viewModel.navigateToSetup(
|
||||||
SetupDestination.UserList(server),
|
SetupDestination.UserList(server),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.nowPlayingEnabled,
|
||||||
|
enter = expandVertically(expandFrom = Alignment.Top),
|
||||||
|
exit = shrinkVertically(shrinkTowards = Alignment.Top),
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
IconNavItem(
|
||||||
|
text = stringResource(R.string.now_playing),
|
||||||
|
subtext = state.nowPlayingTitle,
|
||||||
|
icon = Icons.Default.PlayArrow,
|
||||||
|
selected = selectedIndex == NOW_PLAYING_INDEX,
|
||||||
|
drawerOpen = isOpen,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
onClick = {
|
||||||
|
viewModel.setIndex(NOW_PLAYING_INDEX)
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.NowPlaying)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.ifElse(
|
||||||
|
selectedIndex == NOW_PLAYING_INDEX,
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = navDrawerListState,
|
state = navDrawerListState,
|
||||||
contentPadding = PaddingValues(0.dp),
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
|
@ -353,18 +397,18 @@ fun NavDrawer(
|
||||||
IconNavItem(
|
IconNavItem(
|
||||||
text = stringResource(R.string.search),
|
text = stringResource(R.string.search),
|
||||||
icon = Icons.Default.Search,
|
icon = Icons.Default.Search,
|
||||||
selected = selectedIndex == -2,
|
selected = selectedIndex == SEARCH_INDEX,
|
||||||
drawerOpen = isOpen,
|
drawerOpen = isOpen,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setIndex(-2)
|
viewModel.setIndex(SEARCH_INDEX)
|
||||||
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
|
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.focusRequester(searchFocusRequester)
|
.focusRequester(searchFocusRequester)
|
||||||
.ifElse(
|
.ifElse(
|
||||||
selectedIndex == -2,
|
selectedIndex == SEARCH_INDEX,
|
||||||
Modifier.focusRequester(focusRequester),
|
Modifier.focusRequester(focusRequester),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -374,11 +418,11 @@ fun NavDrawer(
|
||||||
IconNavItem(
|
IconNavItem(
|
||||||
text = stringResource(R.string.home),
|
text = stringResource(R.string.home),
|
||||||
icon = Icons.Default.Home,
|
icon = Icons.Default.Home,
|
||||||
selected = selectedIndex == -1,
|
selected = selectedIndex == HOME_INDEX,
|
||||||
drawerOpen = isOpen,
|
drawerOpen = isOpen,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setIndex(-1)
|
viewModel.setIndex(HOME_INDEX)
|
||||||
if (destination is Destination.Home) {
|
if (destination is Destination.Home) {
|
||||||
viewModel.navigationManager.reloadHome()
|
viewModel.navigationManager.reloadHome()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -388,7 +432,7 @@ fun NavDrawer(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.ifElse(
|
.ifElse(
|
||||||
selectedIndex == -1,
|
selectedIndex == HOME_INDEX,
|
||||||
Modifier.focusRequester(focusRequester),
|
Modifier.focusRequester(focusRequester),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.ForwardingSimpleBasePlayer
|
import androidx.media3.common.ForwardingSimpleBasePlayer
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||||
import com.github.damontecres.wholphin.ui.seekBack
|
import com.github.damontecres.wholphin.ui.seekBack
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
class MediaSessionPlayer(
|
class MediaSessionPlayer(
|
||||||
player: Player,
|
player: Player,
|
||||||
private val controllerViewState: ControllerViewState,
|
|
||||||
private val playbackPreferences: PlaybackPreferences,
|
private val playbackPreferences: PlaybackPreferences,
|
||||||
) : ForwardingSimpleBasePlayer(player) {
|
) : ForwardingSimpleBasePlayer(player) {
|
||||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||||
Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady")
|
Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady")
|
||||||
if (!playWhenReady && player.isPlaying) {
|
if (playWhenReady) {
|
||||||
controllerViewState.showControls()
|
|
||||||
} else if (playWhenReady) {
|
|
||||||
playbackPreferences.skipBackOnResume?.let {
|
playbackPreferences.skipBackOnResume?.let {
|
||||||
player.seekBack(it)
|
player.seekBack(it)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.ui.compose.state.observeState
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an animated "pause" image whenever the player is paused
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PauseIndicator(
|
||||||
|
player: Player,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
duration: Duration = 300.milliseconds,
|
||||||
|
) {
|
||||||
|
val state = rememberPauseState(player)
|
||||||
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(state.isPaused) {
|
||||||
|
if (state.isPaused) visible = true
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter =
|
||||||
|
scaleIn(
|
||||||
|
animationSpec =
|
||||||
|
tween(
|
||||||
|
durationMillis = duration.inWholeMilliseconds.toInt(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow)),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(duration)
|
||||||
|
delay(50)
|
||||||
|
visible = false
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(64.dp, 64.dp),
|
||||||
|
painter = painterResource(id = R.drawable.baseline_pause_24),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember when a player is paused
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberPauseState(player: Player): PauseState {
|
||||||
|
val state = remember(player) { PauseState(player) }
|
||||||
|
LaunchedEffect(player) { state.observe() }
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
class PauseState(
|
||||||
|
private val player: Player,
|
||||||
|
) {
|
||||||
|
var isPaused by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal suspend fun observe() {
|
||||||
|
player
|
||||||
|
.observeState(
|
||||||
|
Player.EVENT_PLAYBACK_STATE_CHANGED,
|
||||||
|
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||||
|
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
|
||||||
|
) {
|
||||||
|
// Timber.v("isPaused=$isPaused, playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}")
|
||||||
|
isPaused = !isPaused && // Not already paused, don't want to trigger more than once
|
||||||
|
!player.playWhenReady && // Player is actually paused
|
||||||
|
// Player could play if it was not paused, ie it is not stopped
|
||||||
|
player.playbackState.let { it == Player.STATE_READY || it == Player.STATE_BUFFERING }
|
||||||
|
}.observe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.playback
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
@ -38,10 +39,15 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
|
@ -57,6 +63,7 @@ import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.components.Button
|
import com.github.damontecres.wholphin.ui.components.Button
|
||||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||||
|
|
@ -284,7 +291,7 @@ fun SeekBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val buttonSpacing = 12.dp
|
val buttonSpacing = 12.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LeftPlaybackButtons(
|
fun LeftPlaybackButtons(
|
||||||
|
|
@ -460,6 +467,54 @@ fun PlaybackButton(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaybackFaButton(
|
||||||
|
@StringRes iconRes: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onControllerInteraction: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
interactionSource: MutableInteractionSource? = null,
|
||||||
|
textColor: Color = Color.Unspecified,
|
||||||
|
) {
|
||||||
|
val selectedColor = MaterialTheme.colorScheme.border
|
||||||
|
Button(
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = onClick,
|
||||||
|
// shape = ButtonDefaults.shape(CircleShape),
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = AppColors.TransparentBlack25,
|
||||||
|
focusedContainerColor = selectedColor,
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(4.dp),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.size(36.dp, 36.dp)
|
||||||
|
.onFocusChanged { onControllerInteraction.invoke() },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(iconRes),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color =
|
||||||
|
if (textColor.isSpecified) {
|
||||||
|
textColor
|
||||||
|
} else if (LocalTheme.current == AppThemeColors.OLED_BLACK) {
|
||||||
|
LocalContentColor.current
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> BottomDialog(
|
fun <T> BottomDialog(
|
||||||
choices: List<BottomDialogItem<T>>,
|
choices: List<BottomDialogItem<T>>,
|
||||||
|
|
@ -554,12 +609,18 @@ data class BottomDialogItem<T>(
|
||||||
@Composable
|
@Composable
|
||||||
private fun ButtonPreview() {
|
private fun ButtonPreview() {
|
||||||
WholphinTheme {
|
WholphinTheme {
|
||||||
Row {
|
Row(Modifier.background(Color.Red)) {
|
||||||
PlaybackButton(
|
PlaybackButton(
|
||||||
iconRes = R.drawable.baseline_play_arrow_24,
|
iconRes = R.drawable.baseline_play_arrow_24,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onControllerInteraction = {},
|
onControllerInteraction = {},
|
||||||
)
|
)
|
||||||
|
PlaybackFaButton(
|
||||||
|
iconRes = R.string.fa_shuffle,
|
||||||
|
onClick = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
textColor = Color.Green,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.ProvideTextStyle
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
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.letNotEmpty
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
@ -104,7 +103,7 @@ fun TranscodeInfo(
|
||||||
"Reason:" to info.transcodeReasons.joinToString(", "),
|
"Reason:" to info.transcodeReasons.joinToString(", "),
|
||||||
"HW Accel:" to info.hardwareAccelerationType?.toString(),
|
"HW Accel:" to info.hardwareAccelerationType?.toString(),
|
||||||
"Container:" to info.container,
|
"Container:" to info.container,
|
||||||
"Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) },
|
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
SimpleTable(
|
SimpleTable(
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,7 @@ class PlaybackKeyHandler(
|
||||||
} else if (oneClickPause && isEnterKey(it)) {
|
} else if (oneClickPause && isEnterKey(it)) {
|
||||||
val wasPlaying = player.isPlaying
|
val wasPlaying = player.isPlaying
|
||||||
Util.handlePlayPauseButtonAction(player)
|
Util.handlePlayPauseButtonAction(player)
|
||||||
if (wasPlaying) {
|
if (!wasPlaying) {
|
||||||
controllerViewState.showControls()
|
|
||||||
} else {
|
|
||||||
skipBackOnResume?.let {
|
skipBackOnResume?.let {
|
||||||
player.seekBack(it)
|
player.seekBack(it)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.animation.slideIn
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOut
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
|
@ -50,6 +57,10 @@ import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
|
@ -64,10 +75,10 @@ import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
|
||||||
import com.github.damontecres.wholphin.ui.cards.ChapterCard
|
import com.github.damontecres.wholphin.ui.cards.ChapterCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
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.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
@ -119,17 +130,14 @@ fun PlaybackOverlay(
|
||||||
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
|
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
|
||||||
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
|
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
|
||||||
|
|
||||||
val chapterInteractionSources =
|
|
||||||
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
val titleHeight =
|
val titleHeight =
|
||||||
remember {
|
remember(item?.title) {
|
||||||
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
||||||
}
|
}
|
||||||
val subtitleHeight =
|
val subtitleHeight =
|
||||||
remember {
|
remember(item?.subtitleLong) {
|
||||||
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,19 +145,6 @@ fun PlaybackOverlay(
|
||||||
var controllerHeight by remember { mutableStateOf(0.dp) }
|
var controllerHeight by remember { mutableStateOf(0.dp) }
|
||||||
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
||||||
|
|
||||||
// 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(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentAlignment = Alignment.BottomCenter,
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
|
@ -160,6 +155,18 @@ fun PlaybackOverlay(
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
modifier = Modifier.matchParentSize(),
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -169,10 +176,16 @@ fun PlaybackOverlay(
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
state == OverlayViewState.CONTROLLER,
|
visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
if (seekBarFocused) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
seekProgressPercent =
|
||||||
|
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
val nextState =
|
val nextState =
|
||||||
if (chapters.isNotEmpty()) {
|
if (chapters.isNotEmpty()) {
|
||||||
OverlayViewState.CHAPTERS
|
OverlayViewState.CHAPTERS
|
||||||
|
|
@ -253,11 +266,13 @@ fun PlaybackOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
state == OverlayViewState.CHAPTERS,
|
visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
|
||||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
if (chapters.isNotEmpty()) {
|
if (chapters.isNotEmpty()) {
|
||||||
|
val chapterInteractionSources =
|
||||||
|
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
val chapterIndex =
|
val chapterIndex =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -368,7 +383,7 @@ fun PlaybackOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
state == OverlayViewState.QUEUE,
|
visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
|
||||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -441,15 +456,17 @@ fun PlaybackOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seekBarInteractionSource.collectIsFocusedAsState().value) {
|
// Trickplay
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
seekProgressPercent =
|
|
||||||
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
seekProgressPercent >= 0 && seekBarFocused,
|
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
|
||||||
|
enter =
|
||||||
|
expandVertically(
|
||||||
|
spring(
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
visibilityThreshold = IntSize.VisibilityThreshold,
|
||||||
|
),
|
||||||
|
) + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -496,9 +513,13 @@ fun PlaybackOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top
|
||||||
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
|
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
!showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
|
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 =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart),
|
.align(Alignment.TopStart),
|
||||||
|
|
@ -514,7 +535,9 @@ fun PlaybackOverlay(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
!showDebugInfo && showClock && controllerViewState.controlsVisible,
|
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 =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopEnd),
|
.align(Alignment.TopEnd),
|
||||||
|
|
@ -522,7 +545,9 @@ fun PlaybackOverlay(
|
||||||
TimeDisplay()
|
TimeDisplay()
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showDebugInfo && controllerViewState.controlsVisible,
|
visible = showDebugInfo && controllerViewState.controlsVisible,
|
||||||
|
enter = slideInVertically() + fadeIn(),
|
||||||
|
exit = slideOutVertically() + fadeOut(),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart),
|
.align(Alignment.TopStart),
|
||||||
|
|
@ -578,6 +603,11 @@ fun Controller(
|
||||||
val verticalOffset by animateDpAsState(
|
val verticalOffset by animateDpAsState(
|
||||||
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
||||||
label = "TitleBumpOffset",
|
label = "TitleBumpOffset",
|
||||||
|
animationSpec =
|
||||||
|
spring(
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
visibilityThreshold = Dp.VisibilityThreshold,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -596,6 +626,9 @@ fun Controller(
|
||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontSize = titleTextSize,
|
fontSize = titleTextSize,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -610,6 +643,9 @@ fun Controller(
|
||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontSize = subtitleTextSize,
|
fontSize = subtitleTextSize,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -622,7 +658,7 @@ fun Controller(
|
||||||
.toLong()
|
.toLong()
|
||||||
.milliseconds
|
.milliseconds
|
||||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||||
endTimeStr = TimeFormatter.format(endTime)
|
endTimeStr = getTimeFormatter().format(endTime)
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import androidx.annotation.Dimension
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -374,46 +372,49 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The playback controls
|
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) {
|
||||||
AnimatedVisibility(
|
PauseIndicator(
|
||||||
controllerViewState.controlsVisible,
|
player = player,
|
||||||
Modifier,
|
|
||||||
slideInVertically { it },
|
|
||||||
slideOutVertically { it },
|
|
||||||
) {
|
|
||||||
PlaybackOverlay(
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(WindowInsets.systemBars.asPaddingValues())
|
.align(Alignment.Center),
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Transparent),
|
|
||||||
item = currentPlayback?.item,
|
|
||||||
playerControls = player,
|
|
||||||
controllerViewState = controllerViewState,
|
|
||||||
showPlay = playPauseState.showPlay,
|
|
||||||
previousEnabled = true,
|
|
||||||
nextEnabled = playlist.hasNext(),
|
|
||||||
seekEnabled = true,
|
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
|
||||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
|
||||||
onClickPlaybackDialogType = { playbackDialog = it },
|
|
||||||
onSeekBarChange = seekBarState::onValueChange,
|
|
||||||
showDebugInfo = showDebugInfo,
|
|
||||||
currentPlayback = currentPlayback,
|
|
||||||
chapters = mediaInfo?.chapters ?: listOf(),
|
|
||||||
trickplayInfo = mediaInfo?.trickPlayInfo,
|
|
||||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
|
||||||
playlist = playlist,
|
|
||||||
onClickPlaylist = {
|
|
||||||
viewModel.playItemInPlaylist(it)
|
|
||||||
},
|
|
||||||
currentSegment = currentSegment?.segment,
|
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The playback controls
|
||||||
|
|
||||||
|
PlaybackOverlay(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Transparent),
|
||||||
|
item = currentPlayback?.item,
|
||||||
|
playerControls = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
showPlay = playPauseState.showPlay,
|
||||||
|
previousEnabled = true,
|
||||||
|
nextEnabled = playlist.hasNext(),
|
||||||
|
seekEnabled = true,
|
||||||
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
onClickPlaybackDialogType = { playbackDialog = it },
|
||||||
|
onSeekBarChange = seekBarState::onValueChange,
|
||||||
|
showDebugInfo = showDebugInfo,
|
||||||
|
currentPlayback = currentPlayback,
|
||||||
|
chapters = mediaInfo?.chapters ?: listOf(),
|
||||||
|
trickplayInfo = mediaInfo?.trickPlayInfo,
|
||||||
|
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||||
|
playlist = playlist,
|
||||||
|
onClickPlaylist = {
|
||||||
|
viewModel.playItemInPlaylist(it)
|
||||||
|
},
|
||||||
|
currentSegment = currentSegment?.segment,
|
||||||
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
|
)
|
||||||
|
|
||||||
val subtitleSettings =
|
val subtitleSettings =
|
||||||
remember(mediaInfo) {
|
remember(mediaInfo) {
|
||||||
Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")
|
Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||||
|
|
@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.PlaybackItemState
|
||||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||||
import com.github.damontecres.wholphin.util.checkForSupport
|
import com.github.damontecres.wholphin.util.checkForSupport
|
||||||
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
|
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
|
||||||
|
|
@ -143,6 +145,7 @@ class PlaybackViewModel
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val imageUrlService: ImageUrlService,
|
private val imageUrlService: ImageUrlService,
|
||||||
private val screensaverService: ScreensaverService,
|
private val screensaverService: ScreensaverService,
|
||||||
|
private val musicService: MusicService,
|
||||||
@Assisted private val destination: Destination,
|
@Assisted private val destination: Destination,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
|
|
@ -260,7 +263,6 @@ class PlaybackViewModel
|
||||||
val sessionPlayer =
|
val sessionPlayer =
|
||||||
MediaSessionPlayer(
|
MediaSessionPlayer(
|
||||||
player,
|
player,
|
||||||
controllerViewState,
|
|
||||||
preferences.appPreferences.playbackPreferences,
|
preferences.appPreferences.playbackPreferences,
|
||||||
)
|
)
|
||||||
mediaSession =
|
mediaSession =
|
||||||
|
|
@ -273,6 +275,7 @@ class PlaybackViewModel
|
||||||
* Initialize from the UI to start playback
|
* Initialize from the UI to start playback
|
||||||
*/
|
*/
|
||||||
private suspend fun init() {
|
private suspend fun init() {
|
||||||
|
musicService.stop()
|
||||||
nextUp.setValueOnMain(null)
|
nextUp.setValueOnMain(null)
|
||||||
this.preferences = userPreferencesService.getCurrent()
|
this.preferences = userPreferencesService.getCurrent()
|
||||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||||
|
|
@ -737,12 +740,12 @@ class PlaybackViewModel
|
||||||
player.removeListener(it)
|
player.removeListener(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val playbackItemState = PlaybackItemState(playback, currentItemPlayback)
|
||||||
val activityListener =
|
val activityListener =
|
||||||
TrackActivityPlaybackListener(
|
TrackActivityPlaybackListener(
|
||||||
api = api,
|
api = api,
|
||||||
player = player,
|
player = player,
|
||||||
playback = playback,
|
getState = { playbackItemState },
|
||||||
itemPlayback = currentItemPlayback,
|
|
||||||
)
|
)
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
this@PlaybackViewModel.activityListener = activityListener
|
this@PlaybackViewModel.activityListener = activityListener
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.Button
|
import com.github.damontecres.wholphin.ui.components.Button
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -85,11 +86,6 @@ data class NavDrawerPin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class MoveDirection {
|
|
||||||
UP,
|
|
||||||
DOWN,
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> List<T>.move(
|
private fun <T> List<T>.move(
|
||||||
direction: MoveDirection,
|
direction: MoveDirection,
|
||||||
index: Int,
|
index: Int,
|
||||||
|
|
@ -246,7 +242,7 @@ fun NavDrawerPreferenceListItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MoveButton(
|
fun MoveButton(
|
||||||
@StringRes icon: Int,
|
@StringRes icon: Int,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
@ -25,9 +25,9 @@ data class Clock(
|
||||||
*/
|
*/
|
||||||
val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()),
|
val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()),
|
||||||
/**
|
/**
|
||||||
* The current time formatted as a string with [TimeFormatter]
|
* The current time formatted as a string with [getTimeFormatter]
|
||||||
*/
|
*/
|
||||||
val timeString: MutableState<String> = mutableStateOf(TimeFormatter.format(now.value)),
|
val timeString: MutableState<String> = mutableStateOf(getTimeFormatter().format(now.value)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -37,7 +37,7 @@ fun ProvideLocalClock(content: @Composable () -> Unit) {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
val time = TimeFormatter.format(now)
|
val time = getTimeFormatter().format(now)
|
||||||
clock.now.value = now
|
clock.now.value = now
|
||||||
clock.timeString.value = time
|
clock.timeString.value = time
|
||||||
delay(2_000)
|
delay(2_000)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
package com.github.damontecres.wholphin.util
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import java.util.function.IntFunction
|
||||||
import java.util.function.Predicate
|
import java.util.function.Predicate
|
||||||
|
|
||||||
interface BlockingList<T> : List<T> {
|
interface BlockingList<T> : List<T> {
|
||||||
suspend fun getBlocking(index: Int): T
|
suspend fun getBlocking(index: Int): T
|
||||||
|
|
||||||
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
|
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BlockingListWrapper<T>(
|
||||||
|
private val list: List<T>,
|
||||||
|
) : BlockingList<T>,
|
||||||
|
List<T> by list {
|
||||||
|
override suspend fun getBlocking(index: Int): T = get(index)
|
||||||
|
|
||||||
|
override suspend fun indexOfBlocking(predicate: Predicate<T>): Int = indexOfFirst { predicate.test(it) }
|
||||||
|
|
||||||
|
@Deprecated("Deprecated")
|
||||||
|
override fun <T> toArray(generator: IntFunction<Array<out T?>?>): Array<out T?> = super<List>.toArray(generator)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ val supportedCollectionTypes =
|
||||||
CollectionType.LIVETV,
|
CollectionType.LIVETV,
|
||||||
CollectionType.MUSICVIDEOS,
|
CollectionType.MUSICVIDEOS,
|
||||||
CollectionType.FOLDERS,
|
CollectionType.FOLDERS,
|
||||||
|
CollectionType.MUSIC,
|
||||||
null, // Mixed
|
null, // Mixed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||||
|
|
@ -20,6 +21,7 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Timer
|
import java.util.Timer
|
||||||
import java.util.TimerTask
|
import java.util.TimerTask
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -30,8 +32,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
class TrackActivityPlaybackListener(
|
class TrackActivityPlaybackListener(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
val playback: CurrentPlayback,
|
private val getState: () -> PlaybackItemState?,
|
||||||
val itemPlayback: ItemPlayback,
|
|
||||||
) : Player.Listener {
|
) : Player.Listener {
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.Main)
|
private val coroutineScope = CoroutineScope(Dispatchers.Main)
|
||||||
private val task: TimerTask =
|
private val task: TimerTask =
|
||||||
|
|
@ -50,44 +51,50 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
launch("reportPlaybackStart") {
|
launch("reportPlaybackStart") {
|
||||||
Timber.v("reportPlaybackStart for ${itemPlayback.itemId}")
|
getState.invoke()?.let { state ->
|
||||||
api.playStateApi.reportPlaybackStart(
|
Timber.v("reportPlaybackStart for ${state.itemId}")
|
||||||
PlaybackStartInfo(
|
api.playStateApi.reportPlaybackStart(
|
||||||
canSeek = true,
|
PlaybackStartInfo(
|
||||||
itemId = itemPlayback.itemId,
|
canSeek = true,
|
||||||
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
|
itemId = state.itemId,
|
||||||
playMethod = playback.playMethod,
|
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
playMethod = state.playMethod,
|
||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
isMuted = false,
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
isMuted = false,
|
||||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
audioStreamIndex = state.audioStreamIndex,
|
||||||
playSessionId = playback.playSessionId,
|
subtitleStreamIndex = state.subtitleStreamIndex,
|
||||||
liveStreamId = playback.liveStreamId,
|
playSessionId = state.playSessionId,
|
||||||
),
|
liveStreamId = state.liveStreamId,
|
||||||
)
|
),
|
||||||
val delay = 5.seconds.inWholeMilliseconds
|
)
|
||||||
// Every x seconds, check if the video is playing
|
|
||||||
TIMER.schedule(task, delay, delay)
|
val delay = 5.seconds.inWholeMilliseconds
|
||||||
initialized = true
|
// Every x seconds, check if the video is playing
|
||||||
|
TIMER.schedule(task, delay, delay)
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
// player.removeListener(this)
|
||||||
task.cancel()
|
task.cancel()
|
||||||
TIMER.purge()
|
TIMER.purge()
|
||||||
val position = player.currentPosition.milliseconds
|
val position = player.currentPosition.milliseconds
|
||||||
launch("reportPlaybackStopped") {
|
launch("reportPlaybackStopped") {
|
||||||
Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position")
|
getState.invoke()?.let { state ->
|
||||||
api.playStateApi.reportPlaybackStopped(
|
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||||
PlaybackStopInfo(
|
api.playStateApi.reportPlaybackStopped(
|
||||||
itemId = itemPlayback.itemId,
|
PlaybackStopInfo(
|
||||||
positionTicks = position.inWholeTicks,
|
itemId = state.itemId,
|
||||||
failed = false,
|
positionTicks = position.inWholeTicks,
|
||||||
playSessionId = playback.playSessionId,
|
failed = false,
|
||||||
liveStreamId = playback.liveStreamId,
|
playSessionId = state.playSessionId,
|
||||||
),
|
liveStreamId = state.liveStreamId,
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,29 +115,31 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
private fun saveActivity(position: Long) {
|
private fun saveActivity(position: Long) {
|
||||||
launch("saveActivity") {
|
launch("saveActivity") {
|
||||||
val calcPosition =
|
getState.invoke()?.let { state ->
|
||||||
withContext(Dispatchers.Main) {
|
val calcPosition =
|
||||||
(if (position >= 0) position else player.currentPosition)
|
withContext(Dispatchers.Main) {
|
||||||
|
(if (position >= 0) position else player.currentPosition)
|
||||||
|
}
|
||||||
|
if (calcPosition > 0) {
|
||||||
|
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
|
||||||
|
Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition")
|
||||||
|
api.playStateApi.reportPlaybackProgress(
|
||||||
|
PlaybackProgressInfo(
|
||||||
|
itemId = state.itemId,
|
||||||
|
positionTicks = calcPosition.milliseconds.inWholeTicks,
|
||||||
|
canSeek = true,
|
||||||
|
isPaused = isPaused,
|
||||||
|
isMuted = false,
|
||||||
|
playMethod = state.playMethod,
|
||||||
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
|
audioStreamIndex = state.audioStreamIndex,
|
||||||
|
subtitleStreamIndex = state.subtitleStreamIndex,
|
||||||
|
playSessionId = state.playSessionId,
|
||||||
|
liveStreamId = state.liveStreamId,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (calcPosition > 0) {
|
|
||||||
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
|
|
||||||
Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition")
|
|
||||||
api.playStateApi.reportPlaybackProgress(
|
|
||||||
PlaybackProgressInfo(
|
|
||||||
itemId = itemPlayback.itemId,
|
|
||||||
positionTicks = calcPosition.milliseconds.inWholeTicks,
|
|
||||||
canSeek = true,
|
|
||||||
isPaused = isPaused,
|
|
||||||
isMuted = false,
|
|
||||||
playMethod = playback.playMethod,
|
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
|
||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
|
||||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
|
||||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
|
||||||
playSessionId = playback.playSessionId,
|
|
||||||
liveStreamId = playback.liveStreamId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +152,7 @@ class TrackActivityPlaybackListener(
|
||||||
try {
|
try {
|
||||||
block.invoke(this)
|
block.invoke(this)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId)
|
Timber.w(ex, "Exception during %s", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,3 +163,24 @@ class TrackActivityPlaybackListener(
|
||||||
private val TIMER by lazy { Timer("$TAG-timer", true) }
|
private val TIMER by lazy { Timer("$TAG-timer", true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class PlaybackItemState(
|
||||||
|
val itemId: UUID,
|
||||||
|
val playMethod: PlayMethod,
|
||||||
|
val audioStreamIndex: Int? = null,
|
||||||
|
val subtitleStreamIndex: Int? = null,
|
||||||
|
val playSessionId: String? = null,
|
||||||
|
val liveStreamId: String? = null,
|
||||||
|
) {
|
||||||
|
constructor(
|
||||||
|
playback: CurrentPlayback,
|
||||||
|
itemPlayback: ItemPlayback,
|
||||||
|
) : this(
|
||||||
|
itemId = itemPlayback.itemId,
|
||||||
|
playMethod = playback.playMethod,
|
||||||
|
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||||
|
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||||
|
playSessionId = playback.playSessionId,
|
||||||
|
liveStreamId = playback.liveStreamId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,13 @@ message PhotoPreferences{
|
||||||
bool slideshow_play_videos = 2;
|
bool slideshow_play_videos = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message MusicPreferences {
|
||||||
|
bool show_lyrics = 1;
|
||||||
|
bool show_visualizer = 2;
|
||||||
|
bool show_album_Art = 3;
|
||||||
|
bool show_backdrop = 4;
|
||||||
|
}
|
||||||
|
|
||||||
message AppPreferences {
|
message AppPreferences {
|
||||||
// The currently signed in server and user IDs, mostly for restoring a session
|
// The currently signed in server and user IDs, mostly for restoring a session
|
||||||
string current_server_id = 1;
|
string current_server_id = 1;
|
||||||
|
|
@ -194,4 +201,5 @@ message AppPreferences {
|
||||||
AdvancedPreferences advanced_preferences = 10;
|
AdvancedPreferences advanced_preferences = 10;
|
||||||
bool sign_in_automatically = 11;
|
bool sign_in_automatically = 11;
|
||||||
PhotoPreferences photo_preferences = 12;
|
PhotoPreferences photo_preferences = 12;
|
||||||
|
MusicPreferences music_preferences = 13;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,7 @@
|
||||||
<string name="fa_check" translatable="false"></string>
|
<string name="fa_check" translatable="false"></string>
|
||||||
<string name="fa_cloud_arrow_up" translatable="false"></string>
|
<string name="fa_cloud_arrow_up" translatable="false"></string>
|
||||||
<string name="fa_cloud_arrow_down" translatable="false"></string>
|
<string name="fa_cloud_arrow_down" translatable="false"></string>
|
||||||
|
<string name="fa_compass" translatable="false"></string>
|
||||||
|
<string name="fa_repeat" translatable="false"></string>
|
||||||
|
<string name="fa_compact_disc" translatable="false"></string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,14 @@
|
||||||
<string name="send_media_info_log_to_server">Send media info log to server</string>
|
<string name="send_media_info_log_to_server">Send media info log to server</string>
|
||||||
<string name="no_limit">No limit</string>
|
<string name="no_limit">No limit</string>
|
||||||
<string name="max_days_next_up">Max days in Next Up</string>
|
<string name="max_days_next_up">Max days in Next Up</string>
|
||||||
|
<string name="albums">Albums</string>
|
||||||
|
<string name="artists">Artists</string>
|
||||||
|
<string name="songs">Songs</string>
|
||||||
|
<string name="go_to_artist">Go to artist</string>
|
||||||
|
<string name="now_playing">Now playing</string>
|
||||||
|
<string name="instant_mix">Instant mix</string>
|
||||||
|
<string name="go_to_album">Go to album</string>
|
||||||
|
<string name="add_to_queue">Add to queue</string>
|
||||||
|
|
||||||
<string name="add_row">Add row</string>
|
<string name="add_row">Add row</string>
|
||||||
<string name="genres_in">Genres in %1$s</string>
|
<string name="genres_in">Genres in %1$s</string>
|
||||||
|
|
@ -707,6 +715,8 @@
|
||||||
<item>@string/photos</item>
|
<item>@string/photos</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
|
<string name="search_for">Search %s</string>
|
||||||
|
|
||||||
<string name="actor">Actor</string>
|
<string name="actor">Actor</string>
|
||||||
<string name="composer">Composer</string>
|
<string name="composer">Composer</string>
|
||||||
<string name="writer">Writer</string>
|
<string name="writer">Writer</string>
|
||||||
|
|
@ -719,7 +729,23 @@
|
||||||
<string name="mixer">Mixer</string>
|
<string name="mixer">Mixer</string>
|
||||||
<string name="creator">Creator</string>
|
<string name="creator">Creator</string>
|
||||||
<string name="artist">Artist</string>
|
<string name="artist">Artist</string>
|
||||||
<string name="search_for">Search %s</string>
|
<string name="album_artist">Album artist</string>
|
||||||
|
<string name="album">Album</string>
|
||||||
|
<string name="popular_songs">Popular songs</string>
|
||||||
|
<string name="play_next">Play next</string>
|
||||||
|
<string name="music_videos">Music videos</string>
|
||||||
|
<string name="lyrics">Lyrics</string>
|
||||||
|
<string name="hide_lyrics">Hide lyrics</string>
|
||||||
|
<string name="show_lyrics">Show lyrics</string>
|
||||||
|
<string name="song_has_lyrics">Song has lyrics</string>
|
||||||
|
<string name="remove_from_queue">Remove from queue</string>
|
||||||
|
<string name="show_album_cover">Show album cover</string>
|
||||||
|
<string name="show_visualizer">Show visualizer</string>
|
||||||
|
<string name="show_backdrop">Show backdrop</string>
|
||||||
|
<string name="play_as_type">Play as?</string>
|
||||||
|
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
|
||||||
|
<string name="continue_string">Continue</string>
|
||||||
<string name="select_all">Select all</string>
|
<string name="select_all">Select all</string>
|
||||||
|
<string name="separate_types">Separate types</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.core.DataStoreFactory
|
import androidx.datastore.core.DataStoreFactory
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.dataStoreFile
|
import androidx.datastore.dataStoreFile
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
|
|
@ -278,5 +281,14 @@ object TestDatabaseModule {
|
||||||
produceNewData = { AppPreferences.getDefaultInstance() },
|
produceNewData = { AppPreferences.getDefaultInstance() },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun keyValueDataStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { context.preferencesDataStoreFile("key_value") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ kotlin = "2.3.20"
|
||||||
ksp = "2.3.6"
|
ksp = "2.3.6"
|
||||||
coreKtx = "1.18.0"
|
coreKtx = "1.18.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
composeBom = "2026.03.00"
|
composeBom = "2026.03.01"
|
||||||
mockk = "1.14.9"
|
mockk = "1.14.9"
|
||||||
robolectric = "4.16.1"
|
robolectric = "4.16.1"
|
||||||
multiplatformMarkdownRenderer = "0.39.2"
|
multiplatformMarkdownRenderer = "0.39.2"
|
||||||
|
|
@ -33,17 +33,17 @@ material3AdaptiveNav3 = "1.0.0-alpha03"
|
||||||
protobuf = "0.9.6"
|
protobuf = "0.9.6"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
kotlinx-serialization = "1.10.0"
|
kotlinx-serialization = "1.10.0"
|
||||||
protobuf-javalite = "4.34.0"
|
protobuf-javalite = "4.34.1"
|
||||||
hilt = "2.59.2"
|
hilt = "2.59.2"
|
||||||
room = "2.8.4"
|
room = "2.8.4"
|
||||||
preferenceKtx = "1.2.1"
|
preferenceKtx = "1.2.1"
|
||||||
tvprovider = "1.1.0"
|
tvprovider = "1.1.0"
|
||||||
workRuntimeKtx = "2.11.1"
|
workRuntimeKtx = "2.11.2"
|
||||||
paletteKtx = "1.0.0"
|
paletteKtx = "1.0.0"
|
||||||
assMedia = "0.4.0"
|
assMedia = "0.4.0"
|
||||||
kotlinxCoroutinesTest = "1.10.2"
|
kotlinxCoroutinesTest = "1.10.2"
|
||||||
coreTesting = "2.2.0"
|
coreTesting = "2.2.0"
|
||||||
openapi-generator = "7.20.0"
|
openapi-generator = "7.21.0"
|
||||||
runner = "1.7.0"
|
runner = "1.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|
@ -73,6 +73,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
|
||||||
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||||
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
||||||
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
||||||
|
|
|
||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|
|
||||||
2
gradlew
vendored
2
gradlew
vendored
|
|
@ -57,7 +57,7 @@
|
||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (3) This script is generated from the Groovy template
|
||||||
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
# within the Gradle project.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,14 @@
|
||||||
# FFmpeg ExoPlayer decoder extension module
|
# FFmpeg ExoPlayer decoder extension module
|
||||||
|
|
||||||
Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback.
|
Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build_ffmpeg_decoder.sh /path/to/android/ndk
|
||||||
|
```
|
||||||
|
|
||||||
|
Or clean build:
|
||||||
|
```sh
|
||||||
|
./build_ffmpeg_decoder.sh /path/to/android/ndk --clean
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main"
|
||||||
HOST="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
HOST="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
HOST_PLATFORM="$HOST-x86_64"
|
HOST_PLATFORM="$HOST-x86_64"
|
||||||
|
|
||||||
|
if [[ "$2" == "--clean" ]]; then
|
||||||
|
rm -rf ffmpeg_decoder
|
||||||
|
rm -f "$TARGET_PATH/lib-decoder-ffmpeg-release.aar"
|
||||||
|
rm -f "$TARGET_PATH/lib-decoder-av1-release.aar"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$TARGET_PATH"
|
mkdir -p "$TARGET_PATH"
|
||||||
mkdir -p ffmpeg_decoder
|
mkdir -p ffmpeg_decoder
|
||||||
|
|
||||||
|
|
@ -38,14 +44,16 @@ pushd ffmpeg_decoder || exit
|
||||||
|
|
||||||
if [[ -d media ]]; then
|
if [[ -d media ]]; then
|
||||||
pushd media || exit
|
pushd media || exit
|
||||||
git checkout --force "$media_version"
|
git fetch origin "$media_version" --depth 1
|
||||||
|
git checkout --force FETCH_HEAD
|
||||||
else
|
else
|
||||||
git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media
|
git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -d ffmpeg ]]; then
|
if [[ -d ffmpeg ]]; then
|
||||||
pushd ffmpeg || exit
|
pushd ffmpeg || exit
|
||||||
git checkout --force "$FFMPEG_BRANCH"
|
git fetch origin "$FFMPEG_BRANCH" --depth 1
|
||||||
|
git checkout --force FETCH_HEAD
|
||||||
else
|
else
|
||||||
git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg
|
git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg
|
||||||
fi
|
fi
|
||||||
|
|
@ -68,7 +76,8 @@ pushd "$AV1_MODULE_PATH/jni" || exit
|
||||||
|
|
||||||
if [[ -d dav1d ]]; then
|
if [[ -d dav1d ]]; then
|
||||||
pushd dav1d || exit
|
pushd dav1d || exit
|
||||||
git checkout --force "$DAV1D_BRANCH"
|
git fetch origin "$DAV1D_BRANCH" --depth 1
|
||||||
|
git checkout --force FETCH_HEAD
|
||||||
else
|
else
|
||||||
git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d
|
git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ function clone(){
|
||||||
|
|
||||||
if [[ -d "$dir" ]]; then
|
if [[ -d "$dir" ]]; then
|
||||||
pushd "$dir" || exit
|
pushd "$dir" || exit
|
||||||
git checkout --force "$branch"
|
git fetch origin "$branch" --depth 1
|
||||||
|
git checkout --force FETCH_HEAD
|
||||||
popd || exit
|
popd || exit
|
||||||
else
|
else
|
||||||
git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@"
|
git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue