mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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 "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: android-actions/setup-android@v4
|
||||
with:
|
||||
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
||||
- name: Add NDK to path
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ dependencies {
|
|||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.protobuf.kotlin.lite)
|
||||
implementation(libs.androidx.tvprovider)
|
||||
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.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
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.ScreensaverState
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
|
|
@ -43,16 +45,17 @@ class InstrumentedBasicUiTests {
|
|||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun myTest() {
|
||||
val navigationManager = NavigationManager()
|
||||
navigationManager.backStack = NavBackStack(Destination.Home())
|
||||
// Start the app
|
||||
composeTestRule.setContent {
|
||||
WholphinTheme {
|
||||
MainContent(
|
||||
backStack = mutableListOf(SetupDestination.ServerList),
|
||||
navigationManager = mockk(relaxed = true),
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = mockk(relaxed = true),
|
||||
backdropService = mockk(relaxed = true),
|
||||
screensaverService = screensaverService,
|
||||
requestedDestination = Destination.Home(),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
|
|
@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -127,6 +129,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private var signInAuto = true
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
classDiscriminator = "_type"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -134,6 +141,23 @@ class MainActivity : AppCompatActivity() {
|
|||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||
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 ->
|
||||
if (user?.hasPin == true) {
|
||||
window?.setFlags(
|
||||
|
|
@ -206,17 +230,12 @@ class MainActivity : AppCompatActivity() {
|
|||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||
) {
|
||||
ProvideLocalClock {
|
||||
val requestedDestination =
|
||||
remember(intent) {
|
||||
intent?.let(::extractDestination) ?: Destination.Home()
|
||||
}
|
||||
MainContent(
|
||||
backStack = setupNavigationManager.backStack,
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = appPreferences,
|
||||
backdropService = backdropService,
|
||||
screensaverService = screensaverService,
|
||||
requestedDestination = requestedDestination,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
@ -287,6 +306,8 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
Timber.d("onSaveInstanceState")
|
||||
val str = json.encodeToString(navigationManager.backStack.toList())
|
||||
outState.putString(KEY_BACK_STACK, str)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
|
|
@ -362,6 +383,8 @@ class MainActivity : AppCompatActivity() {
|
|||
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||
const val INTENT_SEASON_ID = "seaId"
|
||||
|
||||
private const val KEY_BACK_STACK = "backStack"
|
||||
|
||||
lateinit var instance: MainActivity
|
||||
private set
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.ui.components.AppScreensaver
|
||||
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.SwitchUserContent
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
|
||||
@Composable
|
||||
fun MainContent(
|
||||
|
|
@ -45,9 +44,9 @@ fun MainContent(
|
|||
appPreferences: AppPreferences,
|
||||
backdropService: BackdropService,
|
||||
screensaverService: ScreensaverService,
|
||||
requestedDestination: Destination,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
|
|
@ -96,10 +95,6 @@ fun MainContent(
|
|||
backdropService.clearBackdrop()
|
||||
}
|
||||
val current = key.current
|
||||
val preferences =
|
||||
remember(appPreferences) {
|
||||
UserPreferences(appPreferences)
|
||||
}
|
||||
var showContent by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
|
|
@ -113,7 +108,6 @@ fun MainContent(
|
|||
ApplicationContent(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
startDestination = requestedDestination,
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner
|
|||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
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.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
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.first
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
|
@ -61,6 +60,7 @@ class WholphinDreamService :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.d("onCreate")
|
||||
|
||||
savedStateRegistryController.performRestore(null)
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
|
|
@ -74,6 +74,7 @@ class WholphinDreamService :
|
|||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
Timber.d("onAttachedToWindow")
|
||||
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
||||
setContentView(
|
||||
ComposeView(this).apply {
|
||||
|
|
@ -109,11 +110,13 @@ class WholphinDreamService :
|
|||
|
||||
override fun onDreamingStarted() {
|
||||
super.onDreamingStarted()
|
||||
Timber.d("onDreamingStarted")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||
}
|
||||
|
||||
override fun onDreamingStopped() {
|
||||
super.onDreamingStopped()
|
||||
Timber.d("onDreamingStopped")
|
||||
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.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
|
|
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
|
|||
itemId: String,
|
||||
): LibraryDisplayInfo?
|
||||
|
||||
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
|
||||
fun getItemAsFlow(
|
||||
userId: Int,
|
||||
itemId: String,
|
||||
): Flow<LibraryDisplayInfo?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
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.ui.text.AnnotatedString
|
||||
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.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.dot
|
||||
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.playback.playable
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
|
|
@ -32,7 +33,7 @@ import kotlin.time.Duration
|
|||
@Stable
|
||||
data class BaseItem(
|
||||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
val useSeriesForPrimary: Boolean = false,
|
||||
val imageUrlOverride: String? = null,
|
||||
val destinationOverride: Destination? = null,
|
||||
) : CardGridItem {
|
||||
|
|
@ -57,6 +58,7 @@ data class BaseItem(
|
|||
when (type) {
|
||||
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
|
||||
else -> data.productionYear?.toString()
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +118,7 @@ data class BaseItem(
|
|||
buildList {
|
||||
if (type == BaseItemKind.EPISODE) {
|
||||
data.seasonEpisode?.let(::add)
|
||||
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
||||
data.premiereDate?.let { add(getDateFormatter().format(it)) }
|
||||
} else if (type == BaseItemKind.SERIES) {
|
||||
data.seriesProductionYears?.let(::add)
|
||||
} else if (type == BaseItemKind.PHOTO) {
|
||||
|
|
@ -125,6 +127,9 @@ data class BaseItem(
|
|||
} else if (data.premiereDate != null) {
|
||||
add(data.premiereDate!!.toLocalDate().toString())
|
||||
}
|
||||
} else if (type == BaseItemKind.BOX_SET) {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
data.childCount?.let { add("$it items") }
|
||||
} else {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import androidx.room.Ignore
|
|||
import androidx.room.Index
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
|
|
@ -41,4 +43,13 @@ data class LibraryDisplayInfo(
|
|||
) {
|
||||
@Ignore @Transient
|
||||
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
|
||||
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
||||
}.build()
|
||||
|
||||
musicPreferences =
|
||||
MusicPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
showBackdrop = true
|
||||
showLyrics = true
|
||||
showAlbumArt = true
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||
|
|
@ -220,6 +229,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
|
|||
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() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
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.updateLiveTvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.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) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
return@withContext ExtractedColors.DEFAULT
|
||||
|
|
|
|||
|
|
@ -438,10 +438,7 @@ class HomeSettingsService
|
|||
): HomeRowConfigDisplay =
|
||||
when (config) {
|
||||
is HomeRowConfig.ByParent -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
name,
|
||||
|
|
@ -466,10 +463,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.Genres -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
context.getString(R.string.genres_in, name),
|
||||
|
|
@ -490,10 +484,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.RecentlyAdded -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
context.getString(R.string.recently_added_in, name),
|
||||
|
|
@ -502,10 +493,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.RecentlyReleased -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
context.getString(R.string.recently_released_in, name),
|
||||
|
|
@ -547,10 +535,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.Suggestions -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
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]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.NavPinType
|
||||
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.nav.Destination
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
|
@ -33,6 +36,7 @@ import timber.log.Timber
|
|||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@Singleton
|
||||
class NavDrawerService
|
||||
|
|
@ -44,6 +48,7 @@ class NavDrawerService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val musicService: MusicService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
||||
val state: StateFlow<NavDrawerItemState> = _state
|
||||
|
|
@ -73,6 +78,40 @@ class NavDrawerService
|
|||
.onEach { discoverActive ->
|
||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||
}.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(
|
||||
|
|
@ -185,9 +224,11 @@ data class NavDrawerItemState(
|
|||
val items: List<NavDrawerItem>,
|
||||
val moreItems: List<NavDrawerItem>,
|
||||
val discoverEnabled: Boolean,
|
||||
val nowPlayingEnabled: Boolean,
|
||||
val nowPlayingTitle: String?,
|
||||
) {
|
||||
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
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import org.acra.ACRA
|
||||
import timber.log.Timber
|
||||
|
|
@ -14,7 +14,7 @@ import javax.inject.Singleton
|
|||
class NavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
var backStack: MutableList<NavKey> = mutableListOf()
|
||||
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
||||
|
||||
/**
|
||||
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import javax.inject.Singleton
|
|||
@Singleton
|
||||
class SetupNavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
constructor(
|
||||
private val navigationManager: NavigationManager,
|
||||
) {
|
||||
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||
|
||||
/**
|
||||
|
|
@ -25,6 +27,9 @@ class SetupNavigationManager
|
|||
fun navigateTo(destination: SetupDestination) {
|
||||
backStack[0] = destination
|
||||
log()
|
||||
if (destination !is SetupDestination.AppContent) {
|
||||
navigationManager.reloadHome()
|
||||
}
|
||||
}
|
||||
|
||||
private fun log() {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
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 com.github.damontecres.wholphin.data.AppDatabase
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
|
|
@ -85,4 +88,13 @@ object DatabaseModule {
|
|||
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.Dispatchers
|
||||
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.withContext
|
||||
import org.acra.ACRA
|
||||
|
|
@ -434,3 +437,18 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
|||
fun Int?.gt(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.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
|
|
@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException
|
|||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||
private var timeFormatter: DateTimeFormatter =
|
||||
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
|
||||
// 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`
|
||||
*/
|
||||
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? =
|
||||
date?.let {
|
||||
|
|
@ -109,30 +127,25 @@ fun abbreviateNumber(number: Int): String {
|
|||
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")
|
||||
|
||||
/**
|
||||
* Format bytes
|
||||
*/
|
||||
fun formatBytes(
|
||||
bytes: Int,
|
||||
suffixes: List<String> = byteSuffixes,
|
||||
) = formatBytes(bytes.toLong(), suffixes)
|
||||
|
||||
fun formatBytes(
|
||||
bytes: Long,
|
||||
suffixes: List<String> = byteSuffixes,
|
||||
divisor: Int = 1024,
|
||||
): String {
|
||||
var unit = 0
|
||||
var count = bytes.toDouble()
|
||||
while (count >= 1024 && unit + 1 < suffixes.size) {
|
||||
count /= 1024
|
||||
while (count >= divisor && unit + 1 < suffixes.size) {
|
||||
count /= divisor
|
||||
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
|
||||
val MediaSegmentType.stringRes: Int
|
||||
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.Text
|
||||
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.getTimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ fun TimeRemaining(
|
|||
val now by LocalClock.current.now
|
||||
val remainingStr =
|
||||
remember(remaining, now) {
|
||||
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
|
||||
val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
|
||||
buildAnnotatedString {
|
||||
dot()
|
||||
append(context.getString(R.string.ends_at, endTimeStr))
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ 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.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
|
|
@ -110,13 +111,14 @@ abstract class RecommendedViewModel(
|
|||
|
||||
fun update(
|
||||
@StringRes title: Int,
|
||||
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
block: suspend () -> List<BaseItem>,
|
||||
): Deferred<HomeRowLoadingState> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
val titleStr = context.getString(title)
|
||||
val row =
|
||||
try {
|
||||
HomeRowLoadingState.Success(titleStr, block.invoke())
|
||||
HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
|
||||
} catch (ex: Exception) {
|
||||
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.Text
|
||||
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.formatBitrate
|
||||
import com.github.damontecres.wholphin.ui.formatBytes
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
|
|
@ -116,7 +116,7 @@ fun ItemDetailsDialog(
|
|||
}
|
||||
source.bitrate?.let {
|
||||
add(
|
||||
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
||||
bitrateLabel to formatBitrate(it),
|
||||
)
|
||||
}
|
||||
source.runTimeTicks?.let {
|
||||
|
|
@ -297,7 +297,7 @@ private fun buildVideoStreamInfo(
|
|||
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
|
||||
add(aspectRatioLabel to aspectRatio)
|
||||
}
|
||||
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
||||
stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
|
||||
stream.averageFrameRate?.let {
|
||||
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
|
||||
}
|
||||
|
|
@ -405,7 +405,7 @@ private fun buildAudioStreamInfo(
|
|||
stream.channelLayout?.let { add(layoutLabel to it) }
|
||||
stream.channels?.let { add(channelsLabel to it.toString()) }
|
||||
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.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,45 @@ val BoxSetSortOptions =
|
|||
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
|
||||
fun getStringRes(sort: ItemSortBy): Int =
|
||||
when (sort) {
|
||||
|
|
@ -132,5 +171,11 @@ fun getStringRes(sort: ItemSortBy): Int =
|
|||
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
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.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
|
@ -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()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
@ -257,6 +276,12 @@ fun <T : CardGridItem> CardGrid(
|
|||
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
|
||||
jump(-jump1)
|
||||
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 {
|
||||
return@onKeyEvent false
|
||||
}
|
||||
|
|
@ -372,8 +397,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val letters = context.getString(R.string.jump_letters)
|
||||
val letters = stringResource(R.string.jump_letters)
|
||||
// Letters
|
||||
val currentLetter =
|
||||
remember(focusedIndex) {
|
||||
|
|
@ -383,12 +407,10 @@ fun <T : CardGridItem> CardGrid(
|
|||
?.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.let {
|
||||
if (it >= '0' && it <= '9') {
|
||||
'#'
|
||||
} else if (it >= 'A' && it <= 'Z') {
|
||||
it
|
||||
} else {
|
||||
null
|
||||
when (it) {
|
||||
in '0'..'9' -> '#'
|
||||
in 'A'..'Z' -> it
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
?: letters[0]
|
||||
|
|
@ -402,21 +424,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
.align(Alignment.CenterVertically)
|
||||
.padding(start = 16.dp),
|
||||
// Add end padding to push away from edge
|
||||
letterClicked = { letter ->
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
letterClicked = jumpToLetter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
item.data.albumId?.let { albumId ->
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_album),
|
||||
R.string.fa_compact_disc,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.MediaItem(
|
||||
albumId,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_artist),
|
||||
R.string.fa_user,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.MediaItem(
|
||||
artistId,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
seriesId?.let {
|
||||
add(
|
||||
DialogItem(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.ListItem
|
||||
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.GetItemsFilter
|
||||
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.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.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
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.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -73,72 +80,93 @@ import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
|||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||
import com.github.damontecres.wholphin.ui.components.FilterByButton
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
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.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
|
||||
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
|
||||
import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||
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.nav.Destination
|
||||
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.util.FilterUtils
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
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.LoadingExceptionHandler
|
||||
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.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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.MediaType
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
@HiltViewModel
|
||||
@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
|
||||
class PlaylistViewModel
|
||||
@Inject
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
navigationManager: NavigationManager,
|
||||
musicService: MusicService,
|
||||
mediaManagementService: MediaManagementService,
|
||||
private val backdropService: BackdropService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
) : ItemViewModel(api) {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
||||
val filterAndSort =
|
||||
MutableStateFlow<FilterAndSort>(
|
||||
FilterAndSort(
|
||||
filter = GetItemsFilter(),
|
||||
sortAndDirection =
|
||||
SortAndDirection(
|
||||
ItemSortBy.DEFAULT,
|
||||
SortOrder.ASCENDING,
|
||||
),
|
||||
),
|
||||
)
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: UUID,
|
||||
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: UUID): PlaylistViewModel
|
||||
}
|
||||
|
||||
fun init(playlistId: UUID) {
|
||||
loading.value = LoadingState.Loading
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
|
||||
) {
|
||||
val playlist = fetchItem(playlistId)
|
||||
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)
|
||||
|
|
@ -149,23 +177,28 @@ class PlaylistViewModel
|
|||
ItemSortBy.DEFAULT,
|
||||
SortOrder.ASCENDING,
|
||||
)
|
||||
loadItems(filter, sortAndDirection)
|
||||
loadItems(filter, sortAndDirection).join()
|
||||
determineMediaType()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching playlist %s", itemId)
|
||||
state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadItems(
|
||||
filter: GetItemsFilter,
|
||||
sortAndDirection: SortAndDirection,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
) = viewModelScope.launchIO {
|
||||
backdropService.clearBackdrop()
|
||||
loading.setValueOnMain(LoadingState.Loading)
|
||||
this@PlaylistViewModel.filterAndSort.update {
|
||||
FilterAndSort(filter, sortAndDirection)
|
||||
state.update {
|
||||
it.copy(
|
||||
loading = LoadingState.Loading,
|
||||
filterAndSort = FilterAndSort(filter, sortAndDirection),
|
||||
)
|
||||
}
|
||||
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val playlistId = item.value!!.id
|
||||
viewModelScope.launchIO {
|
||||
val libraryDisplayInfo =
|
||||
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
||||
|
|
@ -175,7 +208,7 @@ class PlaylistViewModel
|
|||
)
|
||||
?: LibraryDisplayInfo(
|
||||
userId = user.rowId,
|
||||
itemId = itemId,
|
||||
itemId = itemId.toServerString(),
|
||||
sort = sortAndDirection.sort,
|
||||
direction = sortAndDirection.direction,
|
||||
filter = filter,
|
||||
|
|
@ -187,7 +220,7 @@ class PlaylistViewModel
|
|||
val request =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = playlistId,
|
||||
parentId = itemId,
|
||||
userId = user.id,
|
||||
fields = DefaultItemFields,
|
||||
sortBy = listOf(sortAndDirection.sort),
|
||||
|
|
@ -202,27 +235,77 @@ class PlaylistViewModel
|
|||
GetItemsRequestHandler,
|
||||
viewModelScope,
|
||||
).init()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.value = pager
|
||||
loading.value = LoadingState.Success
|
||||
state.update {
|
||||
it.copy(
|
||||
items = pager,
|
||||
loading = LoadingState.Success,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching playlist %s", itemId)
|
||||
withContext(Dispatchers.Main) {
|
||||
items.value = listOf()
|
||||
loading.value = LoadingState.Error(ex)
|
||||
state.update {
|
||||
it.copy(
|
||||
items = emptyList(),
|
||||
loading = 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> =
|
||||
FilterUtils.getFilterOptionValues(
|
||||
api,
|
||||
serverRepository.currentUser.value?.id,
|
||||
itemUuid,
|
||||
itemId,
|
||||
filterOption,
|
||||
)
|
||||
|
||||
|
|
@ -231,6 +314,24 @@ class PlaylistViewModel
|
|||
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
|
||||
|
|
@ -239,52 +340,111 @@ data class FilterAndSort(
|
|||
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
|
||||
fun PlaylistDetails(
|
||||
preferences: UserPreferences,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlaylistViewModel = hiltViewModel(),
|
||||
viewModel: PlaylistViewModel =
|
||||
hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>(
|
||||
creationCallback = { it.create(destination.itemId) },
|
||||
),
|
||||
addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(destination.itemId)
|
||||
}
|
||||
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()
|
||||
val state by viewModel.state.collectAsState()
|
||||
val musicState by viewModel.musicState.collectAsState()
|
||||
|
||||
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)
|
||||
val playFromHereString = stringResource(R.string.play_from_here)
|
||||
|
||||
PlaylistDetailsContent(
|
||||
loadingState = loading,
|
||||
playlist = playlist,
|
||||
items = items,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
onClickIndex = { index, _ ->
|
||||
fun play(
|
||||
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 = false,
|
||||
filter = filterAndSort.filter,
|
||||
sortAndDirection = filterAndSort.sortAndDirection,
|
||||
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(
|
||||
loadingState = state.loading,
|
||||
playlist = state.playlist,
|
||||
items = state.items,
|
||||
musicState = musicState,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
onClickIndex = { index, item ->
|
||||
play(index, item, false)
|
||||
},
|
||||
onClickPlay = { shuffle ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = destination.itemId,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
filter = filterAndSort.filter,
|
||||
sortAndDirection = filterAndSort.sortAndDirection,
|
||||
),
|
||||
)
|
||||
state.playlist?.let {
|
||||
play(0, it, shuffle)
|
||||
}
|
||||
},
|
||||
onLongClickIndex = { index, item ->
|
||||
longClickDialog =
|
||||
|
|
@ -292,31 +452,30 @@ fun PlaylistDetails(
|
|||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
goToString,
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
DialogItem(
|
||||
playFromHereString,
|
||||
Icons.Default.PlayArrow,
|
||||
) {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = destination.itemId,
|
||||
startIndex = index,
|
||||
shuffle = false,
|
||||
filter = filterAndSort.filter,
|
||||
sortAndDirection = filterAndSort.sortAndDirection,
|
||||
),
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = musicMoreActions,
|
||||
item = item,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
)
|
||||
} else {
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
item = item,
|
||||
seriesId = item.data.seriesId,
|
||||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions = moreActions,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
filterAndSort = filterAndSort,
|
||||
filterAndSort = state.filterAndSort,
|
||||
onFilterAndSortChange = viewModel::loadItems,
|
||||
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
||||
modifier = modifier,
|
||||
|
|
@ -327,12 +486,46 @@ fun PlaylistDetails(
|
|||
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
|
||||
fun PlaylistDetailsContent(
|
||||
playlist: BaseItem?,
|
||||
items: List<BaseItem?>,
|
||||
musicState: MusicServiceState,
|
||||
onClickIndex: (Int, BaseItem) -> Unit,
|
||||
onLongClickIndex: (Int, BaseItem) -> Unit,
|
||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||
|
|
@ -449,10 +642,18 @@ fun PlaylistDetailsContent(
|
|||
onLongClickIndex.invoke(index, item)
|
||||
}
|
||||
},
|
||||
isPlaying =
|
||||
equalsNotNull(
|
||||
musicState.currentItemId,
|
||||
item?.id,
|
||||
),
|
||||
isQueued = item?.id in musicState.queuedIds,
|
||||
modifier =
|
||||
Modifier
|
||||
.height(80.dp)
|
||||
.ifElse(
|
||||
item?.type != BaseItemKind.AUDIO,
|
||||
Modifier.height(80.dp),
|
||||
).ifElse(
|
||||
index == savedIndex,
|
||||
Modifier.focusRequester(focus),
|
||||
).onFocusChanged {
|
||||
|
|
@ -582,6 +783,8 @@ fun PlaylistItem(
|
|||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
isPlaying: Boolean = false,
|
||||
isQueued: Boolean = false,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
ListItem(
|
||||
|
|
@ -592,14 +795,12 @@ fun PlaylistItem(
|
|||
headlineContent = {
|
||||
Text(
|
||||
text = item?.title ?: "",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = item?.subtitle ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
)
|
||||
},
|
||||
|
|
@ -609,18 +810,20 @@ fun PlaylistItem(
|
|||
val endTimeStr =
|
||||
remember(item, now) {
|
||||
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
||||
TimeFormatter.format(endTime)
|
||||
getTimeFormatter().format(endTime)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = duration.toString(),
|
||||
)
|
||||
if (item.type != BaseItemKind.AUDIO) {
|
||||
Text(
|
||||
text = stringResource(R.string.ends_at, endTimeStr),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Row(
|
||||
|
|
@ -631,6 +834,12 @@ fun PlaylistItem(
|
|||
text = "${index + 1}.",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
if (item?.type == BaseItemKind.AUDIO) {
|
||||
MusicQueueMarker(
|
||||
isPlaying = isPlaying,
|
||||
isQueued = isQueued,
|
||||
)
|
||||
} else {
|
||||
ItemCardImage(
|
||||
item = item,
|
||||
name = item?.name,
|
||||
|
|
@ -644,7 +853,52 @@ fun PlaylistItem(
|
|||
useFallbackText = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -85,6 +86,7 @@ fun EpisodeDetails(
|
|||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val canDelete by viewModel.canDelete.collectAsState()
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
|
|
@ -226,7 +228,7 @@ fun EpisodeDetails(
|
|||
onClearChosenStreams = {
|
||||
viewModel.clearChosenStreams(chosenStreams)
|
||||
},
|
||||
canDelete = viewModel.canDelete,
|
||||
canDelete = canDelete,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -236,7 +238,7 @@ fun EpisodeDetails(
|
|||
favoriteOnClick = {
|
||||
viewModel.setFavorite(ep.id, !ep.favorite)
|
||||
},
|
||||
canDelete = viewModel.canDelete,
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = { showDeleteDialog = ep },
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.episode
|
|||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
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.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.combinePair
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -34,6 +37,11 @@ import kotlinx.coroutines.Deferred
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -68,11 +76,19 @@ class EpisodeViewModel
|
|||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
|
||||
var canDelete: Boolean = false
|
||||
private set
|
||||
val canDelete = MutableStateFlow(false)
|
||||
|
||||
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> =
|
||||
|
|
@ -101,7 +117,6 @@ class EpisodeViewModel
|
|||
) {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val item = fetchAndSetItem().await()
|
||||
canDelete = mediaManagementService.canDelete(item)
|
||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@EpisodeViewModel.item.value = item
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ fun MovieDetails(
|
|||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
val canDelete by viewModel.canDelete.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
|
@ -211,7 +212,7 @@ fun MovieDetails(
|
|||
seriesId = null,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
canDelete = viewModel.canDelete,
|
||||
canDelete = canDelete,
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -323,7 +324,7 @@ fun MovieDetails(
|
|||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
canDelete = viewModel.canDelete,
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = { showDeleteDialog = movie },
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.movie
|
|||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
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.deleteItem
|
||||
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.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -46,6 +49,9 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -93,11 +99,19 @@ class MovieViewModel
|
|||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
|
||||
var canDelete: Boolean = false
|
||||
private set
|
||||
val canDelete = MutableStateFlow(false)
|
||||
|
||||
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> =
|
||||
|
|
@ -112,7 +126,6 @@ class MovieViewModel
|
|||
api.userLibraryApi.getItem(itemId).content.let {
|
||||
BaseItem.from(it, api)
|
||||
}
|
||||
canDelete = mediaManagementService.canDelete(item)
|
||||
this@MovieViewModel.item.setValueOnMain(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 showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.refresh()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
|
|
@ -148,9 +156,7 @@ fun SeriesDetails(
|
|||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
val played = item.data.userData?.played ?: false
|
||||
|
|
|
|||
|
|
@ -271,9 +271,9 @@ class SeriesViewModel
|
|||
}
|
||||
|
||||
fun onResumePage() {
|
||||
item.value?.let { item ->
|
||||
viewModelScope.launchDefault { backdropService.submit(item) }
|
||||
viewModelScope.launchIO {
|
||||
item.value?.let {
|
||||
backdropService.submit(it)
|
||||
val playThemeSongs =
|
||||
userPreferencesService
|
||||
.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() {
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
|
|
@ -292,6 +303,7 @@ class SeriesViewModel
|
|||
seasonNum: Int?,
|
||||
): Deferred<List<BaseItem?>> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
Timber.v("getSeasons for %s", series.id)
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = series.id,
|
||||
|
|
|
|||
|
|
@ -227,6 +227,15 @@ fun HomePageContent(
|
|||
listState: LazyListState = rememberLazyListState(),
|
||||
takeFocus: Boolean = true,
|
||||
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 =
|
||||
position.let {
|
||||
|
|
@ -266,13 +275,8 @@ fun HomePageContent(
|
|||
}
|
||||
Box(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
HomePageHeader(
|
||||
item = focusedItem,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
||||
.fillMaxHeight(.33f),
|
||||
)
|
||||
headerComposable.invoke(focusedItem)
|
||||
|
||||
val density = LocalDensity.current
|
||||
val spaceAbovePx =
|
||||
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.services.NavigationManager
|
||||
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.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
|
|
@ -76,6 +77,8 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -100,10 +103,15 @@ class SearchViewModel
|
|||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val episodes = 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)
|
||||
|
||||
private var currentQuery: String? = null
|
||||
|
||||
private val semaphore = Semaphore(4)
|
||||
|
||||
fun search(query: String?) {
|
||||
if (currentQuery == query) {
|
||||
return
|
||||
|
|
@ -117,6 +125,9 @@ class SearchViewModel
|
|||
searchInternal(query, BaseItemKind.MOVIE, movies)
|
||||
searchInternal(query, BaseItemKind.SERIES, series)
|
||||
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)
|
||||
searchSeerr(query)
|
||||
} else {
|
||||
|
|
@ -135,6 +146,7 @@ class SearchViewModel
|
|||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
try {
|
||||
semaphore.withPermit {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
searchTerm = query,
|
||||
|
|
@ -149,6 +161,7 @@ class SearchViewModel
|
|||
withContext(Dispatchers.Main) {
|
||||
target.value = SearchResult.Success(pager)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception searching for $type")
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -202,10 +215,13 @@ sealed interface SearchResult {
|
|||
|
||||
private const val SEARCH_ROW = 0
|
||||
private const val MOVIE_ROW = SEARCH_ROW + 1
|
||||
private const val COLLECTION_ROW = MOVIE_ROW + 1
|
||||
private const val SERIES_ROW = COLLECTION_ROW + 1
|
||||
private const val SERIES_ROW = MOVIE_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. */
|
||||
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
|
||||
|
|
@ -223,6 +239,9 @@ fun SearchPage(
|
|||
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
||||
val series by viewModel.series.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 query = rememberTextFieldState()
|
||||
|
|
@ -367,16 +386,6 @@ fun SearchPage(
|
|||
onClickPosition = { position = it },
|
||||
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(
|
||||
title = context.getString(R.string.tv_shows),
|
||||
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(
|
||||
title = context.getString(R.string.discover),
|
||||
result = seerrResults,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ data class HomeRowPresets(
|
|||
val tvLibrary: HomeRowViewOptions,
|
||||
val videoLibrary: HomeRowViewOptions,
|
||||
val photoLibrary: HomeRowViewOptions,
|
||||
val musicLibrary: HomeRowViewOptions,
|
||||
val playlist: HomeRowViewOptions,
|
||||
val liveTv: HomeRowViewOptions,
|
||||
val genreSize: Int,
|
||||
|
|
@ -51,8 +52,9 @@ data class HomeRowPresets(
|
|||
|
||||
CollectionType.LIVETV -> liveTv
|
||||
|
||||
CollectionType.MUSIC -> musicLibrary
|
||||
|
||||
CollectionType.UNKNOWN,
|
||||
CollectionType.MUSIC,
|
||||
CollectionType.BOOKS,
|
||||
CollectionType.PLAYLISTS,
|
||||
CollectionType.FOLDERS,
|
||||
|
|
@ -74,6 +76,10 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
|
|
@ -111,6 +117,11 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
|
|
@ -154,6 +165,11 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
|
|
@ -198,6 +214,11 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ val favoriteOptions by lazy {
|
|||
BaseItemKind.VIDEO to R.string.videos,
|
||||
BaseItemKind.PLAYLIST to R.string.playlists,
|
||||
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() }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionEx
|
|||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
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.showToast
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
|
|
@ -122,12 +123,12 @@ class HomeSettingsViewModel
|
|||
state.value
|
||||
.let { state ->
|
||||
state.rows
|
||||
.map { it.config }
|
||||
.map { row ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
try {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
row = row,
|
||||
row = row.config,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
userDto = userDto,
|
||||
|
|
@ -135,6 +136,10 @@ class HomeSettingsViewModel
|
|||
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 {
|
||||
updateState {
|
||||
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(
|
||||
rows = rows,
|
||||
rowData = rowData,
|
||||
|
|
@ -258,6 +268,24 @@ class HomeSettingsViewModel
|
|||
rowType: LibraryRowType,
|
||||
): Job =
|
||||
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 newRow =
|
||||
when (rowType) {
|
||||
|
|
@ -267,7 +295,7 @@ class HomeSettingsViewModel
|
|||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = RecentlyAdded(library.itemId),
|
||||
config = RecentlyAdded(library.itemId, viewOptions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +310,7 @@ class HomeSettingsViewModel
|
|||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = RecentlyReleased(library.itemId),
|
||||
config = RecentlyReleased(library.itemId, viewOptions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +329,7 @@ class HomeSettingsViewModel
|
|||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = Suggestions(library.itemId),
|
||||
config = Suggestions(library.itemId, viewOptions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -662,6 +690,7 @@ class HomeSettingsViewModel
|
|||
BaseItemKind.VIDEO -> preset.videoLibrary
|
||||
BaseItemKind.PLAYLIST -> preset.playlist
|
||||
BaseItemKind.PERSON -> preset.movieLibrary
|
||||
BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary
|
||||
else -> preset.movieLibrary
|
||||
}
|
||||
it.config.updateViewOptions(viewOptions)
|
||||
|
|
|
|||
|
|
@ -1,58 +1,28 @@
|
|||
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.foundation.lazy.rememberLazyListState
|
||||
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.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.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
|
||||
import androidx.navigation3.runtime.serialization.NavKeySerializer
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.DrawerValue
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
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.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
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.launchIO
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// Top scrim configuration for text readability (clock, season tabs)
|
||||
const val TOP_SCRIM_ALPHA = 0.55f
|
||||
|
|
@ -78,160 +48,23 @@ class ApplicationContentViewModel
|
|||
fun ApplicationContent(
|
||||
server: JellyfinServer,
|
||||
user: JellyfinUser,
|
||||
startDestination: Destination,
|
||||
navigationManager: NavigationManager,
|
||||
preferences: UserPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
enableTopScrim: Boolean = true,
|
||||
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)
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
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 backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
|
||||
Backdrop(
|
||||
drawerIsOpen = drawerState.isOpen,
|
||||
backdropStyle = backdropStyle,
|
||||
enableTopScrim = enableTopScrim,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
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()
|
||||
NavDisplay(
|
||||
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,
|
||||
) : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data object NowPlaying : Destination(true)
|
||||
|
||||
@Serializable
|
||||
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.LicenseInfo
|
||||
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.CollectionFolderLiveTv
|
||||
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.CollectionFolderPlaylist
|
||||
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.PersonPage
|
||||
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.DiscoverPersonPage
|
||||
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.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.SeriesOverview
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||
|
|
@ -142,11 +146,9 @@ fun DestinationContent(
|
|||
|
||||
BaseItemKind.BOX_SET -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolderBoxSet(
|
||||
CollectionDetails(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
recursive = false,
|
||||
playEnabled = true,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -154,6 +156,7 @@ fun DestinationContent(
|
|||
BaseItemKind.PLAYLIST -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
PlaylistDetails(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
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 -> {
|
||||
Timber.w("Unsupported item type: ${destination.type}")
|
||||
Text("Unsupported item type: ${destination.type}", modifier)
|
||||
|
|
@ -267,6 +288,10 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
Destination.NowPlaying -> {
|
||||
NowPlayingPage(modifier)
|
||||
}
|
||||
|
||||
Destination.UpdateApp -> {
|
||||
InstallUpdatePage(preferences, modifier)
|
||||
}
|
||||
|
|
@ -386,6 +411,14 @@ fun CollectionFolder(
|
|||
)
|
||||
}
|
||||
|
||||
CollectionType.MUSIC -> {
|
||||
CollectionFolderMusic(
|
||||
preferences,
|
||||
destination,
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
|
||||
CollectionType.HOMEVIDEOS,
|
||||
CollectionType.PHOTOS,
|
||||
-> {
|
||||
|
|
@ -398,7 +431,6 @@ fun CollectionFolder(
|
|||
}
|
||||
|
||||
CollectionType.MUSICVIDEOS,
|
||||
CollectionType.MUSIC,
|
||||
CollectionType.BOOKS,
|
||||
-> {
|
||||
CollectionFolderGeneric(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav
|
|||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.animateIntOffsetAsState
|
||||
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.interaction.MutableInteractionSource
|
||||
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.Home
|
||||
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.Settings
|
||||
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.UserPreferences
|
||||
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.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
|
|
@ -104,6 +109,7 @@ class NavDrawerViewModel
|
|||
val navigationManager: NavigationManager,
|
||||
val setupNavigationManager: SetupNavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
private val musicService: MusicService,
|
||||
) : ViewModel() {
|
||||
val state = navDrawerService.state
|
||||
|
||||
|
|
@ -178,9 +184,11 @@ class NavDrawerViewModel
|
|||
if (key is Destination) {
|
||||
val index =
|
||||
if (key is Destination.Home) {
|
||||
-1
|
||||
HOME_INDEX
|
||||
} else if (key is Destination.Search) {
|
||||
-2
|
||||
SEARCH_INDEX
|
||||
} else if (key is Destination.NowPlaying) {
|
||||
NOW_PLAYING_INDEX
|
||||
} else {
|
||||
val idx = asDestinations.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
|
|
@ -198,6 +206,13 @@ class NavDrawerViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToSetup(userList: SetupDestination) {
|
||||
viewModelScope.launchDefault {
|
||||
musicService.stop()
|
||||
setupNavigationManager.navigateTo(userList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
|
@ -324,12 +343,37 @@ fun NavDrawer(
|
|||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
viewModel.setupNavigationManager.navigateTo(
|
||||
viewModel.navigateToSetup(
|
||||
SetupDestination.UserList(server),
|
||||
)
|
||||
},
|
||||
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(
|
||||
state = navDrawerListState,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
|
|
@ -353,18 +397,18 @@ fun NavDrawer(
|
|||
IconNavItem(
|
||||
text = stringResource(R.string.search),
|
||||
icon = Icons.Default.Search,
|
||||
selected = selectedIndex == -2,
|
||||
selected = selectedIndex == SEARCH_INDEX,
|
||||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
viewModel.setIndex(-2)
|
||||
viewModel.setIndex(SEARCH_INDEX)
|
||||
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(searchFocusRequester)
|
||||
.ifElse(
|
||||
selectedIndex == -2,
|
||||
selectedIndex == SEARCH_INDEX,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
|
|
@ -374,11 +418,11 @@ fun NavDrawer(
|
|||
IconNavItem(
|
||||
text = stringResource(R.string.home),
|
||||
icon = Icons.Default.Home,
|
||||
selected = selectedIndex == -1,
|
||||
selected = selectedIndex == HOME_INDEX,
|
||||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
viewModel.setIndex(-1)
|
||||
viewModel.setIndex(HOME_INDEX)
|
||||
if (destination is Destination.Home) {
|
||||
viewModel.navigationManager.reloadHome()
|
||||
} else {
|
||||
|
|
@ -388,7 +432,7 @@ fun NavDrawer(
|
|||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
selectedIndex == -1,
|
||||
selectedIndex == HOME_INDEX,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.ForwardingSimpleBasePlayer
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import timber.log.Timber
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class MediaSessionPlayer(
|
||||
player: Player,
|
||||
private val controllerViewState: ControllerViewState,
|
||||
private val playbackPreferences: PlaybackPreferences,
|
||||
) : ForwardingSimpleBasePlayer(player) {
|
||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||
Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady")
|
||||
if (!playWhenReady && player.isPlaying) {
|
||||
controllerViewState.showControls()
|
||||
} else if (playWhenReady) {
|
||||
if (playWhenReady) {
|
||||
playbackPreferences.skipBackOnResume?.let {
|
||||
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 androidx.annotation.DrawableRes
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
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.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
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.preferences.AppThemeColors
|
||||
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.components.SelectedLeadingContent
|
||||
|
|
@ -284,7 +291,7 @@ fun SeekBar(
|
|||
}
|
||||
}
|
||||
|
||||
private val buttonSpacing = 12.dp
|
||||
val buttonSpacing = 12.dp
|
||||
|
||||
@Composable
|
||||
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
|
||||
fun <T> BottomDialog(
|
||||
choices: List<BottomDialogItem<T>>,
|
||||
|
|
@ -554,12 +609,18 @@ data class BottomDialogItem<T>(
|
|||
@Composable
|
||||
private fun ButtonPreview() {
|
||||
WholphinTheme {
|
||||
Row {
|
||||
Row(Modifier.background(Color.Red)) {
|
||||
PlaybackButton(
|
||||
iconRes = R.drawable.baseline_play_arrow_24,
|
||||
onClick = {},
|
||||
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.Text
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
||||
import com.github.damontecres.wholphin.ui.formatBytes
|
||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
|
@ -104,7 +103,7 @@ fun TranscodeInfo(
|
|||
"Reason:" to info.transcodeReasons.joinToString(", "),
|
||||
"HW Accel:" to info.hardwareAccelerationType?.toString(),
|
||||
"Container:" to info.container,
|
||||
"Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) },
|
||||
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
|
||||
),
|
||||
)
|
||||
SimpleTable(
|
||||
|
|
|
|||
|
|
@ -55,9 +55,7 @@ class PlaybackKeyHandler(
|
|||
} else if (oneClickPause && isEnterKey(it)) {
|
||||
val wasPlaying = player.isPlaying
|
||||
Util.handlePlayPauseButtonAction(player)
|
||||
if (wasPlaying) {
|
||||
controllerViewState.showControls()
|
||||
} else {
|
||||
if (!wasPlaying) {
|
||||
skipBackOnResume?.let {
|
||||
player.seekBack(it)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOut
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
|
|
@ -50,6 +57,10 @@ import androidx.compose.ui.input.key.type
|
|||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.media3.common.Player
|
||||
|
|
@ -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.AspectRatios
|
||||
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.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -119,17 +130,14 @@ fun PlaybackOverlay(
|
|||
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
|
||||
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
|
||||
|
||||
val chapterInteractionSources =
|
||||
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
val titleHeight =
|
||||
remember {
|
||||
remember(item?.title) {
|
||||
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
||||
}
|
||||
val subtitleHeight =
|
||||
remember {
|
||||
remember(item?.subtitleLong) {
|
||||
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +145,16 @@ fun PlaybackOverlay(
|
|||
var controllerHeight by remember { mutableStateOf(0.dp) }
|
||||
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
// Background scrim for OSD readability
|
||||
val scrimBrush =
|
||||
remember {
|
||||
|
|
@ -149,17 +167,6 @@ fun PlaybackOverlay(
|
|||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -169,10 +176,16 @@ fun PlaybackOverlay(
|
|||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
state == OverlayViewState.CONTROLLER,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
|
||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
if (seekBarFocused) {
|
||||
LaunchedEffect(Unit) {
|
||||
seekProgressPercent =
|
||||
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
||||
}
|
||||
}
|
||||
val nextState =
|
||||
if (chapters.isNotEmpty()) {
|
||||
OverlayViewState.CHAPTERS
|
||||
|
|
@ -253,11 +266,13 @@ fun PlaybackOverlay(
|
|||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
state == OverlayViewState.CHAPTERS,
|
||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
|
||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
val chapterInteractionSources =
|
||||
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val chapterIndex =
|
||||
remember {
|
||||
|
|
@ -368,7 +383,7 @@ fun PlaybackOverlay(
|
|||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
state == OverlayViewState.QUEUE,
|
||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
|
||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
|
|
@ -441,15 +456,17 @@ fun PlaybackOverlay(
|
|||
}
|
||||
}
|
||||
|
||||
if (seekBarInteractionSource.collectIsFocusedAsState().value) {
|
||||
LaunchedEffect(Unit) {
|
||||
seekProgressPercent =
|
||||
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Trickplay
|
||||
AnimatedVisibility(
|
||||
seekProgressPercent >= 0 && seekBarFocused,
|
||||
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
|
||||
enter =
|
||||
expandVertically(
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
visibilityThreshold = IntSize.VisibilityThreshold,
|
||||
),
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
|
|
@ -496,9 +513,13 @@ fun PlaybackOverlay(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top
|
||||
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
|
||||
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
|
||||
.align(Alignment.TopStart),
|
||||
|
|
@ -514,7 +535,9 @@ fun PlaybackOverlay(
|
|||
)
|
||||
}
|
||||
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
|
||||
.align(Alignment.TopEnd),
|
||||
|
|
@ -522,7 +545,9 @@ fun PlaybackOverlay(
|
|||
TimeDisplay()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
showDebugInfo && controllerViewState.controlsVisible,
|
||||
visible = showDebugInfo && controllerViewState.controlsVisible,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart),
|
||||
|
|
@ -578,6 +603,11 @@ fun Controller(
|
|||
val verticalOffset by animateDpAsState(
|
||||
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
||||
label = "TitleBumpOffset",
|
||||
animationSpec =
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = Dp.VisibilityThreshold,
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
|
|
@ -596,6 +626,9 @@ fun Controller(
|
|||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = titleTextSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
|
|
@ -610,6 +643,9 @@ fun Controller(
|
|||
text = it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontSize = subtitleTextSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -622,7 +658,7 @@ fun Controller(
|
|||
.toLong()
|
||||
.milliseconds
|
||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||
endTimeStr = TimeFormatter.format(endTime)
|
||||
endTimeStr = getTimeFormatter().format(endTime)
|
||||
delay(1.seconds)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import androidx.annotation.Dimension
|
|||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -374,13 +372,17 @@ fun PlaybackPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) {
|
||||
PauseIndicator(
|
||||
player = player,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
// The playback controls
|
||||
AnimatedVisibility(
|
||||
controllerViewState.controlsVisible,
|
||||
Modifier,
|
||||
slideInVertically { it },
|
||||
slideOutVertically { it },
|
||||
) {
|
||||
|
||||
PlaybackOverlay(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -412,7 +414,6 @@ fun PlaybackPageContent(
|
|||
currentSegment = currentSegment?.segment,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
)
|
||||
}
|
||||
|
||||
val subtitleSettings =
|
||||
remember(mediaInfo) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
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.PlayerFactory
|
||||
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.util.ExceptionHandler
|
||||
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.checkForSupport
|
||||
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
|
||||
|
|
@ -143,6 +145,7 @@ class PlaybackViewModel
|
|||
private val userPreferencesService: UserPreferencesService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val screensaverService: ScreensaverService,
|
||||
private val musicService: MusicService,
|
||||
@Assisted private val destination: Destination,
|
||||
) : ViewModel(),
|
||||
Player.Listener,
|
||||
|
|
@ -260,7 +263,6 @@ class PlaybackViewModel
|
|||
val sessionPlayer =
|
||||
MediaSessionPlayer(
|
||||
player,
|
||||
controllerViewState,
|
||||
preferences.appPreferences.playbackPreferences,
|
||||
)
|
||||
mediaSession =
|
||||
|
|
@ -273,6 +275,7 @@ class PlaybackViewModel
|
|||
* Initialize from the UI to start playback
|
||||
*/
|
||||
private suspend fun init() {
|
||||
musicService.stop()
|
||||
nextUp.setValueOnMain(null)
|
||||
this.preferences = userPreferencesService.getCurrent()
|
||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||
|
|
@ -737,12 +740,12 @@ class PlaybackViewModel
|
|||
player.removeListener(it)
|
||||
}
|
||||
|
||||
val playbackItemState = PlaybackItemState(playback, currentItemPlayback)
|
||||
val activityListener =
|
||||
TrackActivityPlaybackListener(
|
||||
api = api,
|
||||
player = player,
|
||||
playback = playback,
|
||||
itemPlayback = currentItemPlayback,
|
||||
getState = { playbackItemState },
|
||||
)
|
||||
player.addListener(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.launchDefault
|
||||
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.theme.WholphinTheme
|
||||
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(
|
||||
direction: MoveDirection,
|
||||
index: Int,
|
||||
|
|
@ -246,7 +242,7 @@ fun NavDrawerPreferenceListItem(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MoveButton(
|
||||
fun MoveButton(
|
||||
@StringRes icon: Int,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import androidx.compose.runtime.MutableState
|
|||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
|
@ -25,9 +25,9 @@ data class Clock(
|
|||
*/
|
||||
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
|
||||
|
|
@ -37,7 +37,7 @@ fun ProvideLocalClock(content: @Composable () -> Unit) {
|
|||
withContext(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
val now = LocalDateTime.now()
|
||||
val time = TimeFormatter.format(now)
|
||||
val time = getTimeFormatter().format(now)
|
||||
clock.now.value = now
|
||||
clock.timeString.value = time
|
||||
delay(2_000)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,26 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import java.util.function.IntFunction
|
||||
import java.util.function.Predicate
|
||||
|
||||
interface BlockingList<T> : List<T> {
|
||||
suspend fun getBlocking(index: Int): T
|
||||
|
||||
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.MUSICVIDEOS,
|
||||
CollectionType.FOLDERS,
|
||||
CollectionType.MUSIC,
|
||||
null, // Mixed
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
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.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
|
|
@ -20,6 +21,7 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks
|
|||
import timber.log.Timber
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -30,8 +32,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
class TrackActivityPlaybackListener(
|
||||
private val api: ApiClient,
|
||||
private val player: Player,
|
||||
val playback: CurrentPlayback,
|
||||
val itemPlayback: ItemPlayback,
|
||||
private val getState: () -> PlaybackItemState?,
|
||||
) : Player.Listener {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main)
|
||||
private val task: TimerTask =
|
||||
|
|
@ -50,46 +51,52 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
fun init() {
|
||||
launch("reportPlaybackStart") {
|
||||
Timber.v("reportPlaybackStart for ${itemPlayback.itemId}")
|
||||
getState.invoke()?.let { state ->
|
||||
Timber.v("reportPlaybackStart for ${state.itemId}")
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
canSeek = true,
|
||||
itemId = itemPlayback.itemId,
|
||||
itemId = state.itemId,
|
||||
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
|
||||
playMethod = playback.playMethod,
|
||||
playMethod = state.playMethod,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
isMuted = false,
|
||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||
playSessionId = playback.playSessionId,
|
||||
liveStreamId = playback.liveStreamId,
|
||||
audioStreamIndex = state.audioStreamIndex,
|
||||
subtitleStreamIndex = state.subtitleStreamIndex,
|
||||
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)
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
// player.removeListener(this)
|
||||
task.cancel()
|
||||
TIMER.purge()
|
||||
val position = player.currentPosition.milliseconds
|
||||
launch("reportPlaybackStopped") {
|
||||
Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position")
|
||||
getState.invoke()?.let { state ->
|
||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = itemPlayback.itemId,
|
||||
itemId = state.itemId,
|
||||
positionTicks = position.inWholeTicks,
|
||||
failed = false,
|
||||
playSessionId = playback.playSessionId,
|
||||
liveStreamId = playback.liveStreamId,
|
||||
playSessionId = state.playSessionId,
|
||||
liveStreamId = state.liveStreamId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (initialized) {
|
||||
|
|
@ -108,32 +115,34 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
private fun saveActivity(position: Long) {
|
||||
launch("saveActivity") {
|
||||
getState.invoke()?.let { state ->
|
||||
val calcPosition =
|
||||
withContext(Dispatchers.Main) {
|
||||
(if (position >= 0) position else player.currentPosition)
|
||||
}
|
||||
if (calcPosition > 0) {
|
||||
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
|
||||
Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition")
|
||||
Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition")
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = itemPlayback.itemId,
|
||||
itemId = state.itemId,
|
||||
positionTicks = calcPosition.milliseconds.inWholeTicks,
|
||||
canSeek = true,
|
||||
isPaused = isPaused,
|
||||
isMuted = false,
|
||||
playMethod = playback.playMethod,
|
||||
playMethod = state.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,
|
||||
audioStreamIndex = state.audioStreamIndex,
|
||||
subtitleStreamIndex = state.subtitleStreamIndex,
|
||||
playSessionId = state.playSessionId,
|
||||
liveStreamId = state.liveStreamId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launch(
|
||||
name: String,
|
||||
|
|
@ -143,7 +152,7 @@ class TrackActivityPlaybackListener(
|
|||
try {
|
||||
block.invoke(this)
|
||||
} 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) }
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
message MusicPreferences {
|
||||
bool show_lyrics = 1;
|
||||
bool show_visualizer = 2;
|
||||
bool show_album_Art = 3;
|
||||
bool show_backdrop = 4;
|
||||
}
|
||||
|
||||
message AppPreferences {
|
||||
// The currently signed in server and user IDs, mostly for restoring a session
|
||||
string current_server_id = 1;
|
||||
|
|
@ -194,4 +201,5 @@ message AppPreferences {
|
|||
AdvancedPreferences advanced_preferences = 10;
|
||||
bool sign_in_automatically = 11;
|
||||
PhotoPreferences photo_preferences = 12;
|
||||
MusicPreferences music_preferences = 13;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,4 +53,7 @@
|
|||
<string name="fa_check" translatable="false"></string>
|
||||
<string name="fa_cloud_arrow_up" 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>
|
||||
|
|
|
|||
|
|
@ -504,6 +504,14 @@
|
|||
<string name="send_media_info_log_to_server">Send media info log to server</string>
|
||||
<string name="no_limit">No limit</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="genres_in">Genres in %1$s</string>
|
||||
|
|
@ -707,6 +715,8 @@
|
|||
<item>@string/photos</item>
|
||||
</string-array>
|
||||
|
||||
<string name="search_for">Search %s</string>
|
||||
|
||||
<string name="actor">Actor</string>
|
||||
<string name="composer">Composer</string>
|
||||
<string name="writer">Writer</string>
|
||||
|
|
@ -719,7 +729,23 @@
|
|||
<string name="mixer">Mixer</string>
|
||||
<string name="creator">Creator</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="separate_types">Separate types</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
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.work.WorkManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
|
|
@ -278,5 +281,14 @@ object TestDatabaseModule {
|
|||
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"
|
||||
coreKtx = "1.18.0"
|
||||
appcompat = "1.7.1"
|
||||
composeBom = "2026.03.00"
|
||||
composeBom = "2026.03.01"
|
||||
mockk = "1.14.9"
|
||||
robolectric = "4.16.1"
|
||||
multiplatformMarkdownRenderer = "0.39.2"
|
||||
|
|
@ -33,17 +33,17 @@ material3AdaptiveNav3 = "1.0.0-alpha03"
|
|||
protobuf = "0.9.6"
|
||||
datastore = "1.2.1"
|
||||
kotlinx-serialization = "1.10.0"
|
||||
protobuf-javalite = "4.34.0"
|
||||
protobuf-javalite = "4.34.1"
|
||||
hilt = "2.59.2"
|
||||
room = "2.8.4"
|
||||
preferenceKtx = "1.2.1"
|
||||
tvprovider = "1.1.0"
|
||||
workRuntimeKtx = "2.11.1"
|
||||
workRuntimeKtx = "2.11.2"
|
||||
paletteKtx = "1.0.0"
|
||||
assMedia = "0.4.0"
|
||||
kotlinxCoroutinesTest = "1.10.2"
|
||||
coreTesting = "2.2.0"
|
||||
openapi-generator = "7.20.0"
|
||||
openapi-generator = "7.21.0"
|
||||
runner = "1.7.0"
|
||||
|
||||
[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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
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-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
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
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
|||
2
gradlew
vendored
2
gradlew
vendored
|
|
@ -57,7 +57,7 @@
|
|||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (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.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
# FFmpeg ExoPlayer decoder extension module
|
||||
|
||||
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_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 ffmpeg_decoder
|
||||
|
||||
|
|
@ -38,14 +44,16 @@ pushd ffmpeg_decoder || exit
|
|||
|
||||
if [[ -d media ]]; then
|
||||
pushd media || exit
|
||||
git checkout --force "$media_version"
|
||||
git fetch origin "$media_version" --depth 1
|
||||
git checkout --force FETCH_HEAD
|
||||
else
|
||||
git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media
|
||||
fi
|
||||
|
||||
if [[ -d ffmpeg ]]; then
|
||||
pushd ffmpeg || exit
|
||||
git checkout --force "$FFMPEG_BRANCH"
|
||||
git fetch origin "$FFMPEG_BRANCH" --depth 1
|
||||
git checkout --force FETCH_HEAD
|
||||
else
|
||||
git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg
|
||||
fi
|
||||
|
|
@ -68,7 +76,8 @@ pushd "$AV1_MODULE_PATH/jni" || exit
|
|||
|
||||
if [[ -d dav1d ]]; then
|
||||
pushd dav1d || exit
|
||||
git checkout --force "$DAV1D_BRANCH"
|
||||
git fetch origin "$DAV1D_BRANCH" --depth 1
|
||||
git checkout --force FETCH_HEAD
|
||||
else
|
||||
git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ function clone(){
|
|||
|
||||
if [[ -d "$dir" ]]; then
|
||||
pushd "$dir" || exit
|
||||
git checkout --force "$branch"
|
||||
git fetch origin "$branch" --depth 1
|
||||
git checkout --force FETCH_HEAD
|
||||
popd || exit
|
||||
else
|
||||
git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue