Merge branch 'main' into fea/libass-android-support

This commit is contained in:
Damontecres 2026-03-29 10:36:27 -04:00
commit 20fed46382
No known key found for this signature in database
93 changed files with 8674 additions and 726 deletions

View file

@ -21,7 +21,7 @@ runs:
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v4
with: with:
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Add NDK to path - name: Add NDK to path

View file

@ -219,6 +219,7 @@ dependencies {
implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
implementation(libs.androidx.tvprovider) implementation(libs.androidx.tvprovider)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)

View file

@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.MainContent import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
@ -43,16 +45,17 @@ class InstrumentedBasicUiTests {
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
@Test @Test
fun myTest() { fun myTest() {
val navigationManager = NavigationManager()
navigationManager.backStack = NavBackStack(Destination.Home())
// Start the app // Start the app
composeTestRule.setContent { composeTestRule.setContent {
WholphinTheme { WholphinTheme {
MainContent( MainContent(
backStack = mutableListOf(SetupDestination.ServerList), backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = mockk(relaxed = true), navigationManager = navigationManager,
appPreferences = mockk(relaxed = true), appPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true), backdropService = mockk(relaxed = true),
screensaverService = screensaverService, screensaverService = screensaverService,
requestedDestination = Destination.Home(),
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -127,6 +129,11 @@ class MainActivity : AppCompatActivity() {
private var signInAuto = true private var signInAuto = true
private val json =
Json {
classDiscriminator = "_type"
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -134,6 +141,23 @@ class MainActivity : AppCompatActivity() {
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
if (backStackStr != null) {
Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
val lastDest = backStack.lastOrNull()
if (lastDest is Destination.Playback ||
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else {
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
navigationManager.backStack = NavBackStack(startDestination)
}
viewModel.serverRepository.currentUser.observe(this) { user -> viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) { if (user?.hasPin == true) {
window?.setFlags( window?.setFlags(
@ -206,17 +230,12 @@ class MainActivity : AppCompatActivity() {
appThemeColors = appPreferences.interfacePreferences.appThemeColors, appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) { ) {
ProvideLocalClock { ProvideLocalClock {
val requestedDestination =
remember(intent) {
intent?.let(::extractDestination) ?: Destination.Home()
}
MainContent( MainContent(
backStack = setupNavigationManager.backStack, backStack = setupNavigationManager.backStack,
navigationManager = navigationManager, navigationManager = navigationManager,
appPreferences = appPreferences, appPreferences = appPreferences,
backdropService = backdropService, backdropService = backdropService,
screensaverService = screensaverService, screensaverService = screensaverService,
requestedDestination = requestedDestination,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@ -287,6 +306,8 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState") Timber.d("onSaveInstanceState")
val str = json.encodeToString(navigationManager.backStack.toList())
outState.putString(KEY_BACK_STACK, str)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -362,6 +383,8 @@ class MainActivity : AppCompatActivity() {
const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId" const val INTENT_SEASON_ID = "seaId"
private const val KEY_BACK_STACK = "backStack"
lateinit var instance: MainActivity lateinit var instance: MainActivity
private set private set
} }

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -33,10 +34,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@Composable @Composable
fun MainContent( fun MainContent(
@ -45,9 +44,9 @@ fun MainContent(
appPreferences: AppPreferences, appPreferences: AppPreferences,
backdropService: BackdropService, backdropService: BackdropService,
screensaverService: ScreensaverService, screensaverService: ScreensaverService,
requestedDestination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
Surface( Surface(
modifier = modifier =
modifier modifier
@ -96,10 +95,6 @@ fun MainContent(
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
val current = key.current val current = key.current
val preferences =
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember { var showContent by remember {
mutableStateOf(true) mutableStateOf(true)
} }
@ -113,7 +108,6 @@ fun MainContent(
ApplicationContent( ApplicationContent(
user = current.user, user = current.user,
server = current.server, server = current.server,
startDestination = requestedDestination,
navigationManager = navigationManager, navigationManager = navigationManager,
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View file

@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -61,6 +60,7 @@ class WholphinDreamService :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.d("onCreate")
savedStateRegistryController.performRestore(null) savedStateRegistryController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.CREATED
@ -74,6 +74,7 @@ class WholphinDreamService :
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
Timber.d("onAttachedToWindow")
val itemFlow = screensaverService.createItemFlow(lifecycleScope) val itemFlow = screensaverService.createItemFlow(lifecycleScope)
setContentView( setContentView(
ComposeView(this).apply { ComposeView(this).apply {
@ -109,11 +110,13 @@ class WholphinDreamService :
override fun onDreamingStarted() { override fun onDreamingStarted() {
super.onDreamingStarted() super.onDreamingStarted()
Timber.d("onDreamingStarted")
lifecycleRegistry.currentState = Lifecycle.State.STARTED lifecycleRegistry.currentState = Lifecycle.State.STARTED
} }
override fun onDreamingStopped() { override fun onDreamingStopped() {
super.onDreamingStopped() super.onDreamingStopped()
Timber.d("onDreamingStopped")
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
} }
} }

View file

@ -7,6 +7,7 @@ import androidx.room.Query
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.coroutines.flow.Flow
import java.util.UUID import java.util.UUID
@Dao @Dao
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
itemId: String, itemId: String,
): LibraryDisplayInfo? ): LibraryDisplayInfo?
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
fun getItemAsFlow(
userId: Int,
itemId: String,
): Flow<LibraryDisplayInfo?>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveItem(item: LibraryDisplayInfo): Long fun saveItem(item: LibraryDisplayInfo): Long

View file

@ -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,
)
}
}

View file

@ -5,12 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.DateFormatter
import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.music.artistsString
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.getDateFormatter
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
@ -32,7 +33,7 @@ import kotlin.time.Duration
@Stable @Stable
data class BaseItem( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean = false,
val imageUrlOverride: String? = null, val imageUrlOverride: String? = null,
val destinationOverride: Destination? = null, val destinationOverride: Destination? = null,
) : CardGridItem { ) : CardGridItem {
@ -57,6 +58,7 @@ data class BaseItem(
when (type) { when (type) {
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
BaseItemKind.SERIES -> data.seriesProductionYears BaseItemKind.SERIES -> data.seriesProductionYears
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
else -> data.productionYear?.toString() else -> data.productionYear?.toString()
} }
@ -116,7 +118,7 @@ data class BaseItem(
buildList { buildList {
if (type == BaseItemKind.EPISODE) { if (type == BaseItemKind.EPISODE) {
data.seasonEpisode?.let(::add) data.seasonEpisode?.let(::add)
data.premiereDate?.let { add(DateFormatter.format(it)) } data.premiereDate?.let { add(getDateFormatter().format(it)) }
} else if (type == BaseItemKind.SERIES) { } else if (type == BaseItemKind.SERIES) {
data.seriesProductionYears?.let(::add) data.seriesProductionYears?.let(::add)
} else if (type == BaseItemKind.PHOTO) { } else if (type == BaseItemKind.PHOTO) {
@ -125,6 +127,9 @@ data class BaseItem(
} else if (data.premiereDate != null) { } else if (data.premiereDate != null) {
add(data.premiereDate!!.toLocalDate().toString()) add(data.premiereDate!!.toLocalDate().toString())
} }
} else if (type == BaseItemKind.BOX_SET) {
data.productionYear?.let { add(it.toString()) }
data.childCount?.let { add("$it items") }
} else { } else {
data.productionYear?.let { add(it.toString()) } data.productionYear?.let { add(it.toString()) }
} }

View file

@ -9,12 +9,14 @@ import androidx.room.Ignore
import androidx.room.Index import androidx.room.Index
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity( @Entity(
foreignKeys = [ foreignKeys = [
@ -41,4 +43,13 @@ data class LibraryDisplayInfo(
) { ) {
@Ignore @Transient @Ignore @Transient
val sortAndDirection = SortAndDirection(sort, direction) val sortAndDirection = SortAndDirection(sort, direction)
constructor(
user: JellyfinUser,
itemId: UUID,
sort: ItemSortBy,
direction: SortOrder,
filter: GetItemsFilter,
viewOptions: ViewOptions?,
) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions)
} }

View file

@ -152,6 +152,15 @@ class AppPreferencesSerializer
slideshowDuration = AppPreference.SlideshowDuration.defaultValue slideshowDuration = AppPreference.SlideshowDuration.defaultValue
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
}.build() }.build()
musicPreferences =
MusicPreferences
.newBuilder()
.apply {
showBackdrop = true
showLyrics = true
showAlbumArt = true
}.build()
}.build() }.build()
override suspend fun readFrom(input: InputStream): AppPreferences { override suspend fun readFrom(input: InputStream): AppPreferences {
@ -220,6 +229,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
screensaverPreference = screensaverPreference.toBuilder().apply(block).build() screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
} }
inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences =
update {
musicPreferences = musicPreferences.toBuilder().apply(block).build()
}
fun SubtitlePreferences.Builder.resetSubtitles() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()

View file

@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
@ -280,5 +281,15 @@ class AppUpgradeHandler
) )
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) {
appPreferences.updateData {
it.updateMusicPreferences {
showBackdrop = true
showLyrics = true
showAlbumArt = true
}
}
}
} }
} }

View file

@ -114,7 +114,7 @@ class BackdropService
} }
} }
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) { if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT return@withContext ExtractedColors.DEFAULT

View file

@ -438,10 +438,7 @@ class HomeSettingsService
): HomeRowConfigDisplay = ): HomeRowConfigDisplay =
when (config) { when (config) {
is HomeRowConfig.ByParent -> { is HomeRowConfig.ByParent -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
name, name,
@ -466,10 +463,7 @@ class HomeSettingsService
} }
is HomeRowConfig.Genres -> { is HomeRowConfig.Genres -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.genres_in, name), context.getString(R.string.genres_in, name),
@ -490,10 +484,7 @@ class HomeSettingsService
} }
is HomeRowConfig.RecentlyAdded -> { is HomeRowConfig.RecentlyAdded -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.recently_added_in, name), context.getString(R.string.recently_added_in, name),
@ -502,10 +493,7 @@ class HomeSettingsService
} }
is HomeRowConfig.RecentlyReleased -> { is HomeRowConfig.RecentlyReleased -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.recently_released_in, name), context.getString(R.string.recently_released_in, name),
@ -547,10 +535,7 @@ class HomeSettingsService
} }
is HomeRowConfig.Suggestions -> { is HomeRowConfig.Suggestions -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.suggestions_for, name), title = context.getString(R.string.suggestions_for, name),
@ -559,6 +544,16 @@ class HomeSettingsService
} }
} }
private suspend fun getItemName(itemId: UUID): String? =
try {
api.userLibraryApi
.getItem(itemId = itemId)
.content.name
} catch (ex: Exception) {
Timber.e(ex, "Could not get name for %s", itemId)
context.getString(R.string.unknown)
}
/** /**
* Fetch the data from the server for a given [HomeRowConfig] * Fetch the data from the server for a given [HomeRowConfig]
*/ */

View file

@ -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)
}
}
}
}

View file

@ -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
}
}

View file

@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -33,6 +36,7 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
@Singleton @Singleton
class NavDrawerService class NavDrawerService
@ -44,6 +48,7 @@ class NavDrawerService
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao, private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val musicService: MusicService,
) { ) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state val state: StateFlow<NavDrawerItemState> = _state
@ -73,6 +78,40 @@ class NavDrawerService
.onEach { discoverActive -> .onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) } _state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope) }.launchIn(coroutineScope)
coroutineScope.launchDefault {
musicService.state.collectLatest { music ->
Timber.v("MusicService updated")
when (music.status) {
NowPlayingStatus.PLAYING -> {
_state.update {
it.copy(
nowPlayingEnabled = true,
nowPlayingTitle = music.currentItemTitle,
)
}
}
NowPlayingStatus.IDLE -> {
_state.update {
it.copy(
nowPlayingEnabled = false,
nowPlayingTitle = null,
)
}
}
NowPlayingStatus.PAUSED -> {
delay(2.hours)
_state.update {
it.copy(
nowPlayingEnabled = false,
nowPlayingTitle = null,
)
}
}
}
}
}
} }
suspend fun getAllUserLibraries( suspend fun getAllUserLibraries(
@ -185,9 +224,11 @@ data class NavDrawerItemState(
val items: List<NavDrawerItem>, val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem>, val moreItems: List<NavDrawerItem>,
val discoverEnabled: Boolean, val discoverEnabled: Boolean,
val nowPlayingEnabled: Boolean,
val nowPlayingTitle: String?,
) { ) {
companion object { companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
} }
} }

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import org.acra.ACRA import org.acra.ACRA
import timber.log.Timber import timber.log.Timber
@ -14,7 +14,7 @@ import javax.inject.Singleton
class NavigationManager class NavigationManager
@Inject @Inject
constructor() { constructor() {
var backStack: MutableList<NavKey> = mutableListOf() var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
/** /**
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]

View file

@ -16,7 +16,9 @@ import javax.inject.Singleton
@Singleton @Singleton
class SetupNavigationManager class SetupNavigationManager
@Inject @Inject
constructor() { constructor(
private val navigationManager: NavigationManager,
) {
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
/** /**
@ -25,6 +27,9 @@ class SetupNavigationManager
fun navigateTo(destination: SetupDestination) { fun navigateTo(destination: SetupDestination) {
backStack[0] = destination backStack[0] = destination
log() log()
if (destination !is SetupDestination.AppContent) {
navigationManager.reloadHome()
}
} }
private fun log() { private fun log() {

View file

@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room import androidx.room.Room
import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
@ -85,4 +88,13 @@ object DatabaseModule {
produceNewData = { AppPreferences.getDefaultInstance() }, produceNewData = { AppPreferences.getDefaultInstance() },
), ),
) )
@Provides
@Singleton
fun keyValueDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("key_value") },
)
} }

View file

@ -38,6 +38,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
@ -434,3 +437,18 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
fun Int?.gt(that: Int) = (this ?: 0) > that fun Int?.gt(that: Int) = (this ?: 0) > that
fun Int?.lt(that: Int) = (this ?: 0) < that fun Int?.lt(that: Int) = (this ?: 0) < that
/**
* Simplifies endlessly collecting a flow
*/
fun <T> Flow<T>.collectLatestIn(
scope: CoroutineScope,
action: suspend (value: T) -> Unit,
) {
scope.launchDefault { this@collectLatestIn.collectLatest(action) }
}
/**
* Easy way to combine two flows into a [Pair]
*/
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber import timber.log.Timber
import java.time.LocalDate import java.time.LocalDate
@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private var timeFormatter: DateTimeFormatter =
val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
fun getTimeFormatter(): DateTimeFormatter {
if (timeFormatter.locale != Locale.getDefault()) {
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
}
return timeFormatter
}
private var dateFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())
fun getDateFormatter(): DateTimeFormatter {
if (dateFormatter.locale != Locale.getDefault()) {
dateFormatter = dateFormatter.withLocale(Locale.getDefault())
}
return dateFormatter
}
// TODO server returns in UTC, but sdk converts to local time // TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
@ -25,9 +43,9 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy"
/** /**
* Format a [LocalDateTime] as `Aug 24, 2000` * Format a [LocalDateTime] as `Aug 24, 2000`
*/ */
fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime) fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime)
fun toLocalDate(date: String?): LocalDate? = fun toLocalDate(date: String?): LocalDate? =
date?.let { date?.let {
@ -109,30 +127,25 @@ fun abbreviateNumber(number: Int): String {
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit]) return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
} }
val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB") val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB")
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps") val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
/**
* Format bytes
*/
fun formatBytes(
bytes: Int,
suffixes: List<String> = byteSuffixes,
) = formatBytes(bytes.toLong(), suffixes)
fun formatBytes( fun formatBytes(
bytes: Long, bytes: Long,
suffixes: List<String> = byteSuffixes, suffixes: List<String> = byteSuffixes,
divisor: Int = 1024,
): String { ): String {
var unit = 0 var unit = 0
var count = bytes.toDouble() var count = bytes.toDouble()
while (count >= 1024 && unit + 1 < suffixes.size) { while (count >= divisor && unit + 1 < suffixes.size) {
count /= 1024 count /= divisor
unit++ unit++
} }
return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit])
} }
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
@get:StringRes @get:StringRes
val MediaSegmentType.stringRes: Int val MediaSegmentType.stringRes: Int
get() = get() =
@ -184,3 +197,45 @@ fun listToDotString(
} }
} }
} }
@StringRes
fun formatTypeName(type: BaseItemKind): Int =
when (type) {
BaseItemKind.MOVIE -> R.string.movies
BaseItemKind.SERIES -> R.string.tv_shows
BaseItemKind.EPISODE -> R.string.episodes
BaseItemKind.VIDEO -> R.string.videos
BaseItemKind.PLAYLIST -> R.string.playlists
BaseItemKind.PERSON -> R.string.people
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.AUDIO -> TODO()
BaseItemKind.CHANNEL -> R.string.channels
BaseItemKind.GENRE -> R.string.genres
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
BaseItemKind.MUSIC_ALBUM -> TODO()
BaseItemKind.MUSIC_ARTIST -> TODO()
BaseItemKind.MUSIC_GENRE -> TODO()
BaseItemKind.MUSIC_VIDEO -> TODO()
BaseItemKind.PHOTO -> R.string.photos
BaseItemKind.PHOTO_ALBUM -> TODO()
BaseItemKind.PROGRAM -> TODO()
BaseItemKind.RECORDING -> TODO()
BaseItemKind.SEASON -> R.string.tv_seasons
BaseItemKind.STUDIO -> R.string.studios
BaseItemKind.TRAILER -> R.string.trailers
BaseItemKind.TV_CHANNEL -> R.string.channels
BaseItemKind.TV_PROGRAM -> TODO()
BaseItemKind.USER_ROOT_FOLDER -> TODO()
BaseItemKind.USER_VIEW -> TODO()
BaseItemKind.YEAR -> TODO()
BaseItemKind.AGGREGATE_FOLDER -> TODO()
BaseItemKind.AUDIO_BOOK -> TODO()
BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
BaseItemKind.BOOK -> TODO()
BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
BaseItemKind.COLLECTION_FOLDER -> TODO()
BaseItemKind.FOLDER -> TODO()
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
}

View file

@ -21,8 +21,8 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import kotlin.time.Duration import kotlin.time.Duration
@ -107,7 +107,7 @@ fun TimeRemaining(
val now by LocalClock.current.now val now by LocalClock.current.now
val remainingStr = val remainingStr =
remember(remaining, now) { remember(remaining, now) {
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
buildAnnotatedString { buildAnnotatedString {
dot() dot()
append(context.getString(R.string.ends_at, endTimeStr)) append(context.getString(R.string.ends_at, endTimeStr))

View file

@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
@ -110,13 +111,14 @@ abstract class RecommendedViewModel(
fun update( fun update(
@StringRes title: Int, @StringRes title: Int,
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
block: suspend () -> List<BaseItem>, block: suspend () -> List<BaseItem>,
): Deferred<HomeRowLoadingState> = ): Deferred<HomeRowLoadingState> =
viewModelScope.async(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title) val titleStr = context.getString(title)
val row = val row =
try { try {
HomeRowLoadingState.Success(titleStr, block.invoke()) HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
} catch (ex: Exception) { } catch (ex: Exception) {
HomeRowLoadingState.Error(titleStr, null, ex) HomeRowLoadingState.Error(titleStr, null, ex)
} }

View file

@ -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,
)
}

View file

@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
@ -116,7 +116,7 @@ fun ItemDetailsDialog(
} }
source.bitrate?.let { source.bitrate?.let {
add( add(
bitrateLabel to formatBytes(it, byteRateSuffixes), bitrateLabel to formatBitrate(it),
) )
} }
source.runTimeTicks?.let { source.runTimeTicks?.let {
@ -297,7 +297,7 @@ private fun buildVideoStreamInfo(
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
add(aspectRatioLabel to aspectRatio) add(aspectRatioLabel to aspectRatio)
} }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
stream.averageFrameRate?.let { stream.averageFrameRate?.let {
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
} }
@ -405,7 +405,7 @@ private fun buildAudioStreamInfo(
stream.channelLayout?.let { add(layoutLabel to it) } stream.channelLayout?.let { add(layoutLabel to it) }
stream.channels?.let { add(channelsLabel to it.toString()) } stream.channels?.let { add(channelsLabel to it.toString()) }
stream.profile?.let { add(profileLabel to it) } stream.profile?.let { add(profileLabel to it) }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
} }

View file

@ -99,6 +99,45 @@ val BoxSetSortOptions =
ItemSortBy.RANDOM, ItemSortBy.RANDOM,
) )
val AlbumSortOptions =
listOf(
ItemSortBy.SORT_NAME,
ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.ALBUM_ARTIST,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.CRITIC_RATING,
ItemSortBy.RANDOM,
)
val ArtistSortOptions =
listOf(
ItemSortBy.SORT_NAME,
ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.CRITIC_RATING,
ItemSortBy.RANDOM,
)
val SongSortOptions =
listOf(
ItemSortBy.SORT_NAME,
ItemSortBy.PREMIERE_DATE,
ItemSortBy.ALBUM,
ItemSortBy.ALBUM_ARTIST,
ItemSortBy.ARTIST,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.CRITIC_RATING,
ItemSortBy.PLAY_COUNT,
ItemSortBy.RUNTIME,
ItemSortBy.RANDOM,
)
@StringRes @StringRes
fun getStringRes(sort: ItemSortBy): Int = fun getStringRes(sort: ItemSortBy): Int =
when (sort) { when (sort) {
@ -132,5 +171,11 @@ fun getStringRes(sort: ItemSortBy): Int =
ItemSortBy.DEFAULT -> R.string.default_track ItemSortBy.DEFAULT -> R.string.default_track
ItemSortBy.ALBUM -> R.string.album
ItemSortBy.ALBUM_ARTIST -> R.string.album_artist
ItemSortBy.ARTIST -> R.string.artist
else -> throw IllegalArgumentException("Unsupported sort option: $sort") else -> throw IllegalArgumentException("Unsupported sort option: $sort")
} }

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import android.view.KeyEvent
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -51,7 +52,6 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -199,6 +199,25 @@ fun <T : CardGridItem> CardGrid(
} }
} }
val jumpToLetter: (Char) -> Unit =
remember {
{ letter: Char ->
scope.launch(ExceptionHandler()) {
val jumpPosition =
withContext(Dispatchers.IO) {
letterPosition.invoke(letter)
}
Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) {
pager.getOrNull(jumpPosition)
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
}
}
}
}
if (pager.isEmpty()) { if (pager.isEmpty()) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -257,6 +276,12 @@ fun <T : CardGridItem> CardGrid(
} else if (useJumpRemoteButtons && isBackwardButton(it)) { } else if (useJumpRemoteButtons && isBackwardButton(it)) {
jump(-jump1) jump(-jump1)
return@onKeyEvent true return@onKeyEvent true
} else if (showLetterButtons && pager.isNotEmpty() &&
it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z)
) {
val letter = it.nativeKeyEvent.unicodeChar.toChar()
jumpToLetter.invoke(letter)
return@onKeyEvent true
} else { } else {
return@onKeyEvent false return@onKeyEvent false
} }
@ -372,8 +397,7 @@ fun <T : CardGridItem> CardGrid(
} }
} }
} }
val context = LocalContext.current val letters = stringResource(R.string.jump_letters)
val letters = context.getString(R.string.jump_letters)
// Letters // Letters
val currentLetter = val currentLetter =
remember(focusedIndex) { remember(focusedIndex) {
@ -383,12 +407,10 @@ fun <T : CardGridItem> CardGrid(
?.firstOrNull() ?.firstOrNull()
?.uppercaseChar() ?.uppercaseChar()
?.let { ?.let {
if (it >= '0' && it <= '9') { when (it) {
'#' in '0'..'9' -> '#'
} else if (it >= 'A' && it <= 'Z') { in 'A'..'Z' -> it
it else -> null
} else {
null
} }
} }
?: letters[0] ?: letters[0]
@ -402,21 +424,7 @@ fun <T : CardGridItem> CardGrid(
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
.padding(start = 16.dp), .padding(start = 16.dp),
// Add end padding to push away from edge // Add end padding to push away from edge
letterClicked = { letter -> letterClicked = jumpToLetter,
scope.launch(ExceptionHandler()) {
val jumpPosition =
withContext(Dispatchers.IO) {
letterPosition.invoke(letter)
}
Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) {
pager.getOrNull(jumpPosition)
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
}
}
},
) )
} }
} }

View file

@ -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,
)
}

View file

@ -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)
}
}
}
}

View file

@ -170,6 +170,38 @@ fun buildMoreDialogItems(
actions.onClickFavorite.invoke(item.id, !favorite) actions.onClickFavorite.invoke(item.id, !favorite)
}, },
) )
item.data.albumId?.let { albumId ->
add(
DialogItem(
context.getString(R.string.go_to_album),
R.string.fa_compact_disc,
) {
actions.navigateTo(
Destination.MediaItem(
albumId,
BaseItemKind.MUSIC_ALBUM,
null,
),
)
},
)
}
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
add(
DialogItem(
context.getString(R.string.go_to_artist),
R.string.fa_user,
) {
actions.navigateTo(
Destination.MediaItem(
artistId,
BaseItemKind.MUSIC_ARTIST,
null,
),
)
},
)
}
seriesId?.let { seriesId?.let {
add( add(
DialogItem( DialogItem(

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@ -16,11 +17,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@ -44,8 +45,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -60,12 +61,18 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.MusicServiceState
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -73,156 +80,232 @@ import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.FilterByButton import com.github.damontecres.wholphin.ui.components.FilterByButton
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SortByButton import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@HiltViewModel @HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
class PlaylistViewModel class PlaylistViewModel
@Inject @AssistedInject
constructor( constructor(
@ApplicationContext context: Context,
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, navigationManager: NavigationManager,
musicService: MusicService,
mediaManagementService: MediaManagementService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
) : ItemViewModel(api) { private val favoriteWatchManager: FavoriteWatchManager,
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) private val mediaReportService: MediaReportService,
val items = MutableLiveData<List<BaseItem?>>(listOf()) @Assisted itemId: UUID,
val filterAndSort = ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
MutableStateFlow<FilterAndSort>( @AssistedFactory
FilterAndSort( interface Factory {
filter = GetItemsFilter(), fun create(itemId: UUID): PlaylistViewModel
sortAndDirection = }
SortAndDirection(
val state = MutableStateFlow(PlaylistDetailsState())
val musicState = musicService.state
init {
init()
}
override fun init() {
state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault {
try {
val playlist =
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it, false) }
state.update { it.copy(playlist = playlist) }
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT, ItemSortBy.DEFAULT,
SortOrder.ASCENDING, SortOrder.ASCENDING,
), )
), loadItems(filter, sortAndDirection).join()
) determineMediaType()
} catch (ex: Exception) {
fun init(playlistId: UUID) { Timber.e(ex, "Error fetching playlist %s", itemId)
loading.value = LoadingState.Loading state.update { it.copy(loading = LoadingState.Error(ex)) }
viewModelScope.launch( }
Dispatchers.IO +
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
) {
val playlist = fetchItem(playlistId)
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
)
loadItems(filter, sortAndDirection)
} }
} }
fun loadItems( fun loadItems(
filter: GetItemsFilter, filter: GetItemsFilter,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
) { ) = viewModelScope.launchIO {
viewModelScope.launchIO { backdropService.clearBackdrop()
backdropService.clearBackdrop() state.update {
loading.setValueOnMain(LoadingState.Loading) it.copy(
this@PlaylistViewModel.filterAndSort.update { loading = LoadingState.Loading,
FilterAndSort(filter, sortAndDirection) filterAndSort = FilterAndSort(filter, sortAndDirection),
} )
}
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->
val playlistId = item.value!!.id viewModelScope.launchIO {
viewModelScope.launchIO { val libraryDisplayInfo =
val libraryDisplayInfo = libraryDisplayInfoDao.getItem(user, itemId)?.copy(
libraryDisplayInfoDao.getItem(user, itemId)?.copy( filter = filter,
filter = filter, sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
)
?: LibraryDisplayInfo(
userId = user.rowId,
itemId = itemId.toServerString(),
sort = sortAndDirection.sort, sort = sortAndDirection.sort,
direction = sortAndDirection.direction, direction = sortAndDirection.direction,
filter = filter,
viewOptions = null,
) )
?: LibraryDisplayInfo( libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
userId = user.rowId, }
itemId = itemId,
sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
filter = filter,
viewOptions = null,
)
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
}
val request = val request =
filter.applyTo( filter.applyTo(
GetItemsRequest( GetItemsRequest(
parentId = playlistId, parentId = itemId,
userId = user.id, userId = user.id,
fields = DefaultItemFields, fields = DefaultItemFields,
sortBy = listOf(sortAndDirection.sort), sortBy = listOf(sortAndDirection.sort),
sortOrder = listOf(sortAndDirection.direction), sortOrder = listOf(sortAndDirection.direction),
), ),
)
try {
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
).init()
state.update {
it.copy(
items = pager,
loading = LoadingState.Success,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching playlist %s", itemId)
state.update {
it.copy(
items = emptyList(),
loading = LoadingState.Error(ex),
) )
try {
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
).init()
withContext(Dispatchers.Main) {
items.value = pager
loading.value = LoadingState.Success
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching playlist %s", itemId)
withContext(Dispatchers.Main) {
items.value = listOf()
loading.value = LoadingState.Error(ex)
}
} }
} }
} }
} }
/**
* This method tries to determine the [MediaType] of a playlist
*
* In theory, the server will set the type, but sometime it doesn't
*/
private suspend fun determineMediaType() {
// Use the type the server says
var mediaType =
state.value.playlist
?.data
?.mediaType ?: MediaType.UNKNOWN
mediaType =
if (mediaType == MediaType.UNKNOWN) {
// Otherwise, if a most of the list is one type, we can assume that type
val pager = (state.value.items as? ApiRequestPager<*>)
if (pager != null && pager.size <= 50) {
val types =
(0..<50.coerceAtMost(pager.size)).groupBy { index ->
val pagerItem = pager.getBlocking(index)
when (pagerItem?.type) {
BaseItemKind.AUDIO -> MediaType.AUDIO
BaseItemKind.VIDEO,
BaseItemKind.EPISODE,
BaseItemKind.MOVIE,
BaseItemKind.BOX_SET,
-> MediaType.VIDEO
else -> MediaType.UNKNOWN
}
}
if (types.keys.size == 1) {
types.keys.first()
} else {
MediaType.UNKNOWN
}
} else {
MediaType.UNKNOWN
}
} else {
mediaType
}
Timber.d("mediaType=%s", mediaType)
state.update {
it.copy(mediaType = mediaType)
}
}
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
FilterUtils.getFilterOptionValues( FilterUtils.getFilterOptionValues(
api, api,
serverRepository.currentUser.value?.id, serverRepository.currentUser.value?.id,
itemUuid, itemId,
filterOption, filterOption,
) )
@ -231,6 +314,24 @@ class PlaylistViewModel
backdropService.submit(item) backdropService.submit(item)
} }
} }
fun setWatched(
itemId: UUID,
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
}
fun setFavorite(
itemId: UUID,
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
}
fun sendMediaReport(itemId: UUID) {
viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) }
}
} }
@Immutable @Immutable
@ -239,52 +340,111 @@ data class FilterAndSort(
val sortAndDirection: SortAndDirection, val sortAndDirection: SortAndDirection,
) )
data class PlaylistDetailsState(
val playlist: BaseItem? = null,
val mediaType: MediaType = MediaType.UNKNOWN,
val items: List<BaseItem?> = emptyList(),
val filterAndSort: FilterAndSort =
FilterAndSort(
filter = GetItemsFilter(),
sortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
),
val loading: LoadingState = LoadingState.Pending,
)
@Composable @Composable
fun PlaylistDetails( fun PlaylistDetails(
preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaylistViewModel = hiltViewModel(), viewModel: PlaylistViewModel =
hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { val state by viewModel.state.collectAsState()
viewModel.init(destination.itemId) val musicState by viewModel.musicState.collectAsState()
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val playlist by viewModel.item.observeAsState(null)
val items by viewModel.items.observeAsState(listOf())
val filterAndSort by viewModel.filterAndSort.collectAsState()
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) } var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val goToString = stringResource(R.string.go_to) fun play(
val playFromHereString = stringResource(R.string.play_from_here) index: Int,
item: BaseItem,
shuffle: Boolean,
mediaTypeOverride: MediaType? = null,
) {
when (mediaTypeOverride ?: state.mediaType) {
MediaType.VIDEO -> {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = destination.itemId,
startIndex = index,
shuffle = shuffle,
filter = state.filterAndSort.filter,
sortAndDirection = state.filterAndSort.sortAndDirection,
),
)
}
MediaType.AUDIO -> {
viewModel.play(item, index, shuffle)
}
else -> {
showConfirmTypeDialog = Triple(index, item, shuffle)
}
}
}
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val musicMoreActions =
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
onClickPlayNext = { index, item -> viewModel.playNext(item) },
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
onClickAddPlaylist = { itemId ->
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
showPlaylistDialog.makePresent(itemId)
},
onClickRemoveFromQueue = {},
onClickDelete = { showDeleteDialog = it },
)
val moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
onClickAddPlaylist = { itemId ->
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel::sendMediaReport,
onClickDelete = { showDeleteDialog = it },
)
PlaylistDetailsContent( PlaylistDetailsContent(
loadingState = loading, loadingState = state.loading,
playlist = playlist, playlist = state.playlist,
items = items, items = state.items,
musicState = musicState,
onChangeBackdrop = viewModel::updateBackdrop, onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ -> onClickIndex = { index, item ->
viewModel.navigationManager.navigateTo( play(index, item, false)
Destination.PlaybackList(
itemId = destination.itemId,
startIndex = index,
shuffle = false,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
}, },
onClickPlay = { shuffle -> onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo( state.playlist?.let {
Destination.PlaybackList( play(0, it, shuffle)
itemId = destination.itemId, }
startIndex = 0,
shuffle = shuffle,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
}, },
onLongClickIndex = { index, item -> onLongClickIndex = { index, item ->
longClickDialog = longClickDialog =
@ -292,31 +452,30 @@ fun PlaylistDetails(
fromLongClick = true, fromLongClick = true,
title = item.name ?: "", title = item.name ?: "",
items = items =
listOf( if (item.type == BaseItemKind.AUDIO) {
DialogItem( buildMoreDialogForMusic(
goToString, context = context,
Icons.Default.ArrowForward, actions = musicMoreActions,
) { item = item,
viewModel.navigationManager.navigateTo(item.destination()) index = index,
}, canRemove = false,
DialogItem( canDelete = viewModel.canDelete(item, preferences.appPreferences),
playFromHereString, )
Icons.Default.PlayArrow, } else {
) { buildMoreDialogItemsForHome(
viewModel.navigationManager.navigateTo( context = context,
Destination.PlaybackList( item = item,
itemId = destination.itemId, seriesId = item.data.seriesId,
startIndex = index, playbackPosition = item.playbackPosition,
shuffle = false, watched = item.played,
filter = filterAndSort.filter, favorite = item.favorite,
sortAndDirection = filterAndSort.sortAndDirection, canDelete = viewModel.canDelete(item, preferences.appPreferences),
), actions = moreActions,
) )
}, },
),
) )
}, },
filterAndSort = filterAndSort, filterAndSort = state.filterAndSort,
onFilterAndSortChange = viewModel::loadItems, onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues, getPossibleFilterValues = viewModel::getFilterOptionValues,
modifier = modifier, modifier = modifier,
@ -327,12 +486,46 @@ fun PlaylistDetails(
onDismissRequest = { longClickDialog = null }, onDismissRequest = { longClickDialog = null },
) )
} }
showConfirmTypeDialog?.let { (index, item, shuffle) ->
ConfirmMediaTypeDialog(
onConfirm = { mediaType -> play(index, item, shuffle, mediaType) },
onCancel = { showConfirmTypeDialog = null },
)
}
showPlaylistDialog.compose { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
state = addPlaylistState,
onDismissRequest = { showPlaylistDialog.makeAbsent() },
onClick = {
addToPlaylistViewModel.addToPlaylist(it.id, itemId)
showPlaylistDialog.makeAbsent()
},
createEnabled = true,
onCreatePlaylist = {
addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent()
},
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
} }
@Composable @Composable
fun PlaylistDetailsContent( fun PlaylistDetailsContent(
playlist: BaseItem?, playlist: BaseItem?,
items: List<BaseItem?>, items: List<BaseItem?>,
musicState: MusicServiceState,
onClickIndex: (Int, BaseItem) -> Unit, onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
@ -449,10 +642,18 @@ fun PlaylistDetailsContent(
onLongClickIndex.invoke(index, item) onLongClickIndex.invoke(index, item)
} }
}, },
isPlaying =
equalsNotNull(
musicState.currentItemId,
item?.id,
),
isQueued = item?.id in musicState.queuedIds,
modifier = modifier =
Modifier Modifier
.height(80.dp)
.ifElse( .ifElse(
item?.type != BaseItemKind.AUDIO,
Modifier.height(80.dp),
).ifElse(
index == savedIndex, index == savedIndex,
Modifier.focusRequester(focus), Modifier.focusRequester(focus),
).onFocusChanged { ).onFocusChanged {
@ -582,6 +783,8 @@ fun PlaylistItem(
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
isPlaying: Boolean = false,
isQueued: Boolean = false,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
ListItem( ListItem(
@ -592,14 +795,12 @@ fun PlaylistItem(
headlineContent = { headlineContent = {
Text( Text(
text = item?.title ?: "", text = item?.title ?: "",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.enableMarquee(focused), modifier = Modifier.enableMarquee(focused),
) )
}, },
supportingContent = { supportingContent = {
Text( Text(
text = item?.subtitle ?: "", text = item?.subtitle ?: "",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.enableMarquee(focused), modifier = Modifier.enableMarquee(focused),
) )
}, },
@ -609,16 +810,18 @@ fun PlaylistItem(
val endTimeStr = val endTimeStr =
remember(item, now) { remember(item, now) {
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
TimeFormatter.format(endTime) getTimeFormatter().format(endTime)
} }
Column { Column {
Text( Text(
text = duration.toString(), text = duration.toString(),
) )
Text( if (item.type != BaseItemKind.AUDIO) {
text = stringResource(R.string.ends_at, endTimeStr), Text(
style = MaterialTheme.typography.bodySmall, text = stringResource(R.string.ends_at, endTimeStr),
) style = MaterialTheme.typography.bodySmall,
)
}
} }
} }
}, },
@ -631,20 +834,71 @@ fun PlaylistItem(
text = "${index + 1}.", text = "${index + 1}.",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
) )
ItemCardImage( if (item?.type == BaseItemKind.AUDIO) {
item = item, MusicQueueMarker(
name = item?.name, isPlaying = isPlaying,
showOverlay = true, isQueued = isQueued,
favorite = item?.data?.userData?.isFavorite ?: false, )
watched = item?.data?.userData?.played ?: false, } else {
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, ItemCardImage(
watchedPercent = 0.0, item = item,
numberOfVersions = item?.data?.mediaSourceCount ?: 0, name = item?.name,
modifier = Modifier.width(160.dp), showOverlay = true,
useFallbackText = false, favorite = item?.data?.userData?.isFavorite ?: false,
) watched = item?.data?.userData?.played ?: false,
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
watchedPercent = 0.0,
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
modifier = Modifier.width(160.dp),
useFallbackText = false,
)
}
} }
}, },
modifier = modifier, modifier = modifier,
) )
} }
@Composable
fun ConfirmMediaTypeDialog(
onConfirm: (MediaType) -> Unit,
onCancel: () -> Unit,
) {
BasicDialog(
onDismissRequest = onCancel,
properties = DialogProperties(),
elevation = 3.dp,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.wrapContentSize(),
) {
item {
Text(
text = stringResource(R.string.play_as_type),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillParentMaxWidth(),
)
}
item {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
stringRes = R.string.audio,
onClick = { onConfirm.invoke(MediaType.AUDIO) },
)
TextButton(
stringRes = R.string.video,
onClick = { onConfirm.invoke(MediaType.VIDEO) },
)
}
}
}
}
}

View file

@ -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,
)
}
}
}
}

View file

@ -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)
},
)
}

View file

@ -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()
}
}
},
)
}
}
}
}

View file

@ -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
}
},
)
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -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,
)

View file

@ -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,
)
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -85,6 +86,7 @@ fun EpisodeDetails(
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val canDelete by viewModel.canDelete.collectAsState()
val preferredSubtitleLanguage = val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto viewModel.serverRepository.currentUserDto
@ -226,7 +228,7 @@ fun EpisodeDetails(
onClearChosenStreams = { onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams) viewModel.clearChosenStreams(chosenStreams)
}, },
canDelete = viewModel.canDelete, canDelete = canDelete,
), ),
) )
}, },
@ -236,7 +238,7 @@ fun EpisodeDetails(
favoriteOnClick = { favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite) viewModel.setFavorite(ep.id, !ep.favorite)
}, },
canDelete = viewModel.canDelete, canDelete = canDelete,
deleteOnClick = { showDeleteDialog = ep }, deleteOnClick = { showDeleteDialog = ep },
modifier = modifier, modifier = modifier,
) )

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.episode
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
@ -19,6 +20,8 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.combinePair
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -34,6 +37,11 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -68,11 +76,19 @@ class EpisodeViewModel
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
var canDelete: Boolean = false val canDelete = MutableStateFlow(false)
private set
init { init {
init() init()
viewModelScope.launchDefault {
item
.asFlow()
.filterNotNull()
.combinePair(userPreferencesService.flow.map { it.appPreferences })
.collectLatest { (item, preferences) ->
canDelete.update { mediaManagementService.canDelete(item, preferences) }
}
}
} }
private fun fetchAndSetItem(): Deferred<BaseItem> = private fun fetchAndSetItem(): Deferred<BaseItem> =
@ -101,7 +117,6 @@ class EpisodeViewModel
) { ) {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await() val item = fetchAndSetItem().await()
canDelete = mediaManagementService.canDelete(item)
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item this@EpisodeViewModel.item.value = item

View file

@ -106,6 +106,7 @@ fun MovieDetails(
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val discovered by viewModel.discovered.collectAsState() val discovered by viewModel.discovered.collectAsState()
val canDelete by viewModel.canDelete.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -211,7 +212,7 @@ fun MovieDetails(
seriesId = null, seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete, canDelete = canDelete,
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -323,7 +324,7 @@ fun MovieDetails(
onClickDiscover = { index, item -> onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination) viewModel.navigateTo(item.destination)
}, },
canDelete = viewModel.canDelete, canDelete = canDelete,
deleteOnClick = { showDeleteDialog = movie }, deleteOnClick = { showDeleteDialog = movie },
modifier = modifier, modifier = modifier,
) )

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ExtrasItem
@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.combinePair
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -46,6 +49,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -93,11 +99,19 @@ class MovieViewModel
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
var canDelete: Boolean = false val canDelete = MutableStateFlow(false)
private set
init { init {
init() init()
viewModelScope.launchDefault {
item
.asFlow()
.filterNotNull()
.combinePair(userPreferencesService.flow.map { it.appPreferences })
.collectLatest { (item, preferences) ->
canDelete.update { mediaManagementService.canDelete(item, preferences) }
}
}
} }
private fun fetchAndSetItem(): Deferred<BaseItem> = private fun fetchAndSetItem(): Deferred<BaseItem> =
@ -112,7 +126,6 @@ class MovieViewModel
api.userLibraryApi.getItem(itemId).content.let { api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api) BaseItem.from(it, api)
} }
canDelete = mediaManagementService.canDelete(item)
this@MovieViewModel.item.setValueOnMain(item) this@MovieViewModel.item.setValueOnMain(item)
item item
} }

View file

@ -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()
}
}
},
)
}
}
}
}
}

View file

@ -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()
}
}
},
)
}
}
}
}
}

View file

@ -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),
)
}
}
}

View file

@ -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),
),
)
}
}
}
}
}
}

View file

@ -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,
)

View file

@ -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 }
},
),
)

View file

@ -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()
}

View file

@ -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 ->
},
)
}
}
}
}
}
}

View file

@ -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,
)
}
}
}
}

View file

@ -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),
)
}
}
}
}
}
}
}
}

View file

@ -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),
)
}
}
}
}

View file

@ -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()
}

View file

@ -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()
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -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()
}

View file

@ -132,6 +132,14 @@ fun SeriesDetails(
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
LifecycleResumeEffect(destination.itemId) {
viewModel.refresh()
onPauseOrDispose {
viewModel.release()
}
}
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(state, modifier)
@ -148,9 +156,7 @@ fun SeriesDetails(
LifecycleResumeEffect(destination.itemId) { LifecycleResumeEffect(destination.itemId) {
viewModel.onResumePage() viewModel.onResumePage()
onPauseOrDispose { onPauseOrDispose {}
viewModel.release()
}
} }
val played = item.data.userData?.played ?: false val played = item.data.userData?.played ?: false

View file

@ -271,9 +271,9 @@ class SeriesViewModel
} }
fun onResumePage() { fun onResumePage() {
viewModelScope.launchIO { item.value?.let { item ->
item.value?.let { viewModelScope.launchDefault { backdropService.submit(item) }
backdropService.submit(it) viewModelScope.launchIO {
val playThemeSongs = val playThemeSongs =
userPreferencesService userPreferencesService
.getCurrent() .getCurrent()
@ -283,6 +283,17 @@ class SeriesViewModel
} }
} }
fun refresh() {
item.value?.let { item ->
if (loading.value == LoadingState.Success) {
viewModelScope.launchIO {
val seasons = getSeasons(item, null).await()
this@SeriesViewModel.seasons.setValueOnMain(seasons)
}
}
}
}
fun release() { fun release() {
themeSongPlayer.stop() themeSongPlayer.stop()
} }
@ -292,6 +303,7 @@ class SeriesViewModel
seasonNum: Int?, seasonNum: Int?,
): Deferred<List<BaseItem?>> = ): Deferred<List<BaseItem?>> =
viewModelScope.async(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
Timber.v("getSeasons for %s", series.id)
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = series.id, parentId = series.id,

View file

@ -227,6 +227,15 @@ fun HomePageContent(
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
takeFocus: Boolean = true, takeFocus: Boolean = true,
showEmptyRows: Boolean = false, showEmptyRows: Boolean = false,
headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem ->
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f),
)
},
) { ) {
val focusedItem = val focusedItem =
position.let { position.let {
@ -266,13 +275,8 @@ fun HomePageContent(
} }
Box(modifier = modifier) { Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( headerComposable.invoke(focusedItem)
item = focusedItem,
modifier =
Modifier
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f),
)
val density = LocalDensity.current val density = LocalDensity.current
val spaceAbovePx = val spaceAbovePx =
with(density) { with(density) {

View file

@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
@ -76,6 +77,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -100,10 +103,15 @@ class SearchViewModel
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery) val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery) val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery) val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val albums = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery) val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private var currentQuery: String? = null private var currentQuery: String? = null
private val semaphore = Semaphore(4)
fun search(query: String?) { fun search(query: String?) {
if (currentQuery == query) { if (currentQuery == query) {
return return
@ -117,6 +125,9 @@ class SearchViewModel
searchInternal(query, BaseItemKind.MOVIE, movies) searchInternal(query, BaseItemKind.MOVIE, movies)
searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes) searchInternal(query, BaseItemKind.EPISODE, episodes)
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
searchInternal(query, BaseItemKind.AUDIO, songs)
searchInternal(query, BaseItemKind.BOX_SET, collections) searchInternal(query, BaseItemKind.BOX_SET, collections)
searchSeerr(query) searchSeerr(query)
} else { } else {
@ -135,19 +146,21 @@ class SearchViewModel
) { ) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try { try {
val request = semaphore.withPermit {
GetItemsRequest( val request =
searchTerm = query, GetItemsRequest(
recursive = true, searchTerm = query,
includeItemTypes = listOf(type), recursive = true,
fields = SlimItemFields, includeItemTypes = listOf(type),
limit = 25, fields = SlimItemFields,
) limit = 25,
val pager = )
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) val pager =
pager.init() ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
withContext(Dispatchers.Main) { pager.init()
target.value = SearchResult.Success(pager) withContext(Dispatchers.Main) {
target.value = SearchResult.Success(pager)
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception searching for $type") Timber.e(ex, "Exception searching for $type")
@ -202,10 +215,13 @@ sealed interface SearchResult {
private const val SEARCH_ROW = 0 private const val SEARCH_ROW = 0
private const val MOVIE_ROW = SEARCH_ROW + 1 private const val MOVIE_ROW = SEARCH_ROW + 1
private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val SERIES_ROW = MOVIE_ROW + 1
private const val SERIES_ROW = COLLECTION_ROW + 1
private const val EPISODE_ROW = SERIES_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1
private const val SEERR_ROW = EPISODE_ROW + 1 private const val ALBUM_ROW = EPISODE_ROW + 1
private const val ARTIST_ROW = ALBUM_ROW + 1
private const val SONG_ROW = ARTIST_ROW + 1
private const val COLLECTION_ROW = SONG_ROW + 1
private const val SEERR_ROW = COLLECTION_ROW + 1
/** Delay for focus to settle after voice search dialog dismisses. */ /** Delay for focus to settle after voice search dialog dismisses. */
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
@ -223,6 +239,9 @@ fun SearchPage(
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
val albums by viewModel.albums.observeAsState(SearchResult.NoQuery)
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
// val query = rememberTextFieldState() // val query = rememberTextFieldState()
@ -367,16 +386,6 @@ fun SearchPage(
onClickPosition = { position = it }, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
searchResultRow(
title = context.getString(R.string.collections),
result = collections,
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequesters[COLLECTION_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
searchResultRow( searchResultRow(
title = context.getString(R.string.tv_shows), title = context.getString(R.string.tv_shows),
result = series, result = series,
@ -409,6 +418,85 @@ fun SearchPage(
) )
}, },
) )
searchResultRow(
title = context.getString(R.string.albums),
result = albums,
rowIndex = ALBUM_ROW,
position = position,
focusRequester = focusRequesters[ALBUM_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
position = RowColumn(ALBUM_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.heightEpisode,
aspectRatio = AspectRatios.SQUARE,
modifier = mod,
)
},
)
searchResultRow(
title = context.getString(R.string.artists),
result = artists,
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequesters[COLLECTION_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
position = RowColumn(ALBUM_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.heightEpisode,
aspectRatio = AspectRatios.SQUARE,
modifier = mod,
)
},
)
searchResultRow(
title = context.getString(R.string.songs),
result = songs,
rowIndex = SONG_ROW,
position = position,
focusRequester = focusRequesters[SONG_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
position = RowColumn(ALBUM_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.heightEpisode,
aspectRatio = AspectRatios.SQUARE,
modifier = mod,
)
},
)
searchResultRow(
title = context.getString(R.string.collections),
result = collections,
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequesters[COLLECTION_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
searchResultRow( searchResultRow(
title = context.getString(R.string.discover), title = context.getString(R.string.discover),
result = seerrResults, result = seerrResults,

View file

@ -29,6 +29,7 @@ data class HomeRowPresets(
val tvLibrary: HomeRowViewOptions, val tvLibrary: HomeRowViewOptions,
val videoLibrary: HomeRowViewOptions, val videoLibrary: HomeRowViewOptions,
val photoLibrary: HomeRowViewOptions, val photoLibrary: HomeRowViewOptions,
val musicLibrary: HomeRowViewOptions,
val playlist: HomeRowViewOptions, val playlist: HomeRowViewOptions,
val liveTv: HomeRowViewOptions, val liveTv: HomeRowViewOptions,
val genreSize: Int, val genreSize: Int,
@ -51,8 +52,9 @@ data class HomeRowPresets(
CollectionType.LIVETV -> liveTv CollectionType.LIVETV -> liveTv
CollectionType.MUSIC -> musicLibrary
CollectionType.UNKNOWN, CollectionType.UNKNOWN,
CollectionType.MUSIC,
CollectionType.BOOKS, CollectionType.BOOKS,
CollectionType.PLAYLISTS, CollectionType.PLAYLISTS,
CollectionType.FOLDERS, CollectionType.FOLDERS,
@ -74,6 +76,10 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE, aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP, contentScale = PrefContentScale.CROP,
), ),
musicLibrary =
HomeRowViewOptions(
aspectRatio = AspectRatio.SQUARE,
),
playlist = playlist =
HomeRowViewOptions( HomeRowViewOptions(
aspectRatio = AspectRatio.SQUARE, aspectRatio = AspectRatio.SQUARE,
@ -111,6 +117,11 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE, aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP, contentScale = PrefContentScale.CROP,
), ),
musicLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.SQUARE,
),
playlist = playlist =
HomeRowViewOptions( HomeRowViewOptions(
heightDp = epHeight, heightDp = epHeight,
@ -154,6 +165,11 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE, aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP, contentScale = PrefContentScale.CROP,
), ),
musicLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.SQUARE,
),
playlist = playlist =
HomeRowViewOptions( HomeRowViewOptions(
heightDp = epHeight, heightDp = epHeight,
@ -198,6 +214,11 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE, aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP, contentScale = PrefContentScale.CROP,
), ),
musicLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.SQUARE,
),
playlist = playlist =
HomeRowViewOptions( HomeRowViewOptions(
heightDp = epHeight, heightDp = epHeight,

View file

@ -59,6 +59,8 @@ val favoriteOptions by lazy {
BaseItemKind.VIDEO to R.string.videos, BaseItemKind.VIDEO to R.string.videos,
BaseItemKind.PLAYLIST to R.string.playlists, BaseItemKind.PLAYLIST to R.string.playlists,
BaseItemKind.PERSON to R.string.people, BaseItemKind.PERSON to R.string.people,
BaseItemKind.MUSIC_ARTIST to R.string.artists,
BaseItemKind.MUSIC_ALBUM to R.string.albums,
) )
} }
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() } val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }

View file

@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionEx
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
@ -122,19 +123,23 @@ class HomeSettingsViewModel
state.value state.value
.let { state -> .let { state ->
state.rows state.rows
.map { it.config }
.map { row -> .map { row ->
viewModelScope.async(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit { semaphore.withPermit {
homeSettingsService.fetchDataForRow( try {
row = row, homeSettingsService.fetchDataForRow(
scope = viewModelScope, row = row.config,
prefs = prefs, scope = viewModelScope,
userDto = userDto, prefs = prefs,
libraries = state.libraries, userDto = userDto,
limit = limit, libraries = state.libraries,
isRefresh = false, limit = limit,
) isRefresh = false,
)
} catch (ex: Exception) {
Timber.e(ex, "Error on row %s", row)
HomeRowLoadingState.Error(row.title, exception = ex)
}
} }
} }
} }
@ -195,7 +200,12 @@ class HomeSettingsViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
updateState { updateState {
val rows = it.rows.toMutableList().apply { removeAt(index) } val rows = it.rows.toMutableList().apply { removeAt(index) }
val rowData = it.rowData.toMutableList().apply { removeAt(index) } val rowData =
if (index in it.rowData.indices) {
it.rowData.toMutableList().apply { removeAt(index) }
} else {
it.rowData
}
it.copy( it.copy(
rows = rows, rows = rows,
rowData = rowData, rowData = rowData,
@ -258,6 +268,24 @@ class HomeSettingsViewModel
rowType: LibraryRowType, rowType: LibraryRowType,
): Job = ): Job =
viewModelScope.launchIO { viewModelScope.launchIO {
val viewOptions =
when (library.collectionType) {
CollectionType.MUSIC -> {
HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE)
}
CollectionType.HOMEVIDEOS,
CollectionType.MUSICVIDEOS,
-> {
HomeRowViewOptions(
aspectRatio = AspectRatio.WIDE,
)
}
else -> {
HomeRowViewOptions()
}
}
val id = idCounter++ val id = idCounter++
val newRow = val newRow =
when (rowType) { when (rowType) {
@ -267,7 +295,7 @@ class HomeSettingsViewModel
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
config = RecentlyAdded(library.itemId), config = RecentlyAdded(library.itemId, viewOptions),
) )
} }
@ -282,7 +310,7 @@ class HomeSettingsViewModel
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
config = RecentlyReleased(library.itemId), config = RecentlyReleased(library.itemId, viewOptions),
) )
} }
@ -301,7 +329,7 @@ class HomeSettingsViewModel
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,
config = Suggestions(library.itemId), config = Suggestions(library.itemId, viewOptions),
) )
} }
@ -662,6 +690,7 @@ class HomeSettingsViewModel
BaseItemKind.VIDEO -> preset.videoLibrary BaseItemKind.VIDEO -> preset.videoLibrary
BaseItemKind.PLAYLIST -> preset.playlist BaseItemKind.PLAYLIST -> preset.playlist
BaseItemKind.PERSON -> preset.movieLibrary BaseItemKind.PERSON -> preset.movieLibrary
BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary
else -> preset.movieLibrary else -> preset.movieLibrary
} }
it.config.updateViewOptions(viewOptions) it.config.updateViewOptions(viewOptions)

View file

@ -1,58 +1,28 @@
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.DrawerValue import androidx.tv.material3.DrawerValue
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.rememberDrawerState import androidx.tv.material3.rememberDrawerState
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
// Top scrim configuration for text readability (clock, season tabs) // Top scrim configuration for text readability (clock, season tabs)
const val TOP_SCRIM_ALPHA = 0.55f const val TOP_SCRIM_ALPHA = 0.55f
@ -78,160 +48,23 @@ class ApplicationContentViewModel
fun ApplicationContent( fun ApplicationContent(
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
startDestination: Destination,
navigationManager: NavigationManager, navigationManager: NavigationManager,
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enableTopScrim: Boolean = true, enableTopScrim: Boolean = true,
viewModel: ApplicationContentViewModel = hiltViewModel(), viewModel: ApplicationContentViewModel = hiltViewModel(),
) { ) {
val backStack: MutableList<NavKey> =
rememberSerializable(
server,
user,
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
) {
NavBackStack(startDestination)
}
navigationManager.backStack = backStack
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
val baseBackgroundColor = MaterialTheme.colorScheme.background val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
if (backdrop.hasColors && Backdrop(
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) drawerIsOpen = drawerState.isOpen,
) { backdropStyle = backdropStyle,
val animPrimary by animateColorAsState( enableTopScrim = enableTopScrim,
backdrop.primaryColor, viewModel = viewModel,
animationSpec = tween(1250), )
label = "dynamic_backdrop_primary",
)
val animSecondary by animateColorAsState(
backdrop.secondaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_secondary",
)
val animTertiary by animateColorAsState(
backdrop.tertiaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_tertiary",
)
Box(
modifier =
Modifier
.fillMaxSize()
.drawBehind {
drawRect(color = baseBackgroundColor)
// Top Left (Vibrant/Muted)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animSecondary, Color.Transparent),
center = Offset(0f, 0f),
radius = size.width * 0.8f,
),
)
// Bottom Right (DarkVibrant/DarkMuted)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animPrimary, Color.Transparent),
center = Offset(size.width, size.height),
radius = size.width * 0.8f,
),
)
// Bottom Left (Dark / Bridge)
drawRect(
brush =
Brush.radialGradient(
colors =
listOf(
baseBackgroundColor,
Color.Transparent,
),
center = Offset(0f, size.height),
radius = size.width * 0.8f,
),
)
// Top Right (Under Image - Vibrant/Bright)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animTertiary, Color.Transparent),
center = Offset(size.width, 0f),
radius = size.width * 0.8f,
),
)
},
)
}
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
Box(
modifier = Modifier.fillMaxSize(),
) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(backdrop.imageUrl)
.transitionFactory(CrossFadeFactory(800.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
Modifier
.align(Alignment.TopEnd)
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
if (drawerState.isOpen) {
drawRect(
brush = SolidColor(Color.Black),
alpha = .75f,
)
}
// Subtle top scrim for system UI readability (clock, tabs)
if (enableTopScrim) {
drawRect(
brush =
Brush.verticalGradient(
colorStops =
arrayOf(
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
TOP_SCRIM_END_FRACTION to Color.Transparent,
),
),
blendMode = BlendMode.Multiply,
)
}
drawRect(
brush =
Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = 0f,
endX = size.width * 0.6f,
),
blendMode = BlendMode.DstIn,
)
drawRect(
brush =
Brush.verticalGradient(
colors = listOf(Color.Black, Color.Transparent),
startY = 0f,
endY = size.height,
),
blendMode = BlendMode.DstIn,
)
},
)
}
}
val navDrawerListState = rememberLazyListState() val navDrawerListState = rememberLazyListState()
NavDisplay( NavDisplay(
backStack = navigationManager.backStack, backStack = navigationManager.backStack,

View file

@ -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,
)
},
)
}
}
}

View file

@ -129,6 +129,9 @@ sealed class Destination(
val item: DiscoverItem, val item: DiscoverItem,
) : Destination(false) ) : Destination(false)
@Serializable
data object NowPlaying : Destination(true)
@Serializable @Serializable
data object UpdateApp : Destination(true) data object UpdateApp : Destination(true)

View file

@ -11,10 +11,10 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.ItemGrid
import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.components.LicenseInfo
import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
@ -23,11 +23,15 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage
import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PersonPage
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage
import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage
import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverPage
@ -142,11 +146,9 @@ fun DestinationContent(
BaseItemKind.BOX_SET -> { BaseItemKind.BOX_SET -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() } LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderBoxSet( CollectionDetails(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
recursive = false,
playEnabled = true,
modifier = modifier, modifier = modifier,
) )
} }
@ -154,6 +156,7 @@ fun DestinationContent(
BaseItemKind.PLAYLIST -> { BaseItemKind.PLAYLIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() } LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PlaylistDetails( PlaylistDetails(
preferences = preferences,
destination = destination, destination = destination,
modifier = modifier, modifier = modifier,
) )
@ -214,6 +217,24 @@ fun DestinationContent(
) )
} }
BaseItemKind.MUSIC_ALBUM -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
AlbumDetailsPage(
preferences = preferences,
itemId = destination.itemId,
modifier = modifier,
)
}
BaseItemKind.MUSIC_ARTIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
ArtistDetailsPage(
preferences = preferences,
itemId = destination.itemId,
modifier = modifier,
)
}
else -> { else -> {
Timber.w("Unsupported item type: ${destination.type}") Timber.w("Unsupported item type: ${destination.type}")
Text("Unsupported item type: ${destination.type}", modifier) Text("Unsupported item type: ${destination.type}", modifier)
@ -267,6 +288,10 @@ fun DestinationContent(
) )
} }
Destination.NowPlaying -> {
NowPlayingPage(modifier)
}
Destination.UpdateApp -> { Destination.UpdateApp -> {
InstallUpdatePage(preferences, modifier) InstallUpdatePage(preferences, modifier)
} }
@ -386,6 +411,14 @@ fun CollectionFolder(
) )
} }
CollectionType.MUSIC -> {
CollectionFolderMusic(
preferences,
destination,
modifier,
)
}
CollectionType.HOMEVIDEOS, CollectionType.HOMEVIDEOS,
CollectionType.PHOTOS, CollectionType.PHOTOS,
-> { -> {
@ -398,7 +431,6 @@ fun CollectionFolder(
} }
CollectionType.MUSICVIDEOS, CollectionType.MUSICVIDEOS,
CollectionType.MUSIC,
CollectionType.BOOKS, CollectionType.BOOKS,
-> { -> {
CollectionFolderGeneric( CollectionFolderGeneric(

View file

@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateIntOffsetAsState import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -24,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
@ -104,6 +109,7 @@ class NavDrawerViewModel
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager, val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService, val backdropService: BackdropService,
private val musicService: MusicService,
) : ViewModel() { ) : ViewModel() {
val state = navDrawerService.state val state = navDrawerService.state
@ -178,9 +184,11 @@ class NavDrawerViewModel
if (key is Destination) { if (key is Destination) {
val index = val index =
if (key is Destination.Home) { if (key is Destination.Home) {
-1 HOME_INDEX
} else if (key is Destination.Search) { } else if (key is Destination.Search) {
-2 SEARCH_INDEX
} else if (key is Destination.NowPlaying) {
NOW_PLAYING_INDEX
} else { } else {
val idx = asDestinations.indexOf(key) val idx = asDestinations.indexOf(key)
if (idx >= 0) { if (idx >= 0) {
@ -198,6 +206,13 @@ class NavDrawerViewModel
} }
} }
} }
fun navigateToSetup(userList: SetupDestination) {
viewModelScope.launchDefault {
musicService.stop()
setupNavigationManager.navigateTo(userList)
}
}
} }
sealed interface NavDrawerItem { sealed interface NavDrawerItem {
@ -242,6 +257,10 @@ data class ServerNavDrawerItem(
} }
} }
private const val HOME_INDEX = -1
private const val SEARCH_INDEX = -2
private const val NOW_PLAYING_INDEX = -3
/** /**
* Display the left side navigation drawer with [DestinationContent] on the right * Display the left side navigation drawer with [DestinationContent] on the right
*/ */
@ -324,12 +343,37 @@ fun NavDrawer(
drawerOpen = isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setupNavigationManager.navigateTo( viewModel.navigateToSetup(
SetupDestination.UserList(server), SetupDestination.UserList(server),
) )
}, },
modifier = Modifier, modifier = Modifier,
) )
AnimatedVisibility(
visible = state.nowPlayingEnabled,
enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top),
) {
val interactionSource = remember { MutableInteractionSource() }
IconNavItem(
text = stringResource(R.string.now_playing),
subtext = state.nowPlayingTitle,
icon = Icons.Default.PlayArrow,
selected = selectedIndex == NOW_PLAYING_INDEX,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(NOW_PLAYING_INDEX)
viewModel.navigationManager.navigateTo(Destination.NowPlaying)
},
modifier =
Modifier
.ifElse(
selectedIndex == NOW_PLAYING_INDEX,
Modifier.focusRequester(focusRequester),
),
)
}
LazyColumn( LazyColumn(
state = navDrawerListState, state = navDrawerListState,
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
@ -353,18 +397,18 @@ fun NavDrawer(
IconNavItem( IconNavItem(
text = stringResource(R.string.search), text = stringResource(R.string.search),
icon = Icons.Default.Search, icon = Icons.Default.Search,
selected = selectedIndex == -2, selected = selectedIndex == SEARCH_INDEX,
drawerOpen = isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-2) viewModel.setIndex(SEARCH_INDEX)
viewModel.navigationManager.navigateToFromDrawer(Destination.Search) viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
}, },
modifier = modifier =
Modifier Modifier
.focusRequester(searchFocusRequester) .focusRequester(searchFocusRequester)
.ifElse( .ifElse(
selectedIndex == -2, selectedIndex == SEARCH_INDEX,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
), ),
) )
@ -374,11 +418,11 @@ fun NavDrawer(
IconNavItem( IconNavItem(
text = stringResource(R.string.home), text = stringResource(R.string.home),
icon = Icons.Default.Home, icon = Icons.Default.Home,
selected = selectedIndex == -1, selected = selectedIndex == HOME_INDEX,
drawerOpen = isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-1) viewModel.setIndex(HOME_INDEX)
if (destination is Destination.Home) { if (destination is Destination.Home) {
viewModel.navigationManager.reloadHome() viewModel.navigationManager.reloadHome()
} else { } else {
@ -388,7 +432,7 @@ fun NavDrawer(
modifier = modifier =
Modifier Modifier
.ifElse( .ifElse(
selectedIndex == -1, selectedIndex == HOME_INDEX,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
), ),
) )

View file

@ -1,23 +1,23 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import androidx.annotation.OptIn
import androidx.media3.common.ForwardingSimpleBasePlayer import androidx.media3.common.ForwardingSimpleBasePlayer
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekBack
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import timber.log.Timber import timber.log.Timber
@OptIn(UnstableApi::class)
class MediaSessionPlayer( class MediaSessionPlayer(
player: Player, player: Player,
private val controllerViewState: ControllerViewState,
private val playbackPreferences: PlaybackPreferences, private val playbackPreferences: PlaybackPreferences,
) : ForwardingSimpleBasePlayer(player) { ) : ForwardingSimpleBasePlayer(player) {
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady")
if (!playWhenReady && player.isPlaying) { if (playWhenReady) {
controllerViewState.showControls()
} else if (playWhenReady) {
playbackPreferences.skipBackOnResume?.let { playbackPreferences.skipBackOnResume?.let {
player.seekBack(it) player.seekBack(it)
} }

View file

@ -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()
}
}

View file

@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity import android.view.Gravity
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -38,10 +39,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
@ -57,6 +63,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
@ -284,7 +291,7 @@ fun SeekBar(
} }
} }
private val buttonSpacing = 12.dp val buttonSpacing = 12.dp
@Composable @Composable
fun LeftPlaybackButtons( fun LeftPlaybackButtons(
@ -460,6 +467,54 @@ fun PlaybackButton(
} }
} }
@Composable
fun PlaybackFaButton(
@StringRes iconRes: Int,
onClick: () -> Unit,
onControllerInteraction: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
textColor: Color = Color.Unspecified,
) {
val selectedColor = MaterialTheme.colorScheme.border
Button(
enabled = enabled,
onClick = onClick,
// shape = ButtonDefaults.shape(CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = AppColors.TransparentBlack25,
focusedContainerColor = selectedColor,
),
contentPadding = PaddingValues(4.dp),
interactionSource = interactionSource,
modifier =
modifier
.size(36.dp, 36.dp)
.onFocusChanged { onControllerInteraction.invoke() },
) {
Text(
text = stringResource(iconRes),
fontSize = 18.sp,
fontFamily = FontAwesome,
textAlign = TextAlign.Center,
color =
if (textColor.isSpecified) {
textColor
} else if (LocalTheme.current == AppThemeColors.OLED_BLACK) {
LocalContentColor.current
} else {
MaterialTheme.colorScheme.onSurface
},
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.CenterVertically),
)
}
}
@Composable @Composable
fun <T> BottomDialog( fun <T> BottomDialog(
choices: List<BottomDialogItem<T>>, choices: List<BottomDialogItem<T>>,
@ -554,12 +609,18 @@ data class BottomDialogItem<T>(
@Composable @Composable
private fun ButtonPreview() { private fun ButtonPreview() {
WholphinTheme { WholphinTheme {
Row { Row(Modifier.background(Color.Red)) {
PlaybackButton( PlaybackButton(
iconRes = R.drawable.baseline_play_arrow_24, iconRes = R.drawable.baseline_play_arrow_24,
onClick = {}, onClick = {},
onControllerInteraction = {}, onControllerInteraction = {},
) )
PlaybackFaButton(
iconRes = R.string.fa_shuffle,
onClick = {},
onControllerInteraction = {},
textColor = Color.Green,
)
} }
} }
} }

View file

@ -19,8 +19,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -104,7 +103,7 @@ fun TranscodeInfo(
"Reason:" to info.transcodeReasons.joinToString(", "), "Reason:" to info.transcodeReasons.joinToString(", "),
"HW Accel:" to info.hardwareAccelerationType?.toString(), "HW Accel:" to info.hardwareAccelerationType?.toString(),
"Container:" to info.container, "Container:" to info.container,
"Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) }, "Bitrate:" to info.bitrate?.let { formatBitrate(it) },
), ),
) )
SimpleTable( SimpleTable(

View file

@ -55,9 +55,7 @@ class PlaybackKeyHandler(
} else if (oneClickPause && isEnterKey(it)) { } else if (oneClickPause && isEnterKey(it)) {
val wasPlaying = player.isPlaying val wasPlaying = player.isPlaying
Util.handlePlayPauseButtonAction(player) Util.handlePlayPauseButtonAction(player)
if (wasPlaying) { if (!wasPlaying) {
controllerViewState.showControls()
} else {
skipBackOnResume?.let { skipBackOnResume?.let {
player.seekBack(it) player.seekBack(it)
} }

View file

@ -1,10 +1,17 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@ -50,6 +57,10 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.media3.common.Player import androidx.media3.common.Player
@ -64,10 +75,10 @@ import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -119,17 +130,14 @@ fun PlaybackOverlay(
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) } var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat() var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val density = LocalDensity.current val density = LocalDensity.current
val titleHeight = val titleHeight =
remember { remember(item?.title) {
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
} }
val subtitleHeight = val subtitleHeight =
remember { remember(item?.subtitleLong) {
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
} }
@ -137,19 +145,6 @@ fun PlaybackOverlay(
var controllerHeight by remember { mutableStateOf(0.dp) } var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter,
@ -160,6 +155,18 @@ fun PlaybackOverlay(
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
) { ) {
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box( Box(
modifier = modifier =
Modifier Modifier
@ -169,10 +176,16 @@ fun PlaybackOverlay(
} }
AnimatedVisibility( AnimatedVisibility(
state == OverlayViewState.CONTROLLER, visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
enter = slideInVertically() + fadeIn(), enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically() + fadeOut(), exit = slideOutVertically { it / 2 } + fadeOut(),
) { ) {
if (seekBarFocused) {
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
val nextState = val nextState =
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS OverlayViewState.CHAPTERS
@ -253,11 +266,13 @@ fun PlaybackOverlay(
} }
} }
AnimatedVisibility( AnimatedVisibility(
state == OverlayViewState.CHAPTERS, visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
enter = slideInVertically { it / 2 } + fadeIn(), enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(), exit = slideOutVertically { it / 2 } + fadeOut(),
) { ) {
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
val chapterIndex = val chapterIndex =
remember { remember {
@ -368,7 +383,7 @@ fun PlaybackOverlay(
} }
} }
AnimatedVisibility( AnimatedVisibility(
state == OverlayViewState.QUEUE, visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
enter = slideInVertically { it / 2 } + fadeIn(), enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(), exit = slideOutVertically { it / 2 } + fadeOut(),
) { ) {
@ -441,15 +456,17 @@ fun PlaybackOverlay(
} }
} }
if (seekBarInteractionSource.collectIsFocusedAsState().value) { // Trickplay
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
AnimatedVisibility( AnimatedVisibility(
seekProgressPercent >= 0 && seekBarFocused, visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) { ) {
Box( Box(
modifier = modifier =
@ -496,9 +513,13 @@ fun PlaybackOverlay(
} }
} }
} }
// Top
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility( AnimatedVisibility(
!showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart), .align(Alignment.TopStart),
@ -514,7 +535,9 @@ fun PlaybackOverlay(
) )
} }
AnimatedVisibility( AnimatedVisibility(
!showDebugInfo && showClock && controllerViewState.controlsVisible, visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier = modifier =
Modifier Modifier
.align(Alignment.TopEnd), .align(Alignment.TopEnd),
@ -522,7 +545,9 @@ fun PlaybackOverlay(
TimeDisplay() TimeDisplay()
} }
AnimatedVisibility( AnimatedVisibility(
showDebugInfo && controllerViewState.controlsVisible, visible = showDebugInfo && controllerViewState.controlsVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart), .align(Alignment.TopStart),
@ -578,6 +603,11 @@ fun Controller(
val verticalOffset by animateDpAsState( val verticalOffset by animateDpAsState(
targetValue = if (seekBarFocused) (-32).dp else 0.dp, targetValue = if (seekBarFocused) (-32).dp else 0.dp,
label = "TitleBumpOffset", label = "TitleBumpOffset",
animationSpec =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Dp.VisibilityThreshold,
),
) )
Column( Column(
@ -596,6 +626,9 @@ fun Controller(
text = it, text = it,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize, fontSize = titleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
) )
} }
Row( Row(
@ -610,6 +643,9 @@ fun Controller(
text = it, text = it,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize, fontSize = subtitleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
) )
} }
@ -622,7 +658,7 @@ fun Controller(
.toLong() .toLong()
.milliseconds .milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = TimeFormatter.format(endTime) endTimeStr = getTimeFormatter().format(endTime)
delay(1.seconds) delay(1.seconds)
} }
} }

View file

@ -8,8 +8,6 @@ import androidx.annotation.Dimension
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -374,46 +372,49 @@ fun PlaybackPageContent(
} }
} }
// The playback controls if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) {
AnimatedVisibility( PauseIndicator(
controllerViewState.controlsVisible, player = player,
Modifier,
slideInVertically { it },
slideOutVertically { it },
) {
PlaybackOverlay(
modifier = modifier =
Modifier Modifier
.padding(WindowInsets.systemBars.asPaddingValues()) .align(Alignment.Center),
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
playerControls = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
nextEnabled = playlist.hasNext(),
seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = { playbackDialog = it },
onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo,
currentPlayback = currentPlayback,
chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
) )
} }
// The playback controls
PlaybackOverlay(
modifier =
Modifier
.padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
playerControls = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
nextEnabled = playlist.hasNext(),
seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = { playbackDialog = it },
onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo,
currentPlayback = currentPlayback,
chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
val subtitleSettings = val subtitleSettings =
remember(mediaInfo) { remember(mediaInfo) {
Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}") Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")

View file

@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreationResult
@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.checkForSupport
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
@ -143,6 +145,7 @@ class PlaybackViewModel
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val screensaverService: ScreensaverService, private val screensaverService: ScreensaverService,
private val musicService: MusicService,
@Assisted private val destination: Destination, @Assisted private val destination: Destination,
) : ViewModel(), ) : ViewModel(),
Player.Listener, Player.Listener,
@ -260,7 +263,6 @@ class PlaybackViewModel
val sessionPlayer = val sessionPlayer =
MediaSessionPlayer( MediaSessionPlayer(
player, player,
controllerViewState,
preferences.appPreferences.playbackPreferences, preferences.appPreferences.playbackPreferences,
) )
mediaSession = mediaSession =
@ -273,6 +275,7 @@ class PlaybackViewModel
* Initialize from the UI to start playback * Initialize from the UI to start playback
*/ */
private suspend fun init() { private suspend fun init() {
musicService.stop()
nextUp.setValueOnMain(null) nextUp.setValueOnMain(null)
this.preferences = userPreferencesService.getCurrent() this.preferences = userPreferencesService.getCurrent()
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
@ -737,12 +740,12 @@ class PlaybackViewModel
player.removeListener(it) player.removeListener(it)
} }
val playbackItemState = PlaybackItemState(playback, currentItemPlayback)
val activityListener = val activityListener =
TrackActivityPlaybackListener( TrackActivityPlaybackListener(
api = api, api = api,
player = player, player = player,
playback = playback, getState = { playbackItemState },
itemPlayback = currentItemPlayback,
) )
player.addListener(activityListener) player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener this@PlaybackViewModel.activityListener = activityListener

View file

@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -85,11 +86,6 @@ data class NavDrawerPin(
} }
} }
enum class MoveDirection {
UP,
DOWN,
}
private fun <T> List<T>.move( private fun <T> List<T>.move(
direction: MoveDirection, direction: MoveDirection,
index: Int, index: Int,
@ -246,7 +242,7 @@ fun NavDrawerPreferenceListItem(
} }
@Composable @Composable
private fun MoveButton( fun MoveButton(
@StringRes icon: Int, @StringRes icon: Int,
enabled: Boolean, enabled: Boolean,
onClick: () -> Unit, onClick: () -> Unit,

View file

@ -7,7 +7,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.getTimeFormatter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -25,9 +25,9 @@ data class Clock(
*/ */
val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()), val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()),
/** /**
* The current time formatted as a string with [TimeFormatter] * The current time formatted as a string with [getTimeFormatter]
*/ */
val timeString: MutableState<String> = mutableStateOf(TimeFormatter.format(now.value)), val timeString: MutableState<String> = mutableStateOf(getTimeFormatter().format(now.value)),
) )
@Composable @Composable
@ -37,7 +37,7 @@ fun ProvideLocalClock(content: @Composable () -> Unit) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
while (isActive) { while (isActive) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val time = TimeFormatter.format(now) val time = getTimeFormatter().format(now)
clock.now.value = now clock.now.value = now
clock.timeString.value = time clock.timeString.value = time
delay(2_000) delay(2_000)

View file

@ -1,9 +1,26 @@
package com.github.damontecres.wholphin.util package com.github.damontecres.wholphin.util
import java.util.function.IntFunction
import java.util.function.Predicate import java.util.function.Predicate
interface BlockingList<T> : List<T> { interface BlockingList<T> : List<T> {
suspend fun getBlocking(index: Int): T suspend fun getBlocking(index: Int): T
suspend fun indexOfBlocking(predicate: Predicate<T>): Int suspend fun indexOfBlocking(predicate: Predicate<T>): Int
companion object {
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
}
}
private class BlockingListWrapper<T>(
private val list: List<T>,
) : BlockingList<T>,
List<T> by list {
override suspend fun getBlocking(index: Int): T = get(index)
override suspend fun indexOfBlocking(predicate: Predicate<T>): Int = indexOfFirst { predicate.test(it) }
@Deprecated("Deprecated")
override fun <T> toArray(generator: IntFunction<Array<out T?>?>): Array<out T?> = super<List>.toArray(generator)
} }

View file

@ -32,6 +32,7 @@ val supportedCollectionTypes =
CollectionType.LIVETV, CollectionType.LIVETV,
CollectionType.MUSICVIDEOS, CollectionType.MUSICVIDEOS,
CollectionType.FOLDERS, CollectionType.FOLDERS,
CollectionType.MUSIC,
null, // Mixed null, // Mixed
) )

View file

@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo
@ -20,6 +21,7 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber import timber.log.Timber
import java.util.Timer import java.util.Timer
import java.util.TimerTask import java.util.TimerTask
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -30,8 +32,7 @@ import kotlin.time.Duration.Companion.seconds
class TrackActivityPlaybackListener( class TrackActivityPlaybackListener(
private val api: ApiClient, private val api: ApiClient,
private val player: Player, private val player: Player,
val playback: CurrentPlayback, private val getState: () -> PlaybackItemState?,
val itemPlayback: ItemPlayback,
) : Player.Listener { ) : Player.Listener {
private val coroutineScope = CoroutineScope(Dispatchers.Main) private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask = private val task: TimerTask =
@ -50,44 +51,50 @@ class TrackActivityPlaybackListener(
fun init() { fun init() {
launch("reportPlaybackStart") { launch("reportPlaybackStart") {
Timber.v("reportPlaybackStart for ${itemPlayback.itemId}") getState.invoke()?.let { state ->
api.playStateApi.reportPlaybackStart( Timber.v("reportPlaybackStart for ${state.itemId}")
PlaybackStartInfo( api.playStateApi.reportPlaybackStart(
canSeek = true, PlaybackStartInfo(
itemId = itemPlayback.itemId, canSeek = true,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, itemId = state.itemId,
playMethod = playback.playMethod, isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
repeatMode = RepeatMode.REPEAT_NONE, playMethod = state.playMethod,
playbackOrder = PlaybackOrder.DEFAULT, repeatMode = RepeatMode.REPEAT_NONE,
isMuted = false, playbackOrder = PlaybackOrder.DEFAULT,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, isMuted = false,
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, audioStreamIndex = state.audioStreamIndex,
playSessionId = playback.playSessionId, subtitleStreamIndex = state.subtitleStreamIndex,
liveStreamId = playback.liveStreamId, playSessionId = state.playSessionId,
), liveStreamId = state.liveStreamId,
) ),
val delay = 5.seconds.inWholeMilliseconds )
// Every x seconds, check if the video is playing
TIMER.schedule(task, delay, delay) val delay = 5.seconds.inWholeMilliseconds
initialized = true // Every x seconds, check if the video is playing
TIMER.schedule(task, delay, delay)
initialized = true
}
} }
} }
fun release() { fun release() {
// player.removeListener(this)
task.cancel() task.cancel()
TIMER.purge() TIMER.purge()
val position = player.currentPosition.milliseconds val position = player.currentPosition.milliseconds
launch("reportPlaybackStopped") { launch("reportPlaybackStopped") {
Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") getState.invoke()?.let { state ->
api.playStateApi.reportPlaybackStopped( Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
PlaybackStopInfo( api.playStateApi.reportPlaybackStopped(
itemId = itemPlayback.itemId, PlaybackStopInfo(
positionTicks = position.inWholeTicks, itemId = state.itemId,
failed = false, positionTicks = position.inWholeTicks,
playSessionId = playback.playSessionId, failed = false,
liveStreamId = playback.liveStreamId, playSessionId = state.playSessionId,
), liveStreamId = state.liveStreamId,
) ),
)
}
} }
} }
@ -108,29 +115,31 @@ class TrackActivityPlaybackListener(
private fun saveActivity(position: Long) { private fun saveActivity(position: Long) {
launch("saveActivity") { launch("saveActivity") {
val calcPosition = getState.invoke()?.let { state ->
withContext(Dispatchers.Main) { val calcPosition =
(if (position >= 0) position else player.currentPosition) withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition)
}
if (calcPosition > 0) {
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = state.itemId,
positionTicks = calcPosition.milliseconds.inWholeTicks,
canSeek = true,
isPaused = isPaused,
isMuted = false,
playMethod = state.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
audioStreamIndex = state.audioStreamIndex,
subtitleStreamIndex = state.subtitleStreamIndex,
playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId,
),
)
} }
if (calcPosition > 0) {
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = itemPlayback.itemId,
positionTicks = calcPosition.milliseconds.inWholeTicks,
canSeek = true,
isPaused = isPaused,
isMuted = false,
playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
),
)
} }
} }
} }
@ -143,7 +152,7 @@ class TrackActivityPlaybackListener(
try { try {
block.invoke(this) block.invoke(this)
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId) Timber.w(ex, "Exception during %s", name)
} }
} }
} }
@ -154,3 +163,24 @@ class TrackActivityPlaybackListener(
private val TIMER by lazy { Timer("$TAG-timer", true) } private val TIMER by lazy { Timer("$TAG-timer", true) }
} }
} }
data class PlaybackItemState(
val itemId: UUID,
val playMethod: PlayMethod,
val audioStreamIndex: Int? = null,
val subtitleStreamIndex: Int? = null,
val playSessionId: String? = null,
val liveStreamId: String? = null,
) {
constructor(
playback: CurrentPlayback,
itemPlayback: ItemPlayback,
) : this(
itemId = itemPlayback.itemId,
playMethod = playback.playMethod,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
)
}

View file

@ -178,6 +178,13 @@ message PhotoPreferences{
bool slideshow_play_videos = 2; bool slideshow_play_videos = 2;
} }
message MusicPreferences {
bool show_lyrics = 1;
bool show_visualizer = 2;
bool show_album_Art = 3;
bool show_backdrop = 4;
}
message AppPreferences { message AppPreferences {
// The currently signed in server and user IDs, mostly for restoring a session // The currently signed in server and user IDs, mostly for restoring a session
string current_server_id = 1; string current_server_id = 1;
@ -194,4 +201,5 @@ message AppPreferences {
AdvancedPreferences advanced_preferences = 10; AdvancedPreferences advanced_preferences = 10;
bool sign_in_automatically = 11; bool sign_in_automatically = 11;
PhotoPreferences photo_preferences = 12; PhotoPreferences photo_preferences = 12;
MusicPreferences music_preferences = 13;
} }

View file

@ -53,4 +53,7 @@
<string name="fa_check" translatable="false">&#xf00c;</string> <string name="fa_check" translatable="false">&#xf00c;</string>
<string name="fa_cloud_arrow_up" translatable="false">&#xf0ee;</string> <string name="fa_cloud_arrow_up" translatable="false">&#xf0ee;</string>
<string name="fa_cloud_arrow_down" translatable="false">&#xf0ed;</string> <string name="fa_cloud_arrow_down" translatable="false">&#xf0ed;</string>
<string name="fa_compass" translatable="false">&#xf14e;</string>
<string name="fa_repeat" translatable="false">&#xf363;</string>
<string name="fa_compact_disc" translatable="false">&#xf51f;</string>
</resources> </resources>

View file

@ -504,6 +504,14 @@
<string name="send_media_info_log_to_server">Send media info log to server</string> <string name="send_media_info_log_to_server">Send media info log to server</string>
<string name="no_limit">No limit</string> <string name="no_limit">No limit</string>
<string name="max_days_next_up">Max days in Next Up</string> <string name="max_days_next_up">Max days in Next Up</string>
<string name="albums">Albums</string>
<string name="artists">Artists</string>
<string name="songs">Songs</string>
<string name="go_to_artist">Go to artist</string>
<string name="now_playing">Now playing</string>
<string name="instant_mix">Instant mix</string>
<string name="go_to_album">Go to album</string>
<string name="add_to_queue">Add to queue</string>
<string name="add_row">Add row</string> <string name="add_row">Add row</string>
<string name="genres_in">Genres in %1$s</string> <string name="genres_in">Genres in %1$s</string>
@ -707,6 +715,8 @@
<item>@string/photos</item> <item>@string/photos</item>
</string-array> </string-array>
<string name="search_for">Search %s</string>
<string name="actor">Actor</string> <string name="actor">Actor</string>
<string name="composer">Composer</string> <string name="composer">Composer</string>
<string name="writer">Writer</string> <string name="writer">Writer</string>
@ -719,7 +729,23 @@
<string name="mixer">Mixer</string> <string name="mixer">Mixer</string>
<string name="creator">Creator</string> <string name="creator">Creator</string>
<string name="artist">Artist</string> <string name="artist">Artist</string>
<string name="search_for">Search %s</string> <string name="album_artist">Album artist</string>
<string name="album">Album</string>
<string name="popular_songs">Popular songs</string>
<string name="play_next">Play next</string>
<string name="music_videos">Music videos</string>
<string name="lyrics">Lyrics</string>
<string name="hide_lyrics">Hide lyrics</string>
<string name="show_lyrics">Show lyrics</string>
<string name="song_has_lyrics">Song has lyrics</string>
<string name="remove_from_queue">Remove from queue</string>
<string name="show_album_cover">Show album cover</string>
<string name="show_visualizer">Show visualizer</string>
<string name="show_backdrop">Show backdrop</string>
<string name="play_as_type">Play as?</string>
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
<string name="continue_string">Continue</string>
<string name="select_all">Select all</string> <string name="select_all">Select all</string>
<string name="separate_types">Separate types</string>
</resources> </resources>

View file

@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room import androidx.room.Room
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
@ -278,5 +281,14 @@ object TestDatabaseModule {
produceNewData = { AppPreferences.getDefaultInstance() }, produceNewData = { AppPreferences.getDefaultInstance() },
), ),
) )
@Provides
@Singleton
fun keyValueDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("key_value") },
)
} }
} }

View file

@ -12,7 +12,7 @@ kotlin = "2.3.20"
ksp = "2.3.6" ksp = "2.3.6"
coreKtx = "1.18.0" coreKtx = "1.18.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2026.03.00" composeBom = "2026.03.01"
mockk = "1.14.9" mockk = "1.14.9"
robolectric = "4.16.1" robolectric = "4.16.1"
multiplatformMarkdownRenderer = "0.39.2" multiplatformMarkdownRenderer = "0.39.2"
@ -33,17 +33,17 @@ material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.6" protobuf = "0.9.6"
datastore = "1.2.1" datastore = "1.2.1"
kotlinx-serialization = "1.10.0" kotlinx-serialization = "1.10.0"
protobuf-javalite = "4.34.0" protobuf-javalite = "4.34.1"
hilt = "2.59.2" hilt = "2.59.2"
room = "2.8.4" room = "2.8.4"
preferenceKtx = "1.2.1" preferenceKtx = "1.2.1"
tvprovider = "1.1.0" tvprovider = "1.1.0"
workRuntimeKtx = "2.11.1" workRuntimeKtx = "2.11.2"
paletteKtx = "1.0.0" paletteKtx = "1.0.0"
assMedia = "0.4.0" assMedia = "0.4.0"
kotlinxCoroutinesTest = "1.10.2" kotlinxCoroutinesTest = "1.10.2"
coreTesting = "2.2.0" coreTesting = "2.2.0"
openapi-generator = "7.20.0" openapi-generator = "7.21.0"
runner = "1.7.0" runner = "1.7.0"
[libraries] [libraries]
@ -73,6 +73,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

2
gradlew vendored
View file

@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.

View file

@ -1,3 +1,14 @@
# FFmpeg ExoPlayer decoder extension module # FFmpeg ExoPlayer decoder extension module
Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback. Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback.
## Usage
```sh
./build_ffmpeg_decoder.sh /path/to/android/ndk
```
Or clean build:
```sh
./build_ffmpeg_decoder.sh /path/to/android/ndk --clean
```

View file

@ -27,6 +27,12 @@ AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main"
HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST="$(uname -s | tr '[:upper:]' '[:lower:]')"
HOST_PLATFORM="$HOST-x86_64" HOST_PLATFORM="$HOST-x86_64"
if [[ "$2" == "--clean" ]]; then
rm -rf ffmpeg_decoder
rm -f "$TARGET_PATH/lib-decoder-ffmpeg-release.aar"
rm -f "$TARGET_PATH/lib-decoder-av1-release.aar"
fi
mkdir -p "$TARGET_PATH" mkdir -p "$TARGET_PATH"
mkdir -p ffmpeg_decoder mkdir -p ffmpeg_decoder
@ -38,14 +44,16 @@ pushd ffmpeg_decoder || exit
if [[ -d media ]]; then if [[ -d media ]]; then
pushd media || exit pushd media || exit
git checkout --force "$media_version" git fetch origin "$media_version" --depth 1
git checkout --force FETCH_HEAD
else else
git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media
fi fi
if [[ -d ffmpeg ]]; then if [[ -d ffmpeg ]]; then
pushd ffmpeg || exit pushd ffmpeg || exit
git checkout --force "$FFMPEG_BRANCH" git fetch origin "$FFMPEG_BRANCH" --depth 1
git checkout --force FETCH_HEAD
else else
git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg
fi fi
@ -68,7 +76,8 @@ pushd "$AV1_MODULE_PATH/jni" || exit
if [[ -d dav1d ]]; then if [[ -d dav1d ]]; then
pushd dav1d || exit pushd dav1d || exit
git checkout --force "$DAV1D_BRANCH" git fetch origin "$DAV1D_BRANCH" --depth 1
git checkout --force FETCH_HEAD
else else
git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d
fi fi

View file

@ -15,7 +15,8 @@ function clone(){
if [[ -d "$dir" ]]; then if [[ -d "$dir" ]]; then
pushd "$dir" || exit pushd "$dir" || exit
git checkout --force "$branch" git fetch origin "$branch" --depth 1
git checkout --force FETCH_HEAD
popd || exit popd || exit
else else
git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@" git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@"