diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 774dae38..d7b2f290 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -21,7 +21,7 @@ runs: echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 with: packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5f86522..799d0f14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -219,6 +219,7 @@ dependencies { implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) implementation(libs.protobuf.kotlin.lite) implementation(libs.androidx.tvprovider) implementation(libs.androidx.work.runtime.ktx) diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index 72cf2896..09dc6745 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey +import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverState import com.github.damontecres.wholphin.services.SetupDestination @@ -43,16 +45,17 @@ class InstrumentedBasicUiTests { @OptIn(ExperimentalTestApi::class) @Test fun myTest() { + val navigationManager = NavigationManager() + navigationManager.backStack = NavBackStack(Destination.Home()) // Start the app composeTestRule.setContent { WholphinTheme { MainContent( backStack = mutableListOf(SetupDestination.ServerList), - navigationManager = mockk(relaxed = true), + navigationManager = navigationManager, appPreferences = mockk(relaxed = true), backdropService = mockk(relaxed = true), screensaverService = screensaverService, - requestedDestination = Destination.Home(), modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 9cb740ab..e8791bde 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference @@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -127,6 +129,11 @@ class MainActivity : AppCompatActivity() { private var signInAuto = true + private val json = + Json { + classDiscriminator = "_type" + } + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -134,6 +141,23 @@ class MainActivity : AppCompatActivity() { Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) + val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) + if (backStackStr != null) { + Timber.d("Restoring back stack") + var backStack = json.decodeFromString>(backStackStr) + val lastDest = backStack.lastOrNull() + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { + backStack = backStack.toMutableList().apply { removeAt(lastIndex) } + } + navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) + } else { + val startDestination = intent?.let(::extractDestination) ?: Destination.Home() + navigationManager.backStack = NavBackStack(startDestination) + } + viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( @@ -206,17 +230,12 @@ class MainActivity : AppCompatActivity() { appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { ProvideLocalClock { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) ?: Destination.Home() - } MainContent( backStack = setupNavigationManager.backStack, navigationManager = navigationManager, appPreferences = appPreferences, backdropService = backdropService, screensaverService = screensaverService, - requestedDestination = requestedDestination, modifier = Modifier.fillMaxSize(), ) } @@ -287,6 +306,8 @@ class MainActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") + val str = json.encodeToString(navigationManager.backStack.toList()) + outState.putString(KEY_BACK_STACK, str) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -362,6 +383,8 @@ class MainActivity : AppCompatActivity() { const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + private const val KEY_BACK_STACK = "backStack" + lateinit var instance: MainActivity private set } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 5bade867..0794da67 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,10 +34,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.nav.ApplicationContent -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock @Composable fun MainContent( @@ -45,9 +44,9 @@ fun MainContent( appPreferences: AppPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, - requestedDestination: Destination, modifier: Modifier = Modifier, ) { + val preferences by rememberUpdatedState(UserPreferences(appPreferences)) Surface( modifier = modifier @@ -96,10 +95,6 @@ fun MainContent( backdropService.clearBackdrop() } val current = key.current - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } var showContent by remember { mutableStateOf(true) } @@ -113,7 +108,6 @@ fun MainContent( ApplicationContent( user = current.user, server = current.server, - startDestination = requestedDestination, navigationManager = navigationManager, preferences = preferences, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index a4c09ad4..1a350905 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -61,6 +60,7 @@ class WholphinDreamService : override fun onCreate() { super.onCreate() + Timber.d("onCreate") savedStateRegistryController.performRestore(null) lifecycleRegistry.currentState = Lifecycle.State.CREATED @@ -74,6 +74,7 @@ class WholphinDreamService : override fun onAttachedToWindow() { super.onAttachedToWindow() + Timber.d("onAttachedToWindow") val itemFlow = screensaverService.createItemFlow(lifecycleScope) setContentView( ComposeView(this).apply { @@ -109,11 +110,13 @@ class WholphinDreamService : override fun onDreamingStarted() { super.onDreamingStarted() + Timber.d("onDreamingStarted") lifecycleRegistry.currentState = Lifecycle.State.STARTED } override fun onDreamingStopped() { super.onDreamingStopped() + Timber.d("onDreamingStopped") lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt index 0124ac3f..524281cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.ui.toServerString +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao { itemId: String, ): LibraryDisplayInfo? + @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId") + fun getItemAsFlow( + userId: Int, + itemId: String, + ): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) fun saveItem(item: LibraryDisplayInfo): Long diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt new file mode 100644 index 00000000..49b3c694 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 3c34851b..83683976 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -5,12 +5,13 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString -import com.github.damontecres.wholphin.ui.DateFormatter import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.getDateFormatter import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.roundMinutes @@ -32,7 +33,7 @@ import kotlin.time.Duration @Stable data class BaseItem( val data: BaseItemDto, - val useSeriesForPrimary: Boolean, + val useSeriesForPrimary: Boolean = false, val imageUrlOverride: String? = null, val destinationOverride: Destination? = null, ) : CardGridItem { @@ -57,6 +58,7 @@ data class BaseItem( when (type) { BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name BaseItemKind.SERIES -> data.seriesProductionYears + BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ") else -> data.productionYear?.toString() } @@ -116,7 +118,7 @@ data class BaseItem( buildList { if (type == BaseItemKind.EPISODE) { data.seasonEpisode?.let(::add) - data.premiereDate?.let { add(DateFormatter.format(it)) } + data.premiereDate?.let { add(getDateFormatter().format(it)) } } else if (type == BaseItemKind.SERIES) { data.seriesProductionYears?.let(::add) } else if (type == BaseItemKind.PHOTO) { @@ -125,6 +127,9 @@ data class BaseItem( } else if (data.premiereDate != null) { add(data.premiereDate!!.toLocalDate().toString()) } + } else if (type == BaseItemKind.BOX_SET) { + data.productionYear?.let { add(it.toString()) } + data.childCount?.let { add("$it items") } } else { data.productionYear?.let { add(it.toString()) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt index eb8ffdd1..e5bcafcb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -9,12 +9,14 @@ import androidx.room.Ignore import androidx.room.Index import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID @Entity( foreignKeys = [ @@ -41,4 +43,13 @@ data class LibraryDisplayInfo( ) { @Ignore @Transient val sortAndDirection = SortAndDirection(sort, direction) + + constructor( + user: JellyfinUser, + itemId: UUID, + sort: ItemSortBy, + direction: SortOrder, + filter: GetItemsFilter, + viewOptions: ViewOptions?, + ) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index bf8ab2a4..f55b4ea9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -152,6 +152,15 @@ class AppPreferencesSerializer slideshowDuration = AppPreference.SlideshowDuration.defaultValue slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue }.build() + + musicPreferences = + MusicPreferences + .newBuilder() + .apply { + showBackdrop = true + showLyrics = true + showAlbumArt = true + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -220,6 +229,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere screensaverPreference = screensaverPreference.toBuilder().apply(block).build() } +inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences = + update { + musicPreferences = musicPreferences.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index ca720af3..617944fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions +import com.github.damontecres.wholphin.preferences.updateMusicPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences @@ -280,5 +281,15 @@ class AppUpgradeHandler ) } } + + if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) { + appPreferences.updateData { + it.updateMusicPreferences { + showBackdrop = true + showLyrics = true + showAlbumArt = true + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index e0c48cca..832d4137 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -114,7 +114,7 @@ class BackdropService } } - private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = + suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = withContext(Dispatchers.IO) { if (imageUrl.isNullOrBlank()) { return@withContext ExtractedColors.DEFAULT diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index b8a850ef..b28dded4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -438,10 +438,7 @@ class HomeSettingsService ): HomeRowConfigDisplay = when (config) { is HomeRowConfig.ByParent -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, name, @@ -466,10 +463,7 @@ class HomeSettingsService } is HomeRowConfig.Genres -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, context.getString(R.string.genres_in, name), @@ -490,10 +484,7 @@ class HomeSettingsService } is HomeRowConfig.RecentlyAdded -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, context.getString(R.string.recently_added_in, name), @@ -502,10 +493,7 @@ class HomeSettingsService } is HomeRowConfig.RecentlyReleased -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, context.getString(R.string.recently_released_in, name), @@ -547,10 +535,7 @@ class HomeSettingsService } is HomeRowConfig.Suggestions -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id = id, title = context.getString(R.string.suggestions_for, name), @@ -559,6 +544,16 @@ class HomeSettingsService } } + private suspend fun getItemName(itemId: UUID): String? = + try { + api.userLibraryApi + .getItem(itemId = itemId) + .content.name + } catch (ex: Exception) { + Timber.e(ex, "Could not get name for %s", itemId) + context.getString(R.string.unknown) + } + /** * Fetch the data from the server for a given [HomeRowConfig] */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt new file mode 100644 index 00000000..543ea656 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt @@ -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, + ) { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + inline fun get(key: String): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString(serializer(), it) + } + } + + inline fun get( + key: String, + defaultValue: T, + ): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString(serializer(), it) + } ?: defaultValue + } + + inline fun get( + userId: UUID, + key: String, + defaultValue: T, + ): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey("${userId}_$key")]?.let { + json.decodeFromString(serializer(), it) + } ?: defaultValue + } + + suspend inline fun save( + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey(key), valueStr) + } + } + } + + suspend inline fun save( + userId: UUID, + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey("${userId}_$key"), valueStr) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt new file mode 100644 index 00000000..be3ceac7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -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 = _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, + 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, + 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, + 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.. 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() + .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, +) { + 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, +) : 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.. = + remember(queueVersion, queueSize) { + object : AbstractList() { + override val size: Int + get() = player.mediaItemCount + + override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index b83f0bd0..8dbf49ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem @@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -33,6 +36,7 @@ import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours @Singleton class NavDrawerService @@ -44,6 +48,7 @@ class NavDrawerService private val serverRepository: ServerRepository, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, + private val musicService: MusicService, ) { private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) val state: StateFlow = _state @@ -73,6 +78,40 @@ class NavDrawerService .onEach { discoverActive -> _state.update { it.copy(discoverEnabled = discoverActive) } }.launchIn(coroutineScope) + coroutineScope.launchDefault { + musicService.state.collectLatest { music -> + Timber.v("MusicService updated") + when (music.status) { + NowPlayingStatus.PLAYING -> { + _state.update { + it.copy( + nowPlayingEnabled = true, + nowPlayingTitle = music.currentItemTitle, + ) + } + } + + NowPlayingStatus.IDLE -> { + _state.update { + it.copy( + nowPlayingEnabled = false, + nowPlayingTitle = null, + ) + } + } + + NowPlayingStatus.PAUSED -> { + delay(2.hours) + _state.update { + it.copy( + nowPlayingEnabled = false, + nowPlayingTitle = null, + ) + } + } + } + } + } } suspend fun getAllUserLibraries( @@ -185,9 +224,11 @@ data class NavDrawerItemState( val items: List, val moreItems: List, val discoverEnabled: Boolean, + val nowPlayingEnabled: Boolean, + val nowPlayingTitle: String?, ) { companion object { - val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) + val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index a1754298..3208f4ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.services -import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.ui.nav.Destination import org.acra.ACRA import timber.log.Timber @@ -14,7 +14,7 @@ import javax.inject.Singleton class NavigationManager @Inject constructor() { - var backStack: MutableList = mutableListOf() + var backStack: MutableList = NavBackStack(Destination.Home()) /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index e4910031..fa42469e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -16,7 +16,9 @@ import javax.inject.Singleton @Singleton class SetupNavigationManager @Inject - constructor() { + constructor( + private val navigationManager: NavigationManager, + ) { var backStack: MutableList = mutableStateListOf(SetupDestination.Loading) /** @@ -25,6 +27,9 @@ class SetupNavigationManager fun navigateTo(destination: SetupDestination) { backStack[0] = destination log() + if (destination !is SetupDestination.AppContent) { + navigationManager.reloadHome() + } } private fun log() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 31c92774..62609628 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.ItemPlaybackDao @@ -85,4 +88,13 @@ object DatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 865c6d29..4d69fe0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -38,6 +38,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.ACRA @@ -434,3 +437,18 @@ fun Response.toBaseItems( fun Int?.gt(that: Int) = (this ?: 0) > that fun Int?.lt(that: Int) = (this ?: 0) < that + +/** + * Simplifies endlessly collecting a flow + */ +fun Flow.collectLatestIn( + scope: CoroutineScope, + action: suspend (value: T) -> Unit, +) { + scope.launchDefault { this@collectLatestIn.collectLatest(action) } +} + +/** + * Easy way to combine two flows into a [Pair] + */ +fun Flow.combinePair(flow: Flow): Flow> = combine(flow) { t1, t2 -> Pair(t1, t2) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 8f4066bb..7435cca8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber import java.time.LocalDate @@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Locale -val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) -val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") +private var timeFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()) + +fun getTimeFormatter(): DateTimeFormatter { + if (timeFormatter.locale != Locale.getDefault()) { + timeFormatter = timeFormatter.withLocale(Locale.getDefault()) + } + return timeFormatter +} + +private var dateFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) + +fun getDateFormatter(): DateTimeFormatter { + if (dateFormatter.locale != Locale.getDefault()) { + dateFormatter = dateFormatter.withLocale(Locale.getDefault()) + } + return dateFormatter +} // TODO server returns in UTC, but sdk converts to local time // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 @@ -25,9 +43,9 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy" /** * Format a [LocalDateTime] as `Aug 24, 2000` */ -fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime) +fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime) -fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) +fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime) fun toLocalDate(date: String?): LocalDate? = date?.let { @@ -109,30 +127,25 @@ fun abbreviateNumber(number: Int): String { return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit]) } -val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB") +val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB") val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps") -/** - * Format bytes - */ -fun formatBytes( - bytes: Int, - suffixes: List = byteSuffixes, -) = formatBytes(bytes.toLong(), suffixes) - fun formatBytes( bytes: Long, suffixes: List = byteSuffixes, + divisor: Int = 1024, ): String { var unit = 0 var count = bytes.toDouble() - while (count >= 1024 && unit + 1 < suffixes.size) { - count /= 1024 + while (count >= divisor && unit + 1 < suffixes.size) { + count /= divisor unit++ } - return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) + return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit]) } +fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) + @get:StringRes val MediaSegmentType.stringRes: Int get() = @@ -184,3 +197,45 @@ fun listToDotString( } } } + +@StringRes +fun formatTypeName(type: BaseItemKind): Int = + when (type) { + BaseItemKind.MOVIE -> R.string.movies + BaseItemKind.SERIES -> R.string.tv_shows + BaseItemKind.EPISODE -> R.string.episodes + BaseItemKind.VIDEO -> R.string.videos + BaseItemKind.PLAYLIST -> R.string.playlists + BaseItemKind.PERSON -> R.string.people + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.AUDIO -> TODO() + BaseItemKind.CHANNEL -> R.string.channels + BaseItemKind.GENRE -> R.string.genres + BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels + BaseItemKind.MUSIC_ALBUM -> TODO() + BaseItemKind.MUSIC_ARTIST -> TODO() + BaseItemKind.MUSIC_GENRE -> TODO() + BaseItemKind.MUSIC_VIDEO -> TODO() + BaseItemKind.PHOTO -> R.string.photos + BaseItemKind.PHOTO_ALBUM -> TODO() + BaseItemKind.PROGRAM -> TODO() + BaseItemKind.RECORDING -> TODO() + BaseItemKind.SEASON -> R.string.tv_seasons + BaseItemKind.STUDIO -> R.string.studios + BaseItemKind.TRAILER -> R.string.trailers + BaseItemKind.TV_CHANNEL -> R.string.channels + BaseItemKind.TV_PROGRAM -> TODO() + BaseItemKind.USER_ROOT_FOLDER -> TODO() + BaseItemKind.USER_VIEW -> TODO() + BaseItemKind.YEAR -> TODO() + BaseItemKind.AGGREGATE_FOLDER -> TODO() + BaseItemKind.AUDIO_BOOK -> TODO() + BaseItemKind.BASE_PLUGIN_FOLDER -> TODO() + BaseItemKind.BOOK -> TODO() + BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO() + BaseItemKind.COLLECTION_FOLDER -> TODO() + BaseItemKind.FOLDER -> TODO() + BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO() + BaseItemKind.LIVE_TV_PROGRAM -> TODO() + BaseItemKind.PLAYLISTS_FOLDER -> TODO() + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index cf5b0697..d4931d77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -21,8 +21,8 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.util.LocalClock import kotlin.time.Duration @@ -107,7 +107,7 @@ fun TimeRemaining( val now by LocalClock.current.now val remainingStr = remember(remaining, now) { - val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) + val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) buildAnnotatedString { dot() append(context.getString(R.string.ends_at, endTimeStr)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index c3d1a4b6..ae7edb08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService @@ -110,13 +111,14 @@ abstract class RecommendedViewModel( fun update( @StringRes title: Int, + viewOptions: HomeRowViewOptions = HomeRowViewOptions(), block: suspend () -> List, ): Deferred = viewModelScope.async(Dispatchers.IO) { val titleStr = context.getString(title) val row = try { - HomeRowLoadingState.Success(titleStr, block.invoke()) + HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions) } catch (ex: Exception) { HomeRowLoadingState.Error(titleStr, null, ex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt new file mode 100644 index 00000000..87cc7c0d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -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, + 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>( + 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>() + 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.. + 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( + creationCallback = { it.create(parentId) }, + ), +) { + RecommendedContent( + preferences = preferences, + viewModel = viewModel, + onFocusPosition = onFocusPosition, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index fa32c595..072de4e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -116,7 +116,7 @@ fun ItemDetailsDialog( } source.bitrate?.let { add( - bitrateLabel to formatBytes(it, byteRateSuffixes), + bitrateLabel to formatBitrate(it), ) } source.runTimeTicks?.let { @@ -297,7 +297,7 @@ private fun buildVideoStreamInfo( val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) add(aspectRatioLabel to aspectRatio) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } stream.averageFrameRate?.let { add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) } @@ -405,7 +405,7 @@ private fun buildAudioStreamInfo( stream.channelLayout?.let { add(layoutLabel to it) } stream.channels?.let { add(channelsLabel to it.toString()) } stream.profile?.let { add(profileLabel to it) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index 17f3d0ee..ee4b8b26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -99,6 +99,45 @@ val BoxSetSortOptions = ItemSortBy.RANDOM, ) +val AlbumSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.ALBUM_ARTIST, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.RANDOM, + ) + +val ArtistSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.RANDOM, + ) + +val SongSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.ALBUM, + ItemSortBy.ALBUM_ARTIST, + ItemSortBy.ARTIST, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.PLAY_COUNT, + ItemSortBy.RUNTIME, + ItemSortBy.RANDOM, + ) + @StringRes fun getStringRes(sort: ItemSortBy): Int = when (sort) { @@ -132,5 +171,11 @@ fun getStringRes(sort: ItemSortBy): Int = ItemSortBy.DEFAULT -> R.string.default_track + ItemSortBy.ALBUM -> R.string.album + + ItemSortBy.ALBUM_ARTIST -> R.string.album_artist + + ItemSortBy.ARTIST -> R.string.artist + else -> throw IllegalArgumentException("Unsupported sort option: $sort") } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index ac72a144..a2b9e923 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail +import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -51,7 +52,6 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -199,6 +199,25 @@ fun CardGrid( } } + val jumpToLetter: (Char) -> Unit = + remember { + { letter: Char -> + scope.launch(ExceptionHandler()) { + val jumpPosition = + withContext(Dispatchers.IO) { + letterPosition.invoke(letter) + } + Timber.d("Alphabet jump to $jumpPosition") + if (jumpPosition >= 0) { + pager.getOrNull(jumpPosition) + gridState.scrollToItem(jumpPosition) + focusOn(jumpPosition) + alphabetFocus = true + } + } + } + } + if (pager.isEmpty()) { Box( contentAlignment = Alignment.Center, @@ -257,6 +276,12 @@ fun CardGrid( } else if (useJumpRemoteButtons && isBackwardButton(it)) { jump(-jump1) return@onKeyEvent true + } else if (showLetterButtons && pager.isNotEmpty() && + it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) + ) { + val letter = it.nativeKeyEvent.unicodeChar.toChar() + jumpToLetter.invoke(letter) + return@onKeyEvent true } else { return@onKeyEvent false } @@ -372,8 +397,7 @@ fun CardGrid( } } } - val context = LocalContext.current - val letters = context.getString(R.string.jump_letters) + val letters = stringResource(R.string.jump_letters) // Letters val currentLetter = remember(focusedIndex) { @@ -383,12 +407,10 @@ fun CardGrid( ?.firstOrNull() ?.uppercaseChar() ?.let { - if (it >= '0' && it <= '9') { - '#' - } else if (it >= 'A' && it <= 'Z') { - it - } else { - null + when (it) { + in '0'..'9' -> '#' + in 'A'..'Z' -> it + else -> null } } ?: letters[0] @@ -402,21 +424,7 @@ fun CardGrid( .align(Alignment.CenterVertically) .padding(start = 16.dp), // Add end padding to push away from edge - letterClicked = { letter -> - scope.launch(ExceptionHandler()) { - val jumpPosition = - withContext(Dispatchers.IO) { - letterPosition.invoke(letter) - } - Timber.d("Alphabet jump to $jumpPosition") - if (jumpPosition >= 0) { - pager.getOrNull(jumpPosition) - gridState.scrollToItem(jumpPosition) - focusOn(jumpPosition) - alphabetFocus = true - } - } - }, + letterClicked = jumpToLetter, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt deleted file mode 100644 index 07c24c37..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ /dev/null @@ -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, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt new file mode 100644 index 00000000..30534ffc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 5fe13871..b4618eda 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -170,6 +170,38 @@ fun buildMoreDialogItems( actions.onClickFavorite.invoke(item.id, !favorite) }, ) + item.data.albumId?.let { albumId -> + add( + DialogItem( + context.getString(R.string.go_to_album), + R.string.fa_compact_disc, + ) { + actions.navigateTo( + Destination.MediaItem( + albumId, + BaseItemKind.MUSIC_ALBUM, + null, + ), + ) + }, + ) + } + item.data.artistItems?.firstOrNull()?.id?.let { artistId -> + add( + DialogItem( + context.getString(R.string.go_to_artist), + R.string.fa_user, + ) { + actions.navigateTo( + Destination.MediaItem( + artistId, + BaseItemKind.MUSIC_ARTIST, + null, + ), + ) + }, + ) + } seriesId?.let { add( DialogItem( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 57c8c5d6..bd50ce59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail +import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable @@ -16,11 +17,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -44,8 +45,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme @@ -60,12 +61,18 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields -import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.cards.ItemCardImage -import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -73,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.FilterByButton import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions +import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker +import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel +import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration -@HiltViewModel +@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class) class PlaylistViewModel - @Inject + @AssistedInject constructor( + @ApplicationContext context: Context, api: ApiClient, - val navigationManager: NavigationManager, + navigationManager: NavigationManager, + musicService: MusicService, + mediaManagementService: MediaManagementService, private val backdropService: BackdropService, private val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - ) : ItemViewModel(api) { - val loading = MutableLiveData(LoadingState.Pending) - val items = MutableLiveData>(listOf()) - val filterAndSort = - MutableStateFlow( - FilterAndSort( - filter = GetItemsFilter(), - sortAndDirection = - SortAndDirection( + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaReportService: MediaReportService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): PlaylistViewModel + } + + 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, SortOrder.ASCENDING, - ), - ), - ) - - fun init(playlistId: UUID) { - loading.value = LoadingState.Loading - 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) + ) + loadItems(filter, sortAndDirection).join() + determineMediaType() + } catch (ex: Exception) { + Timber.e(ex, "Error fetching playlist %s", itemId) + state.update { it.copy(loading = LoadingState.Error(ex)) } + } } } fun loadItems( filter: GetItemsFilter, sortAndDirection: SortAndDirection, - ) { - viewModelScope.launchIO { - backdropService.clearBackdrop() - loading.setValueOnMain(LoadingState.Loading) - this@PlaylistViewModel.filterAndSort.update { - FilterAndSort(filter, sortAndDirection) - } + ) = viewModelScope.launchIO { + backdropService.clearBackdrop() + state.update { + it.copy( + loading = LoadingState.Loading, + filterAndSort = FilterAndSort(filter, sortAndDirection), + ) + } - serverRepository.currentUser.value?.let { user -> - val playlistId = item.value!!.id - viewModelScope.launchIO { - val libraryDisplayInfo = - libraryDisplayInfoDao.getItem(user, itemId)?.copy( - filter = filter, + serverRepository.currentUser.value?.let { user -> + viewModelScope.launchIO { + val libraryDisplayInfo = + libraryDisplayInfoDao.getItem(user, itemId)?.copy( + filter = filter, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + ) + ?: LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId.toServerString(), sort = sortAndDirection.sort, direction = sortAndDirection.direction, + filter = filter, + viewOptions = null, ) - ?: LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = filter, - viewOptions = null, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } - val request = - filter.applyTo( - GetItemsRequest( - parentId = playlistId, - userId = user.id, - fields = DefaultItemFields, - sortBy = listOf(sortAndDirection.sort), - sortOrder = listOf(sortAndDirection.direction), - ), + val request = + filter.applyTo( + GetItemsRequest( + parentId = itemId, + userId = user.id, + fields = DefaultItemFields, + sortBy = listOf(sortAndDirection.sort), + 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 = FilterUtils.getFilterOptionValues( api, serverRepository.currentUser.value?.id, - itemUuid, + itemId, filterOption, ) @@ -231,6 +314,24 @@ class PlaylistViewModel backdropService.submit(item) } } + + fun setWatched( + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + } + + fun sendMediaReport(itemId: UUID) { + viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) } + } } @Immutable @@ -239,52 +340,111 @@ data class FilterAndSort( val sortAndDirection: SortAndDirection, ) +data class PlaylistDetailsState( + val playlist: BaseItem? = null, + val mediaType: MediaType = MediaType.UNKNOWN, + val items: List = emptyList(), + val filterAndSort: FilterAndSort = + FilterAndSort( + filter = GetItemsFilter(), + sortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + ), + val loading: LoadingState = LoadingState.Pending, +) + @Composable fun PlaylistDetails( + preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: PlaylistViewModel = hiltViewModel(), + viewModel: PlaylistViewModel = + hiltViewModel( + creationCallback = { it.create(destination.itemId) }, + ), + addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.init(destination.itemId) - } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val playlist by viewModel.item.observeAsState(null) - val items by viewModel.items.observeAsState(listOf()) - val filterAndSort by viewModel.filterAndSort.collectAsState() + val state by viewModel.state.collectAsState() + val musicState by viewModel.musicState.collectAsState() var longClickDialog by remember { mutableStateOf(null) } + var showConfirmTypeDialog by remember { mutableStateOf?>(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - val goToString = stringResource(R.string.go_to) - val playFromHereString = stringResource(R.string.play_from_here) + fun play( + index: Int, + item: BaseItem, + shuffle: Boolean, + mediaTypeOverride: MediaType? = null, + ) { + when (mediaTypeOverride ?: state.mediaType) { + MediaType.VIDEO -> { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = 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(null) } + val musicMoreActions = + MusicMoreDialogActions( + onNavigate = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, + onClickPlayNext = { index, item -> viewModel.playNext(item) }, + onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO) + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = {}, + onClickDelete = { showDeleteDialog = it }, + ) + val moreActions = + MoreDialogActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel::sendMediaReport, + onClickDelete = { showDeleteDialog = it }, + ) PlaylistDetailsContent( - loadingState = loading, - playlist = playlist, - items = items, + loadingState = state.loading, + playlist = state.playlist, + items = state.items, + musicState = musicState, onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, _ -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = index, - shuffle = false, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) + onClickIndex = { index, item -> + play(index, item, false) }, onClickPlay = { shuffle -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = 0, - shuffle = shuffle, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) + state.playlist?.let { + play(0, it, shuffle) + } }, onLongClickIndex = { index, item -> longClickDialog = @@ -292,31 +452,30 @@ fun PlaylistDetails( fromLongClick = true, title = item.name ?: "", items = - listOf( - DialogItem( - goToString, - Icons.Default.ArrowForward, - ) { - viewModel.navigationManager.navigateTo(item.destination()) - }, - DialogItem( - playFromHereString, - Icons.Default.PlayArrow, - ) { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = index, - shuffle = false, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) - }, - ), + if (item.type == BaseItemKind.AUDIO) { + buildMoreDialogForMusic( + context = context, + actions = musicMoreActions, + item = item, + index = index, + canRemove = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + ) + } else { + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = item.data.seriesId, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + actions = moreActions, + ) + }, ) }, - filterAndSort = filterAndSort, + filterAndSort = state.filterAndSort, onFilterAndSortChange = viewModel::loadItems, getPossibleFilterValues = viewModel::getFilterOptionValues, modifier = modifier, @@ -327,12 +486,46 @@ fun PlaylistDetails( onDismissRequest = { longClickDialog = null }, ) } + showConfirmTypeDialog?.let { (index, item, shuffle) -> + ConfirmMediaTypeDialog( + onConfirm = { mediaType -> play(index, item, shuffle, mediaType) }, + onCancel = { showConfirmTypeDialog = null }, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = addPlaylistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + addToPlaylistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } @Composable fun PlaylistDetailsContent( playlist: BaseItem?, items: List, + musicState: MusicServiceState, onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, @@ -449,10 +642,18 @@ fun PlaylistDetailsContent( onLongClickIndex.invoke(index, item) } }, + isPlaying = + equalsNotNull( + musicState.currentItemId, + item?.id, + ), + isQueued = item?.id in musicState.queuedIds, modifier = Modifier - .height(80.dp) .ifElse( + item?.type != BaseItemKind.AUDIO, + Modifier.height(80.dp), + ).ifElse( index == savedIndex, Modifier.focusRequester(focus), ).onFocusChanged { @@ -582,6 +783,8 @@ fun PlaylistItem( onLongClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + isPlaying: Boolean = false, + isQueued: Boolean = false, ) { val focused by interactionSource.collectIsFocusedAsState() ListItem( @@ -592,14 +795,12 @@ fun PlaylistItem( headlineContent = { Text( text = item?.title ?: "", - style = MaterialTheme.typography.titleLarge, modifier = Modifier.enableMarquee(focused), ) }, supportingContent = { Text( text = item?.subtitle ?: "", - style = MaterialTheme.typography.titleSmall, modifier = Modifier.enableMarquee(focused), ) }, @@ -609,16 +810,18 @@ fun PlaylistItem( val endTimeStr = remember(item, now) { val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - TimeFormatter.format(endTime) + getTimeFormatter().format(endTime) } Column { Text( text = duration.toString(), ) - Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, - ) + if (item.type != BaseItemKind.AUDIO) { + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = MaterialTheme.typography.bodySmall, + ) + } } } }, @@ -631,20 +834,71 @@ fun PlaylistItem( text = "${index + 1}.", style = MaterialTheme.typography.labelLarge, ) - ItemCardImage( - item = item, - name = item?.name, - showOverlay = true, - 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, - ) + if (item?.type == BaseItemKind.AUDIO) { + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } else { + ItemCardImage( + item = item, + name = item?.name, + showOverlay = true, + 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, ) } + +@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) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt new file mode 100644 index 00000000..b10e9a17 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt @@ -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, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt new file mode 100644 index 00000000..7f9cc1ca --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -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( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + + // Dialogs + var moreDialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf?>(null) } + var showViewOptionsDialog by remember { mutableStateOf(false) } + var overviewDialog by remember { mutableStateOf(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, + 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(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 = + 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) + }, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt new file mode 100644 index 00000000..a71ac187 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt @@ -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() + } + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt new file mode 100644 index 00000000..64a6eb10 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -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 + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt new file mode 100644 index 00000000..ef78fb3b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt new file mode 100644 index 00000000..badc4e9d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -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 = _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, + ): ApiRequestPager { + 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, + ): GetItemsRequest { + val includeItemTypes: List? + val excludeItemTypes: List? + // 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 = + 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 = emptyList(), + val separateItems: Map = emptyMap(), + val logoImageUrl: String? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt new file mode 100644 index 00000000..b080e817 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt @@ -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 + 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( + 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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index e341923b..a89db27b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -85,6 +86,7 @@ fun EpisodeDetails( var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val canDelete by viewModel.canDelete.collectAsState() val preferredSubtitleLanguage = viewModel.serverRepository.currentUserDto @@ -226,7 +228,7 @@ fun EpisodeDetails( onClearChosenStreams = { viewModel.clearChosenStreams(chosenStreams) }, - canDelete = viewModel.canDelete, + canDelete = canDelete, ), ) }, @@ -236,7 +238,7 @@ fun EpisodeDetails( favoriteOnClick = { viewModel.setFavorite(ep.id, !ep.favorite) }, - canDelete = viewModel.canDelete, + canDelete = canDelete, deleteOnClick = { showDeleteDialog = ep }, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 9b8bda5c..df3b180a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.episode import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository @@ -19,6 +20,8 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.combinePair +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -34,6 +37,11 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -68,11 +76,19 @@ class EpisodeViewModel val item = MutableLiveData(null) val chosenStreams = MutableLiveData(null) - var canDelete: Boolean = false - private set + val canDelete = MutableStateFlow(false) init { init() + viewModelScope.launchDefault { + item + .asFlow() + .filterNotNull() + .combinePair(userPreferencesService.flow.map { it.appPreferences }) + .collectLatest { (item, preferences) -> + canDelete.update { mediaManagementService.canDelete(item, preferences) } + } + } } private fun fetchAndSetItem(): Deferred = @@ -101,7 +117,6 @@ class EpisodeViewModel ) { val prefs = userPreferencesService.getCurrent() val item = fetchAndSetItem().await() - canDelete = mediaManagementService.canDelete(item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) withContext(Dispatchers.Main) { this@EpisodeViewModel.item.value = item diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 87a5dd75..4195e9a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -106,6 +106,7 @@ fun MovieDetails( val loading by viewModel.loading.observeAsState(LoadingState.Loading) val chosenStreams by viewModel.chosenStreams.observeAsState(null) val discovered by viewModel.discovered.collectAsState() + val canDelete by viewModel.canDelete.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -211,7 +212,7 @@ fun MovieDetails( seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, - canDelete = viewModel.canDelete, + canDelete = canDelete, actions = moreActions, onChooseVersion = { chooseVersion = @@ -323,7 +324,7 @@ fun MovieDetails( onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, - canDelete = viewModel.canDelete, + canDelete = canDelete, deleteOnClick = { showDeleteDialog = movie }, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 993260dc..131c0cd6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.movie import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem @@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.combinePair +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination @@ -46,6 +49,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -93,11 +99,19 @@ class MovieViewModel val chosenStreams = MutableLiveData(null) val discovered = MutableStateFlow>(listOf()) - var canDelete: Boolean = false - private set + val canDelete = MutableStateFlow(false) init { init() + viewModelScope.launchDefault { + item + .asFlow() + .filterNotNull() + .combinePair(userPreferencesService.flow.map { it.appPreferences }) + .collectLatest { (item, preferences) -> + canDelete.update { mediaManagementService.canDelete(item, preferences) } + } + } } private fun fetchAndSetItem(): Deferred = @@ -112,7 +126,6 @@ class MovieViewModel api.userLibraryApi.getItem(itemId).content.let { BaseItem.from(it, api) } - canDelete = mediaManagementService.canDelete(item) this@MovieViewModel.item.setValueOnMain(item) item } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt new file mode 100644 index 00000000..dfd0aa0b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -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 = _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, + val similar: List, + val loading: LoadingState, + val musicVideos: List = 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( + 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.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var moreDialog by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(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() + } + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt new file mode 100644 index 00000000..050373fb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -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 = _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, + val albums: List, + val similar: List, + val loading: LoadingState, + val musicVideos: List, +) { + 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( + 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.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var moreDialog by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(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() + } + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt new file mode 100644 index 00000000..d5ea2037 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt new file mode 100644 index 00000000..6ccea46e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -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), + ), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt new file mode 100644 index 00000000..78a5603b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt new file mode 100644 index 00000000..1a519998 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt @@ -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( + title = R.string.show_album_cover, + defaultValue = false, + getter = { it.musicPreferences.showAlbumArt }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showAlbumArt = value } + }, + ), + AppSwitchPreference( + title = R.string.show_visualizer, + defaultValue = false, + getter = { it.musicPreferences.showVisualizer }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showVisualizer = value } + }, + ), + AppSwitchPreference( + title = R.string.show_backdrop, + defaultValue = false, + getter = { it.musicPreferences.showBackdrop }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showBackdrop = value } + }, + ), + AppSwitchPreference( + title = R.string.show_lyrics, + defaultValue = false, + getter = { it.musicPreferences.showLyrics }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showLyrics = value } + }, + ), + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt new file mode 100644 index 00000000..e7b85418 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt @@ -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() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt new file mode 100644 index 00000000..cf9f9c7c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt @@ -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 + 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 -> + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt new file mode 100644 index 00000000..44f08012 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt new file mode 100644 index 00000000..d76e560e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -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, + 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), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt new file mode 100644 index 00000000..1f253b82 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -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( + 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(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), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt new file mode 100644 index 00000000..f6fe40e4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -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() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt new file mode 100644 index 00000000..f1cc9225 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -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, + 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(0)) + + private val lyricCache = + InMemoryKache(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.. +// 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() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt new file mode 100644 index 00000000..82846282 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt new file mode 100644 index 00000000..5fae787c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -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 = + 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 = + 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 { + 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 { + 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 { + val request = + GetItemsRequest( + parentId = playlistId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DefaultItemFields, + sortBy = + listOf( + ItemSortBy.DEFAULT, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 4f8e4366..aea22bf1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -132,6 +132,14 @@ fun SeriesDetails( var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } var showDeleteDialog by remember { mutableStateOf(null) } + LifecycleResumeEffect(destination.itemId) { + viewModel.refresh() + + onPauseOrDispose { + viewModel.release() + } + } + when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) @@ -148,9 +156,7 @@ fun SeriesDetails( LifecycleResumeEffect(destination.itemId) { viewModel.onResumePage() - onPauseOrDispose { - viewModel.release() - } + onPauseOrDispose {} } val played = item.data.userData?.played ?: false diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 2d044670..cfc43be9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -271,9 +271,9 @@ class SeriesViewModel } fun onResumePage() { - viewModelScope.launchIO { - item.value?.let { - backdropService.submit(it) + item.value?.let { item -> + viewModelScope.launchDefault { backdropService.submit(item) } + viewModelScope.launchIO { val playThemeSongs = userPreferencesService .getCurrent() @@ -283,6 +283,17 @@ class SeriesViewModel } } + fun refresh() { + item.value?.let { item -> + if (loading.value == LoadingState.Success) { + viewModelScope.launchIO { + val seasons = getSeasons(item, null).await() + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + } + } + } + fun release() { themeSongPlayer.stop() } @@ -292,6 +303,7 @@ class SeriesViewModel seasonNum: Int?, ): Deferred> = viewModelScope.async(Dispatchers.IO) { + Timber.v("getSeasons for %s", series.id) val request = GetItemsRequest( parentId = series.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 3a7a457a..733ddef2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -227,6 +227,15 @@ fun HomePageContent( listState: LazyListState = rememberLazyListState(), takeFocus: Boolean = true, showEmptyRows: Boolean = false, + headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem -> + HomePageHeader( + item = focusedItem, + modifier = + Modifier + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) + .fillMaxHeight(.33f), + ) + }, ) { val focusedItem = position.let { @@ -266,13 +275,8 @@ fun HomePageContent( } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { - HomePageHeader( - item = focusedItem, - modifier = - Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) - .fillMaxHeight(.33f), - ) + headerComposable.invoke(focusedItem) + val density = LocalDensity.current val spaceAbovePx = with(density) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index a7fade4e..fde8e3aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard @@ -76,6 +77,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind @@ -100,10 +103,15 @@ class SearchViewModel val series = MutableLiveData(SearchResult.NoQuery) val episodes = MutableLiveData(SearchResult.NoQuery) val collections = MutableLiveData(SearchResult.NoQuery) + val albums = MutableLiveData(SearchResult.NoQuery) + val artists = MutableLiveData(SearchResult.NoQuery) + val songs = MutableLiveData(SearchResult.NoQuery) val seerrResults = MutableLiveData(SearchResult.NoQuery) private var currentQuery: String? = null + private val semaphore = Semaphore(4) + fun search(query: String?) { if (currentQuery == query) { return @@ -117,6 +125,9 @@ class SearchViewModel searchInternal(query, BaseItemKind.MOVIE, movies) searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.EPISODE, episodes) + searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums) + searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists) + searchInternal(query, BaseItemKind.AUDIO, songs) searchInternal(query, BaseItemKind.BOX_SET, collections) searchSeerr(query) } else { @@ -135,19 +146,21 @@ class SearchViewModel ) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { try { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(type), - fields = SlimItemFields, - limit = 25, - ) - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - pager.init() - withContext(Dispatchers.Main) { - target.value = SearchResult.Success(pager) + semaphore.withPermit { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = 25, + ) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + pager.init() + withContext(Dispatchers.Main) { + target.value = SearchResult.Success(pager) + } } } catch (ex: Exception) { Timber.e(ex, "Exception searching for $type") @@ -202,10 +215,13 @@ sealed interface SearchResult { private const val SEARCH_ROW = 0 private const val MOVIE_ROW = SEARCH_ROW + 1 -private const val COLLECTION_ROW = MOVIE_ROW + 1 -private const val SERIES_ROW = COLLECTION_ROW + 1 +private const val SERIES_ROW = MOVIE_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 -private const val SEERR_ROW = EPISODE_ROW + 1 +private const val ALBUM_ROW = EPISODE_ROW + 1 +private const val ARTIST_ROW = ALBUM_ROW + 1 +private const val SONG_ROW = ARTIST_ROW + 1 +private const val COLLECTION_ROW = SONG_ROW + 1 +private const val SEERR_ROW = COLLECTION_ROW + 1 /** Delay for focus to settle after voice search dialog dismisses. */ private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L @@ -223,6 +239,9 @@ fun SearchPage( val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) + val albums by viewModel.albums.observeAsState(SearchResult.NoQuery) + val artists by viewModel.artists.observeAsState(SearchResult.NoQuery) + val songs by viewModel.songs.observeAsState(SearchResult.NoQuery) val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) // val query = rememberTextFieldState() @@ -367,16 +386,6 @@ fun SearchPage( onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), ) - searchResultRow( - title = context.getString(R.string.collections), - result = collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) searchResultRow( title = context.getString(R.string.tv_shows), result = series, @@ -409,6 +418,85 @@ fun SearchPage( ) }, ) + searchResultRow( + title = context.getString(R.string.albums), + result = albums, + rowIndex = ALBUM_ROW, + position = position, + focusRequester = focusRequesters[ALBUM_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ALBUM_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + ) + searchResultRow( + title = context.getString(R.string.artists), + result = artists, + rowIndex = COLLECTION_ROW, + position = position, + focusRequester = focusRequesters[COLLECTION_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ALBUM_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + ) + searchResultRow( + title = context.getString(R.string.songs), + result = songs, + rowIndex = SONG_ROW, + position = position, + focusRequester = focusRequesters[SONG_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ALBUM_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + ) + searchResultRow( + title = context.getString(R.string.collections), + result = collections, + rowIndex = COLLECTION_ROW, + position = position, + focusRequester = focusRequesters[COLLECTION_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) searchResultRow( title = context.getString(R.string.discover), result = seerrResults, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index eaf44d81..5714723e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -29,6 +29,7 @@ data class HomeRowPresets( val tvLibrary: HomeRowViewOptions, val videoLibrary: HomeRowViewOptions, val photoLibrary: HomeRowViewOptions, + val musicLibrary: HomeRowViewOptions, val playlist: HomeRowViewOptions, val liveTv: HomeRowViewOptions, val genreSize: Int, @@ -51,8 +52,9 @@ data class HomeRowPresets( CollectionType.LIVETV -> liveTv + CollectionType.MUSIC -> musicLibrary + CollectionType.UNKNOWN, - CollectionType.MUSIC, CollectionType.BOOKS, CollectionType.PLAYLISTS, CollectionType.FOLDERS, @@ -74,6 +76,10 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( aspectRatio = AspectRatio.SQUARE, @@ -111,6 +117,11 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( heightDp = epHeight, @@ -154,6 +165,11 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( heightDp = epHeight, @@ -198,6 +214,11 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( heightDp = epHeight, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt index 3bac590a..5bf3056d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -59,6 +59,8 @@ val favoriteOptions by lazy { BaseItemKind.VIDEO to R.string.videos, BaseItemKind.PLAYLIST to R.string.playlists, BaseItemKind.PERSON to R.string.people, + BaseItemKind.MUSIC_ARTIST to R.string.artists, + BaseItemKind.MUSIC_ALBUM to R.string.albums, ) } val favoriteOptionsList by lazy { favoriteOptions.keys.toList() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 67b57e28..50f34513 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionEx import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -122,19 +123,23 @@ class HomeSettingsViewModel state.value .let { state -> state.rows - .map { it.config } .map { row -> viewModelScope.async(Dispatchers.IO) { semaphore.withPermit { - homeSettingsService.fetchDataForRow( - row = row, - scope = viewModelScope, - prefs = prefs, - userDto = userDto, - libraries = state.libraries, - limit = limit, - isRefresh = false, - ) + try { + homeSettingsService.fetchDataForRow( + row = row.config, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = state.libraries, + limit = limit, + isRefresh = false, + ) + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error(row.title, exception = ex) + } } } } @@ -195,7 +200,12 @@ class HomeSettingsViewModel viewModelScope.launchIO { updateState { val rows = it.rows.toMutableList().apply { removeAt(index) } - val rowData = it.rowData.toMutableList().apply { removeAt(index) } + val rowData = + if (index in it.rowData.indices) { + it.rowData.toMutableList().apply { removeAt(index) } + } else { + it.rowData + } it.copy( rows = rows, rowData = rowData, @@ -258,6 +268,24 @@ class HomeSettingsViewModel rowType: LibraryRowType, ): Job = viewModelScope.launchIO { + val viewOptions = + when (library.collectionType) { + CollectionType.MUSIC -> { + HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE) + } + + CollectionType.HOMEVIDEOS, + CollectionType.MUSICVIDEOS, + -> { + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + ) + } + + else -> { + HomeRowViewOptions() + } + } val id = idCounter++ val newRow = when (rowType) { @@ -267,7 +295,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = RecentlyAdded(library.itemId), + config = RecentlyAdded(library.itemId, viewOptions), ) } @@ -282,7 +310,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = RecentlyReleased(library.itemId), + config = RecentlyReleased(library.itemId, viewOptions), ) } @@ -301,7 +329,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = Suggestions(library.itemId), + config = Suggestions(library.itemId, viewOptions), ) } @@ -662,6 +690,7 @@ class HomeSettingsViewModel BaseItemKind.VIDEO -> preset.videoLibrary BaseItemKind.PLAYLIST -> preset.playlist BaseItemKind.PERSON -> preset.movieLibrary + BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary else -> preset.movieLibrary } it.config.updateViewOptions(viewOptions) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 5f01f20d..0cf42622 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -1,58 +1,28 @@ package com.github.damontecres.wholphin.ui.nav -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.saveable.rememberSerializable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.serialization.NavBackStackSerializer -import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.DrawerValue -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.rememberDrawerState -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.transitionFactory import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.launchIO import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds // Top scrim configuration for text readability (clock, season tabs) const val TOP_SCRIM_ALPHA = 0.55f @@ -78,160 +48,23 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, - startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { - val backStack: MutableList = - rememberSerializable( - server, - user, - serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), - ) { - NavBackStack(startDestination) - } - navigationManager.backStack = backStack - val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() - val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val drawerState = rememberDrawerState(DrawerValue.Closed) Box( modifier = modifier, ) { - val baseBackgroundColor = MaterialTheme.colorScheme.background - if (backdrop.hasColors && - (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) - ) { - val animPrimary by animateColorAsState( - backdrop.primaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_primary", - ) - val 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 backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle + Backdrop( + drawerIsOpen = drawerState.isOpen, + backdropStyle = backdropStyle, + enableTopScrim = enableTopScrim, + viewModel = viewModel, + ) val navDrawerListState = rememberLazyListState() NavDisplay( backStack = navigationManager.backStack, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt new file mode 100644 index 00000000..21520447 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt @@ -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, + ) + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 59d102c4..936ce69f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -129,6 +129,9 @@ sealed class Destination( val item: DiscoverItem, ) : Destination(false) + @Serializable + data object NowPlaying : Destination(true) + @Serializable data object UpdateApp : Destination(true) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index c0e4ce26..7c41c6d1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -11,10 +11,10 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions -import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie +import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings @@ -23,11 +23,15 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails +import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails +import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage +import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage +import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage @@ -142,11 +146,9 @@ fun DestinationContent( BaseItemKind.BOX_SET -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } - CollectionFolderBoxSet( + CollectionDetails( preferences = preferences, itemId = destination.itemId, - recursive = false, - playEnabled = true, modifier = modifier, ) } @@ -154,6 +156,7 @@ fun DestinationContent( BaseItemKind.PLAYLIST -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } PlaylistDetails( + preferences = preferences, destination = destination, modifier = modifier, ) @@ -214,6 +217,24 @@ fun DestinationContent( ) } + BaseItemKind.MUSIC_ALBUM -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + AlbumDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + + BaseItemKind.MUSIC_ARTIST -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + ArtistDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}", modifier) @@ -267,6 +288,10 @@ fun DestinationContent( ) } + Destination.NowPlaying -> { + NowPlayingPage(modifier) + } + Destination.UpdateApp -> { InstallUpdatePage(preferences, modifier) } @@ -386,6 +411,14 @@ fun CollectionFolder( ) } + CollectionType.MUSIC -> { + CollectionFolderMusic( + preferences, + destination, + modifier, + ) + } + CollectionType.HOMEVIDEOS, CollectionType.PHOTOS, -> { @@ -398,7 +431,6 @@ fun CollectionFolder( } CollectionType.MUSICVIDEOS, - CollectionType.MUSIC, CollectionType.BOOKS, -> { CollectionFolderGeneric( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index f0276ffd..6e2a2260 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateIntOffsetAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -24,6 +27,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable @@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination @@ -104,6 +109,7 @@ class NavDrawerViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, + private val musicService: MusicService, ) : ViewModel() { val state = navDrawerService.state @@ -178,9 +184,11 @@ class NavDrawerViewModel if (key is Destination) { val index = if (key is Destination.Home) { - -1 + HOME_INDEX } else if (key is Destination.Search) { - -2 + SEARCH_INDEX + } else if (key is Destination.NowPlaying) { + NOW_PLAYING_INDEX } else { val idx = asDestinations.indexOf(key) if (idx >= 0) { @@ -198,6 +206,13 @@ class NavDrawerViewModel } } } + + fun navigateToSetup(userList: SetupDestination) { + viewModelScope.launchDefault { + musicService.stop() + setupNavigationManager.navigateTo(userList) + } + } } sealed interface NavDrawerItem { @@ -242,6 +257,10 @@ data class ServerNavDrawerItem( } } +private const val HOME_INDEX = -1 +private const val SEARCH_INDEX = -2 +private const val NOW_PLAYING_INDEX = -3 + /** * Display the left side navigation drawer with [DestinationContent] on the right */ @@ -324,12 +343,37 @@ fun NavDrawer( drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setupNavigationManager.navigateTo( + viewModel.navigateToSetup( SetupDestination.UserList(server), ) }, modifier = Modifier, ) + AnimatedVisibility( + visible = state.nowPlayingEnabled, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + val interactionSource = remember { MutableInteractionSource() } + IconNavItem( + text = stringResource(R.string.now_playing), + subtext = state.nowPlayingTitle, + icon = Icons.Default.PlayArrow, + selected = selectedIndex == NOW_PLAYING_INDEX, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.setIndex(NOW_PLAYING_INDEX) + viewModel.navigationManager.navigateTo(Destination.NowPlaying) + }, + modifier = + Modifier + .ifElse( + selectedIndex == NOW_PLAYING_INDEX, + Modifier.focusRequester(focusRequester), + ), + ) + } LazyColumn( state = navDrawerListState, contentPadding = PaddingValues(0.dp), @@ -353,18 +397,18 @@ fun NavDrawer( IconNavItem( text = stringResource(R.string.search), icon = Icons.Default.Search, - selected = selectedIndex == -2, + selected = selectedIndex == SEARCH_INDEX, drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(-2) + viewModel.setIndex(SEARCH_INDEX) viewModel.navigationManager.navigateToFromDrawer(Destination.Search) }, modifier = Modifier .focusRequester(searchFocusRequester) .ifElse( - selectedIndex == -2, + selectedIndex == SEARCH_INDEX, Modifier.focusRequester(focusRequester), ), ) @@ -374,11 +418,11 @@ fun NavDrawer( IconNavItem( text = stringResource(R.string.home), icon = Icons.Default.Home, - selected = selectedIndex == -1, + selected = selectedIndex == HOME_INDEX, drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(-1) + viewModel.setIndex(HOME_INDEX) if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() } else { @@ -388,7 +432,7 @@ fun NavDrawer( modifier = Modifier .ifElse( - selectedIndex == -1, + selectedIndex == HOME_INDEX, Modifier.focusRequester(focusRequester), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt index 94a41aa1..30165f58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt @@ -1,23 +1,23 @@ package com.github.damontecres.wholphin.ui.playback +import androidx.annotation.OptIn import androidx.media3.common.ForwardingSimpleBasePlayer import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.seekBack import com.google.common.util.concurrent.ListenableFuture import timber.log.Timber +@OptIn(UnstableApi::class) class MediaSessionPlayer( player: Player, - private val controllerViewState: ControllerViewState, private val playbackPreferences: PlaybackPreferences, ) : ForwardingSimpleBasePlayer(player) { override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") - if (!playWhenReady && player.isPlaying) { - controllerViewState.showControls() - } else if (playWhenReady) { + if (playWhenReady) { playbackPreferences.skipBackOnResume?.let { player.seekBack(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt new file mode 100644 index 00000000..2f812970 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt @@ -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() + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 8ad9282c..28f9b9d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.annotation.DrawableRes import androidx.annotation.OptIn +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource @@ -38,10 +39,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider @@ -57,6 +63,7 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent @@ -284,7 +291,7 @@ fun SeekBar( } } -private val buttonSpacing = 12.dp +val buttonSpacing = 12.dp @Composable fun LeftPlaybackButtons( @@ -460,6 +467,54 @@ fun PlaybackButton( } } +@Composable +fun PlaybackFaButton( + @StringRes iconRes: Int, + onClick: () -> Unit, + onControllerInteraction: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + textColor: Color = Color.Unspecified, +) { + val selectedColor = MaterialTheme.colorScheme.border + Button( + enabled = enabled, + onClick = onClick, +// shape = ButtonDefaults.shape(CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = AppColors.TransparentBlack25, + focusedContainerColor = selectedColor, + ), + contentPadding = PaddingValues(4.dp), + interactionSource = interactionSource, + modifier = + modifier + .size(36.dp, 36.dp) + .onFocusChanged { onControllerInteraction.invoke() }, + ) { + Text( + text = stringResource(iconRes), + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + color = + if (textColor.isSpecified) { + textColor + } else if (LocalTheme.current == AppThemeColors.OLED_BLACK) { + LocalContentColor.current + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) + } +} + @Composable fun BottomDialog( choices: List>, @@ -554,12 +609,18 @@ data class BottomDialogItem( @Composable private fun ButtonPreview() { WholphinTheme { - Row { + Row(Modifier.background(Color.Red)) { PlaybackButton( iconRes = R.drawable.baseline_play_arrow_24, onClick = {}, onControllerInteraction = {}, ) + PlaybackFaButton( + iconRes = R.string.fa_shuffle, + onClick = {}, + onControllerInteraction = {}, + textColor = Color.Green, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index ce31d865..e756a9df 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -19,8 +19,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.byteRateSuffixes -import com.github.damontecres.wholphin.ui.formatBytes +import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.letNotEmpty import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -104,7 +103,7 @@ fun TranscodeInfo( "Reason:" to info.transcodeReasons.joinToString(", "), "HW Accel:" to info.hardwareAccelerationType?.toString(), "Container:" to info.container, - "Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) }, + "Bitrate:" to info.bitrate?.let { formatBitrate(it) }, ), ) SimpleTable( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 5ed82729..a7cd4bb1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -55,9 +55,7 @@ class PlaybackKeyHandler( } else if (oneClickPause && isEnterKey(it)) { val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) - if (wasPlaying) { - controllerViewState.showControls() - } else { + if (!wasPlaying) { skipBackOnResume?.let { player.seekBack(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 0886590e..3bc4ddb0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -1,10 +1,17 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideIn import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOut import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -50,6 +57,10 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.Player @@ -64,10 +75,10 @@ import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.TimeDisplay +import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -119,17 +130,14 @@ fun PlaybackOverlay( var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) } var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat() - val chapterInteractionSources = - remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } - val density = LocalDensity.current val titleHeight = - remember { + remember(item?.title) { if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp } val subtitleHeight = - remember { + remember(item?.subtitleLong) { if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp } @@ -137,19 +145,6 @@ fun PlaybackOverlay( var controllerHeight by remember { mutableStateOf(0.dp) } 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( modifier = modifier, contentAlignment = Alignment.BottomCenter, @@ -160,6 +155,18 @@ fun PlaybackOverlay( exit = fadeOut(), modifier = Modifier.matchParentSize(), ) { + // Background scrim for OSD readability + val scrimBrush = + remember { + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.80f), + ), + ) + } Box( modifier = Modifier @@ -169,10 +176,16 @@ fun PlaybackOverlay( } AnimatedVisibility( - state == OverlayViewState.CONTROLLER, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), ) { + if (seekBarFocused) { + LaunchedEffect(Unit) { + seekProgressPercent = + (playerControls.currentPosition.toFloat() / playerControls.duration) + } + } val nextState = if (chapters.isNotEmpty()) { OverlayViewState.CHAPTERS @@ -253,11 +266,13 @@ fun PlaybackOverlay( } } AnimatedVisibility( - state == OverlayViewState.CHAPTERS, + visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS, enter = slideInVertically { it / 2 } + fadeIn(), exit = slideOutVertically { it / 2 } + fadeOut(), ) { if (chapters.isNotEmpty()) { + val chapterInteractionSources = + remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } val bringIntoViewRequester = remember { BringIntoViewRequester() } val chapterIndex = remember { @@ -368,7 +383,7 @@ fun PlaybackOverlay( } } AnimatedVisibility( - state == OverlayViewState.QUEUE, + visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE, enter = slideInVertically { it / 2 } + fadeIn(), exit = slideOutVertically { it / 2 } + fadeOut(), ) { @@ -441,15 +456,17 @@ fun PlaybackOverlay( } } - if (seekBarInteractionSource.collectIsFocusedAsState().value) { - LaunchedEffect(Unit) { - seekProgressPercent = - (playerControls.currentPosition.toFloat() / playerControls.duration) - } - } - + // Trickplay AnimatedVisibility( - seekProgressPercent >= 0 && seekBarFocused, + visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, + enter = + expandVertically( + spring( + stiffness = Spring.StiffnessMedium, + visibilityThreshold = IntSize.VisibilityThreshold, + ), + ) + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { Box( modifier = @@ -496,9 +513,13 @@ fun PlaybackOverlay( } } } + + // Top val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) AnimatedVisibility( - !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, + visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, + enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(), + exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(), modifier = Modifier .align(Alignment.TopStart), @@ -514,7 +535,9 @@ fun PlaybackOverlay( ) } AnimatedVisibility( - !showDebugInfo && showClock && controllerViewState.controlsVisible, + visible = !showDebugInfo && showClock && controllerViewState.controlsVisible, + enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(), + exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(), modifier = Modifier .align(Alignment.TopEnd), @@ -522,7 +545,9 @@ fun PlaybackOverlay( TimeDisplay() } AnimatedVisibility( - showDebugInfo && controllerViewState.controlsVisible, + visible = showDebugInfo && controllerViewState.controlsVisible, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), modifier = Modifier .align(Alignment.TopStart), @@ -578,6 +603,11 @@ fun Controller( val verticalOffset by animateDpAsState( targetValue = if (seekBarFocused) (-32).dp else 0.dp, label = "TitleBumpOffset", + animationSpec = + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Dp.VisibilityThreshold, + ), ) Column( @@ -596,6 +626,9 @@ fun Controller( text = it, style = MaterialTheme.typography.titleLarge, fontSize = titleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), ) } Row( @@ -610,6 +643,9 @@ fun Controller( text = it, style = MaterialTheme.typography.titleMedium, fontSize = subtitleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), ) } @@ -622,7 +658,7 @@ fun Controller( .toLong() .milliseconds val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = TimeFormatter.format(endTime) + endTimeStr = getTimeFormatter().format(endTime) delay(1.seconds) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 799bb4a3..5f1c9455 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -8,8 +8,6 @@ import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box @@ -374,46 +372,49 @@ fun PlaybackPageContent( } } - // The playback controls - AnimatedVisibility( - controllerViewState.controlsVisible, - Modifier, - slideInVertically { it }, - slideOutVertically { it }, - ) { - PlaybackOverlay( + if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) { + PauseIndicator( + player = player, 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, + .align(Alignment.Center), ) } + // 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 = remember(mediaInfo) { Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}") diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 7eabeec1..f12e80bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile @@ -143,6 +145,7 @@ class PlaybackViewModel private val userPreferencesService: UserPreferencesService, private val imageUrlService: ImageUrlService, private val screensaverService: ScreensaverService, + private val musicService: MusicService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -260,7 +263,6 @@ class PlaybackViewModel val sessionPlayer = MediaSessionPlayer( player, - controllerViewState, preferences.appPreferences.playbackPreferences, ) mediaSession = @@ -273,6 +275,7 @@ class PlaybackViewModel * Initialize from the UI to start playback */ private suspend fun init() { + musicService.stop() nextUp.setValueOnMain(null) this.preferences = userPreferencesService.getCurrent() if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { @@ -737,12 +740,12 @@ class PlaybackViewModel player.removeListener(it) } + val playbackItemState = PlaybackItemState(playback, currentItemPlayback) val activityListener = TrackActivityPlaybackListener( api = api, player = player, - playback = playback, - itemPlayback = currentItemPlayback, + getState = { playbackItemState }, ) player.addListener(activityListener) this@PlaybackViewModel.activityListener = activityListener diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index afdc93a1..89fce8fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.ExceptionHandler @@ -85,11 +86,6 @@ data class NavDrawerPin( } } -enum class MoveDirection { - UP, - DOWN, -} - private fun List.move( direction: MoveDirection, index: Int, @@ -246,7 +242,7 @@ fun NavDrawerPreferenceListItem( } @Composable -private fun MoveButton( +fun MoveButton( @StringRes icon: Int, enabled: Boolean, onClick: () -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index bca5ae64..213dc725 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.github.damontecres.wholphin.ui.TimeFormatter +import com.github.damontecres.wholphin.ui.getTimeFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -25,9 +25,9 @@ data class Clock( */ val now: MutableState = mutableStateOf(LocalDateTime.now()), /** - * The current time formatted as a string with [TimeFormatter] + * The current time formatted as a string with [getTimeFormatter] */ - val timeString: MutableState = mutableStateOf(TimeFormatter.format(now.value)), + val timeString: MutableState = mutableStateOf(getTimeFormatter().format(now.value)), ) @Composable @@ -37,7 +37,7 @@ fun ProvideLocalClock(content: @Composable () -> Unit) { withContext(Dispatchers.Default) { while (isActive) { val now = LocalDateTime.now() - val time = TimeFormatter.format(now) + val time = getTimeFormatter().format(now) clock.now.value = now clock.timeString.value = time delay(2_000) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt index 75ccc38e..8a6fc939 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt @@ -1,9 +1,26 @@ package com.github.damontecres.wholphin.util +import java.util.function.IntFunction import java.util.function.Predicate interface BlockingList : List { suspend fun getBlocking(index: Int): T suspend fun indexOfBlocking(predicate: Predicate): Int + + companion object { + fun of(list: List): BlockingList = BlockingListWrapper(list) + } +} + +private class BlockingListWrapper( + private val list: List, +) : BlockingList, + List by list { + override suspend fun getBlocking(index: Int): T = get(index) + + override suspend fun indexOfBlocking(predicate: Predicate): Int = indexOfFirst { predicate.test(it) } + + @Deprecated("Deprecated") + override fun toArray(generator: IntFunction?>): Array = super.toArray(generator) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index fa0476cb..0c577571 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -32,6 +32,7 @@ val supportedCollectionTypes = CollectionType.LIVETV, CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, + CollectionType.MUSIC, null, // Mixed ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index b0c1f593..86ba1425 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo @@ -20,6 +21,7 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks import timber.log.Timber import java.util.Timer import java.util.TimerTask +import java.util.UUID import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -30,8 +32,7 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, - val playback: CurrentPlayback, - val itemPlayback: ItemPlayback, + private val getState: () -> PlaybackItemState?, ) : Player.Listener { private val coroutineScope = CoroutineScope(Dispatchers.Main) private val task: TimerTask = @@ -50,44 +51,50 @@ class TrackActivityPlaybackListener( fun init() { launch("reportPlaybackStart") { - Timber.v("reportPlaybackStart for ${itemPlayback.itemId}") - api.playStateApi.reportPlaybackStart( - PlaybackStartInfo( - canSeek = true, - itemId = itemPlayback.itemId, - isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, - playMethod = playback.playMethod, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - isMuted = false, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) - val delay = 5.seconds.inWholeMilliseconds - // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true + getState.invoke()?.let { state -> + Timber.v("reportPlaybackStart for ${state.itemId}") + api.playStateApi.reportPlaybackStart( + PlaybackStartInfo( + canSeek = true, + itemId = state.itemId, + isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, + playMethod = state.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + isMuted = false, + audioStreamIndex = state.audioStreamIndex, + subtitleStreamIndex = state.subtitleStreamIndex, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) + + val delay = 5.seconds.inWholeMilliseconds + // Every x seconds, check if the video is playing + TIMER.schedule(task, delay, delay) + initialized = true + } } } fun release() { +// player.removeListener(this) task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds launch("reportPlaybackStopped") { - Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") - api.playStateApi.reportPlaybackStopped( - PlaybackStopInfo( - itemId = itemPlayback.itemId, - positionTicks = position.inWholeTicks, - failed = false, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) + getState.invoke()?.let { state -> + Timber.v("reportPlaybackStopped for ${state.itemId} at $position") + api.playStateApi.reportPlaybackStopped( + PlaybackStopInfo( + itemId = state.itemId, + positionTicks = position.inWholeTicks, + failed = false, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) + } } } @@ -108,29 +115,31 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { - val calcPosition = - withContext(Dispatchers.Main) { - (if (position >= 0) position else player.currentPosition) + getState.invoke()?.let { state -> + val calcPosition = + withContext(Dispatchers.Main) { + (if (position >= 0) position else player.currentPosition) + } + if (calcPosition > 0) { + val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } + Timber.v("saveActivity: itemId=${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 { block.invoke(this) } catch (ex: Exception) { - Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId) + Timber.w(ex, "Exception during %s", name) } } } @@ -154,3 +163,24 @@ class TrackActivityPlaybackListener( private val TIMER by lazy { Timer("$TAG-timer", true) } } } + +data class PlaybackItemState( + val itemId: UUID, + val playMethod: PlayMethod, + val audioStreamIndex: Int? = null, + val subtitleStreamIndex: Int? = null, + val playSessionId: String? = null, + val liveStreamId: String? = null, +) { + constructor( + playback: CurrentPlayback, + itemPlayback: ItemPlayback, + ) : this( + itemId = itemPlayback.itemId, + playMethod = playback.playMethod, + audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, + subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + playSessionId = playback.playSessionId, + liveStreamId = playback.liveStreamId, + ) +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 081b25e4..ad8c7a20 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -178,6 +178,13 @@ message PhotoPreferences{ bool slideshow_play_videos = 2; } +message MusicPreferences { + bool show_lyrics = 1; + bool show_visualizer = 2; + bool show_album_Art = 3; + bool show_backdrop = 4; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -194,4 +201,5 @@ message AppPreferences { AdvancedPreferences advanced_preferences = 10; bool sign_in_automatically = 11; PhotoPreferences photo_preferences = 12; + MusicPreferences music_preferences = 13; } diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 38cf32c1..770a6b72 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -53,4 +53,7 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8dda4f85..1245917b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -504,6 +504,14 @@ Send media info log to server No limit Max days in Next Up + Albums + Artists + Songs + Go to artist + Now playing + Instant mix + Go to album + Add to queue Add row Genres in %1$s @@ -707,6 +715,8 @@ @string/photos + Search %s + Actor Composer Writer @@ -719,7 +729,23 @@ Mixer Creator Artist - Search %s + Album artist + Album + Popular songs + Play next + Music videos + Lyrics + Hide lyrics + Show lyrics + Song has lyrics + Remove from queue + Show album cover + Show visualizer + Show backdrop + Play as? + Visualizing the currently playing audio requires permission to record audio. + Continue Select all + Separate types diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 39705dd3..6ebdde41 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig @@ -278,5 +281,14 @@ object TestDatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6470883..9d92707f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kotlin = "2.3.20" ksp = "2.3.6" coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.03.00" +composeBom = "2026.03.01" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -33,17 +33,17 @@ material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.1" kotlinx-serialization = "1.10.0" -protobuf-javalite = "4.34.0" +protobuf-javalite = "4.34.1" hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" -workRuntimeKtx = "2.11.1" +workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" -openapi-generator = "7.20.0" +openapi-generator = "7.21.0" runner = "1.7.0" [libraries] @@ -73,6 +73,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dbc3ce4a..c61a118f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0262dcbd..739907df 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/scripts/ffmpeg/README.md b/scripts/ffmpeg/README.md index d1fdac02..36a9ca9a 100644 --- a/scripts/ffmpeg/README.md +++ b/scripts/ffmpeg/README.md @@ -1,3 +1,14 @@ # FFmpeg ExoPlayer decoder extension module Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback. + +## Usage + +```sh +./build_ffmpeg_decoder.sh /path/to/android/ndk +``` + +Or clean build: +```sh +./build_ffmpeg_decoder.sh /path/to/android/ndk --clean +``` diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh index 1741c703..338b850e 100755 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh @@ -27,6 +27,12 @@ AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main" HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST_PLATFORM="$HOST-x86_64" +if [[ "$2" == "--clean" ]]; then + rm -rf ffmpeg_decoder + rm -f "$TARGET_PATH/lib-decoder-ffmpeg-release.aar" + rm -f "$TARGET_PATH/lib-decoder-av1-release.aar" +fi + mkdir -p "$TARGET_PATH" mkdir -p ffmpeg_decoder @@ -38,14 +44,16 @@ pushd ffmpeg_decoder || exit if [[ -d media ]]; then pushd media || exit - git checkout --force "$media_version" + git fetch origin "$media_version" --depth 1 + git checkout --force FETCH_HEAD else git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media fi if [[ -d ffmpeg ]]; then pushd ffmpeg || exit - git checkout --force "$FFMPEG_BRANCH" + git fetch origin "$FFMPEG_BRANCH" --depth 1 + git checkout --force FETCH_HEAD else git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg fi @@ -68,7 +76,8 @@ pushd "$AV1_MODULE_PATH/jni" || exit if [[ -d dav1d ]]; then pushd dav1d || exit - git checkout --force "$DAV1D_BRANCH" + git fetch origin "$DAV1D_BRANCH" --depth 1 + git checkout --force FETCH_HEAD else git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d fi diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh index 74bff6d6..829b2a06 100755 --- a/scripts/mpv/get_dependencies.sh +++ b/scripts/mpv/get_dependencies.sh @@ -15,7 +15,8 @@ function clone(){ if [[ -d "$dir" ]]; then pushd "$dir" || exit - git checkout --force "$branch" + git fetch origin "$branch" --depth 1 + git checkout --force FETCH_HEAD popd || exit else git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@"