mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add support for music (#1059)
## Description Adds support for music playback ### Features - Adds a "Now Playing" page which is accessible from the nav drawer while music is playing - Manage the playback queue, add/remove/re-order songs - Supports synced & non-synced lyrics - Scroll through lyrics - Click on line for synced lyrics to seek to that position ### User interface - Customize what's shown on the "Now Playing" page - Show album cover art, backdrops, and lyrics - Show a basic bar visualizer - Search for albums, artists, & songs - Links between albums, artists, playlists, & music videos when metadata is available - Throughout the app, see which song is playing and which ones are in the queue Note: lyrics are synced by line. `10.11` added support for syncing by word, but Wholphin still supports `10.10` ### Related issues Closes #2 Closes #267 ### Testing Emulator & shield testing ## Screenshots <details><summary>Click to see screenshots</summary> <p> ### Album grid  ### Artist page  ### Album details  ### Album is playing/queued  ### Now playing page  ### Now playing queue  </p> </details> ## AI or LLM usage None ## Try it out See instructions at https://github.com/damontecres/Wholphin/releases/tag/develop-music
This commit is contained in:
parent
8a37d63d09
commit
f2570d4ab0
46 changed files with 6342 additions and 436 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ 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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,15 @@ class AppPreferencesSerializer
|
|||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
||||
}.build()
|
||||
|
||||
musicPreferences =
|
||||
MusicPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
showBackdrop = true
|
||||
showLyrics = true
|
||||
showAlbumArt = true
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||
|
|
@ -220,6 +229,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
|
|||
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences =
|
||||
update {
|
||||
musicPreferences = musicPreferences.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
|||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||
|
|
@ -280,5 +281,15 @@ class AppUpgradeHandler
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateMusicPreferences {
|
||||
showBackdrop = true
|
||||
showLyrics = true
|
||||
showAlbumArt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ class BackdropService
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
return@withContext ExtractedColors.DEFAULT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,519 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.github.damontecres.wholphin.ui.seekForward
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.BlockingList
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.PlaybackItemState
|
||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.instantMixApi
|
||||
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||
import org.jellyfin.sdk.api.sockets.subscribe
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class MusicService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||
@param:DefaultCoroutineScope private val defaultScope: CoroutineScope,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
||||
val state: StateFlow<MusicServiceState> = _state
|
||||
|
||||
val player: Player by lazy {
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(
|
||||
OkHttpDataSource.Factory(authOkHttpClient),
|
||||
),
|
||||
).build()
|
||||
.also {
|
||||
it.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
false,
|
||||
)
|
||||
it.addListener(MusicPlayerListener(it, _state))
|
||||
}
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
var mediaSession: MediaSession? = null
|
||||
private set
|
||||
private var activityTracker: TrackActivityPlaybackListener? = null
|
||||
private var websocketJob: Job? = null
|
||||
|
||||
suspend fun start() {
|
||||
if (mediaSession == null) {
|
||||
mutex.withLock {
|
||||
if (mediaSession == null) {
|
||||
Timber.i("Starting music MediaSession")
|
||||
mediaSession = MediaSession.Builder(context, player).build()
|
||||
activityTracker =
|
||||
TrackActivityPlaybackListener(api, player) {
|
||||
state.value.currentItemId?.let { itemId ->
|
||||
PlaybackItemState(
|
||||
itemId = itemId,
|
||||
playMethod = PlayMethod.DIRECT_PLAY,
|
||||
// playSessionId = mediaSession?.id,
|
||||
)
|
||||
}
|
||||
}.also { player.addListener(it) }
|
||||
websocketJob = subscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
onMain {
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
mutex.withLock {
|
||||
Timber.i("Stopping music")
|
||||
if (mediaSession == null) {
|
||||
Timber.w("Stopping but no MediaSession")
|
||||
}
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
onMain {
|
||||
websocketJob?.cancel()
|
||||
websocketJob = null
|
||||
activityTracker?.let {
|
||||
it.release()
|
||||
player.removeListener(it)
|
||||
}
|
||||
activityTracker = null
|
||||
player.stop()
|
||||
player.setMediaItems(emptyList())
|
||||
}
|
||||
_state.update {
|
||||
MusicServiceState.EMPTY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches instant mix items, replaces the queue, and begins playback
|
||||
*/
|
||||
suspend fun startInstantMix(itemId: UUID) =
|
||||
loading {
|
||||
val items =
|
||||
api.instantMixApi
|
||||
.getInstantMixFromItem(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
itemId = itemId,
|
||||
limit = 200,
|
||||
fields = DefaultItemFields,
|
||||
).content.items
|
||||
.map { BaseItem(it, false) }
|
||||
setQueue(items, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the queue with the given list and starting playing the song as startIndex as soon as its ready
|
||||
*
|
||||
* Fetches each item in a blocking way and adds to the queue
|
||||
*/
|
||||
suspend fun setQueue(
|
||||
items: BlockingList<BaseItem?>,
|
||||
startIndex: Int,
|
||||
shuffled: Boolean,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
|
||||
withContext(Dispatchers.Main) {
|
||||
player.setMediaItems(emptyList())
|
||||
player.shuffleModeEnabled = shuffled
|
||||
}
|
||||
start()
|
||||
addAllToQueue(items, startIndex)
|
||||
}
|
||||
|
||||
suspend fun setQueue(
|
||||
items: List<BaseItem>,
|
||||
shuffled: Boolean,
|
||||
) {
|
||||
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
|
||||
val mediaItems =
|
||||
items
|
||||
.filter { it.type == BaseItemKind.AUDIO }
|
||||
.map(::convert)
|
||||
withContext(Dispatchers.Main) {
|
||||
player.setMediaItems(mediaItems)
|
||||
player.shuffleModeEnabled = shuffled
|
||||
updateQueueSize()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addToQueue(
|
||||
item: BaseItem,
|
||||
index: Int? = null,
|
||||
) {
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
val mediaItem = convert(item)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (index != null) {
|
||||
player.addMediaItem(index, mediaItem)
|
||||
} else {
|
||||
player.addMediaItem(mediaItem)
|
||||
}
|
||||
updateQueueSize()
|
||||
if (player.mediaItemCount == 1) {
|
||||
// Start playing if this was the first time added
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addAllToQueue(
|
||||
list: BlockingList<BaseItem?>,
|
||||
startIndex: Int,
|
||||
) = loading {
|
||||
var remaining = startIndex
|
||||
list.indices
|
||||
.chunked(25)
|
||||
.forEach {
|
||||
val mediaItems =
|
||||
it.mapNotNull {
|
||||
if (remaining == 0) {
|
||||
list
|
||||
.getBlocking(it)
|
||||
?.takeIf { it.type == BaseItemKind.AUDIO }
|
||||
?.let(::convert)
|
||||
} else {
|
||||
Timber.v("Skipping $remaining")
|
||||
remaining--
|
||||
null
|
||||
}
|
||||
}
|
||||
onMain { player.addMediaItems(mediaItems) }
|
||||
}
|
||||
updateQueueSize()
|
||||
}
|
||||
|
||||
private fun convert(audio: BaseItem): MediaItem {
|
||||
val url =
|
||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||
itemId = audio.id,
|
||||
container =
|
||||
listOf(
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.MP3,
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.FLAC,
|
||||
),
|
||||
)
|
||||
val imageUrl =
|
||||
audio.data.albumId?.let { albumId ->
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = albumId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
)
|
||||
}
|
||||
return MediaItem
|
||||
.Builder()
|
||||
.setUri(url)
|
||||
.setMediaId(audio.id.toServerString())
|
||||
.setTag(AudioItem.from(audio, imageUrl))
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun updateQueueSize() {
|
||||
// val ids =
|
||||
// withContext(Dispatchers.Default) {
|
||||
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
|
||||
// }
|
||||
val timeline = onMain { player.currentTimeline }
|
||||
val window = Timeline.Window()
|
||||
val ids =
|
||||
(0..<timeline.windowCount)
|
||||
.map {
|
||||
timeline.getWindow(it, window)
|
||||
window.mediaItem.mediaId.toUUID()
|
||||
}.toSet()
|
||||
withContext(Dispatchers.Main) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
queueVersion = it.queueVersion + 1,
|
||||
queueSize = player.mediaItemCount,
|
||||
queuedIds = ids,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun moveQueue(
|
||||
index: Int,
|
||||
direction: MoveDirection,
|
||||
) = withContext(Dispatchers.Main) {
|
||||
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
|
||||
updateQueueSize()
|
||||
}
|
||||
|
||||
suspend fun moveQueue(
|
||||
index: Int,
|
||||
newIndex: Int,
|
||||
) = withContext(Dispatchers.Main) {
|
||||
player.moveMediaItem(index, newIndex)
|
||||
updateQueueSize()
|
||||
}
|
||||
|
||||
suspend fun playIndex(index: Int) {
|
||||
onMain {
|
||||
player.seekTo(index, 0L)
|
||||
player.play()
|
||||
}
|
||||
// MusicPlayerListener will update state
|
||||
}
|
||||
|
||||
suspend fun playNext(song: BaseItem) {
|
||||
val mediaItem = convert(song)
|
||||
onMain {
|
||||
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
|
||||
if (player.mediaItemCount == 1) {
|
||||
start()
|
||||
}
|
||||
}
|
||||
updateQueueSize()
|
||||
}
|
||||
|
||||
suspend fun removeFromQueue(index: Int) {
|
||||
onMain { player.removeMediaItem(index) }
|
||||
updateQueueSize()
|
||||
}
|
||||
|
||||
private suspend fun <T> loading(block: suspend () -> T): T {
|
||||
_state.update { it.copy(loadingState = LoadingState.Loading) }
|
||||
val result = block.invoke()
|
||||
_state.update { it.copy(loadingState = LoadingState.Success) }
|
||||
return result
|
||||
}
|
||||
|
||||
fun subscribe(): Job =
|
||||
api.webSocket
|
||||
.subscribe<PlaystateMessage>()
|
||||
.onEach { message ->
|
||||
message.data?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
when (it.command) {
|
||||
PlaystateCommand.STOP -> {
|
||||
stop()
|
||||
}
|
||||
|
||||
PlaystateCommand.PAUSE -> {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
PlaystateCommand.UNPAUSE -> {
|
||||
player.play()
|
||||
}
|
||||
|
||||
PlaystateCommand.NEXT_TRACK -> {
|
||||
player.seekToNext()
|
||||
}
|
||||
|
||||
PlaystateCommand.PREVIOUS_TRACK -> {
|
||||
player.seekToPrevious()
|
||||
}
|
||||
|
||||
PlaystateCommand.SEEK -> {
|
||||
it.seekPositionTicks?.ticks?.let {
|
||||
player.seekTo(
|
||||
it.inWholeMilliseconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PlaystateCommand.REWIND -> {
|
||||
player.seekBack(10.seconds)
|
||||
}
|
||||
|
||||
PlaystateCommand.FAST_FORWARD -> {
|
||||
player.seekForward(30.seconds)
|
||||
}
|
||||
|
||||
PlaystateCommand.PLAY_PAUSE -> {
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error in websocket subscription")
|
||||
}.launchIn(defaultScope)
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class MusicServiceState(
|
||||
val queueVersion: Long,
|
||||
val queueSize: Int,
|
||||
val currentIndex: Int,
|
||||
val currentItemId: UUID?,
|
||||
val status: NowPlayingStatus,
|
||||
val currentItemTitle: String?,
|
||||
val loadingState: LoadingState = LoadingState.Pending,
|
||||
val queuedIds: Set<UUID>,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY =
|
||||
MusicServiceState(
|
||||
0L,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
NowPlayingStatus.IDLE,
|
||||
null,
|
||||
LoadingState.Pending,
|
||||
emptySet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class NowPlayingStatus {
|
||||
PLAYING,
|
||||
PAUSED,
|
||||
IDLE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to [Player] events and updates the [StateFlow]
|
||||
*/
|
||||
private class MusicPlayerListener(
|
||||
private val player: Player,
|
||||
private val state: MutableStateFlow<MusicServiceState>,
|
||||
) : Player.Listener {
|
||||
init {
|
||||
Timber.v("MusicPlayerListener init")
|
||||
state.update {
|
||||
it.copy(
|
||||
queueSize = player.mediaItemCount,
|
||||
currentIndex = player.currentMediaItemIndex,
|
||||
status = if (player.isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.IDLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
Timber.v("MusicPlayerListener onIsPlayingChanged")
|
||||
state.update {
|
||||
it.copy(
|
||||
status = if (isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.PAUSED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
Timber.v("MusicPlayerListener onMediaItemTransition")
|
||||
updateCurrentIndex()
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(
|
||||
timeline: Timeline,
|
||||
reason: Int,
|
||||
) {
|
||||
// Timber.v("MusicPlayerListener onTimelineChanged")
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
|
||||
updateCurrentIndex()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentIndex() {
|
||||
state.update { state ->
|
||||
player.currentMediaItemIndex.takeIf { it >= 0 }?.let { currentMediaItemIndex ->
|
||||
if (currentMediaItemIndex in (0..<player.mediaItemCount)) {
|
||||
val item =
|
||||
player.getMediaItemAt(currentMediaItemIndex).localConfiguration?.tag as? AudioItem
|
||||
state.copy(
|
||||
currentIndex = currentMediaItemIndex,
|
||||
currentItemId = player.getMediaItemAt(currentMediaItemIndex).mediaId.toUUIDOrNull(),
|
||||
currentItemTitle = item?.title,
|
||||
)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
} ?: state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberQueue(
|
||||
player: Player,
|
||||
queueVersion: Long,
|
||||
queueSize: Int,
|
||||
): List<AudioItem> =
|
||||
remember(queueVersion, queueSize) {
|
||||
object : AbstractList<AudioItem>() {
|
||||
override val size: Int
|
||||
get() = player.mediaItemCount
|
||||
|
||||
override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
|
|
@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
|
@ -33,6 +36,7 @@ import timber.log.Timber
|
|||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@Singleton
|
||||
class NavDrawerService
|
||||
|
|
@ -44,6 +48,7 @@ class NavDrawerService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val musicService: MusicService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
||||
val state: StateFlow<NavDrawerItemState> = _state
|
||||
|
|
@ -73,6 +78,40 @@ class NavDrawerService
|
|||
.onEach { discoverActive ->
|
||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||
}.launchIn(coroutineScope)
|
||||
coroutineScope.launchDefault {
|
||||
musicService.state.collectLatest { music ->
|
||||
Timber.v("MusicService updated")
|
||||
when (music.status) {
|
||||
NowPlayingStatus.PLAYING -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
nowPlayingEnabled = true,
|
||||
nowPlayingTitle = music.currentItemTitle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NowPlayingStatus.IDLE -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
nowPlayingEnabled = false,
|
||||
nowPlayingTitle = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NowPlayingStatus.PAUSED -> {
|
||||
delay(2.hours)
|
||||
_state.update {
|
||||
it.copy(
|
||||
nowPlayingEnabled = false,
|
||||
nowPlayingTitle = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAllUserLibraries(
|
||||
|
|
@ -185,9 +224,11 @@ data class NavDrawerItemState(
|
|||
val items: List<NavDrawerItem>,
|
||||
val moreItems: List<NavDrawerItem>,
|
||||
val discoverEnabled: Boolean,
|
||||
val nowPlayingEnabled: Boolean,
|
||||
val nowPlayingTitle: String?,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
|
||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
|
|
@ -110,13 +111,14 @@ abstract class RecommendedViewModel(
|
|||
|
||||
fun update(
|
||||
@StringRes title: Int,
|
||||
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
block: suspend () -> List<BaseItem>,
|
||||
): Deferred<HomeRowLoadingState> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
val titleStr = context.getString(title)
|
||||
val row =
|
||||
try {
|
||||
HomeRowLoadingState.Success(titleStr, block.invoke())
|
||||
HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
|
||||
} catch (ex: Exception) {
|
||||
HomeRowLoadingState.Error(titleStr, null, ex)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionService
|
||||
import com.github.damontecres.wholphin.services.SuggestionsResource
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = RecommendedMusicViewModel.Factory::class)
|
||||
class RecommendedMusicViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val suggestionService: SuggestionService,
|
||||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
mediaReportService: MediaReportService,
|
||||
backdropService: BackdropService,
|
||||
mediaManagementService: MediaManagementService,
|
||||
) : RecommendedViewModel(
|
||||
context,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
backdropService,
|
||||
mediaManagementService,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(parentId: UUID): RecommendedMusicViewModel
|
||||
}
|
||||
|
||||
override val rows =
|
||||
MutableStateFlow<List<HomeRowLoadingState>>(
|
||||
rowTitles.keys.map {
|
||||
HomeRowLoadingState.Pending(
|
||||
context.getString(it),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
override fun init() {
|
||||
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||
val itemsPerRow =
|
||||
preferencesDataStore.data
|
||||
.firstOrNull()
|
||||
?.homePagePreferences
|
||||
?.maxItemsPerRow
|
||||
?: AppPreference.HomePageItems.defaultValue.toInt()
|
||||
|
||||
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||
val viewOptions =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
heightDp = Cards.HEIGHT_EPISODE,
|
||||
showTitles = true,
|
||||
)
|
||||
|
||||
update(R.string.recently_released, viewOptions) {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
recursive = true,
|
||||
enableUserData = true,
|
||||
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
startIndex = 0,
|
||||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.recently_added, viewOptions) {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
recursive = true,
|
||||
enableUserData = true,
|
||||
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
startIndex = 0,
|
||||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.top_unwatched, viewOptions) {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
recursive = true,
|
||||
enableUserData = true,
|
||||
isPlayed = false,
|
||||
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
startIndex = 0,
|
||||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}.also(jobs::add)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
suggestionService
|
||||
.getSuggestionsFlow(parentId, BaseItemKind.MUSIC_ALBUM)
|
||||
.collect { resource ->
|
||||
val state =
|
||||
when (resource) {
|
||||
is SuggestionsResource.Loading -> {
|
||||
HomeRowLoadingState.Loading(
|
||||
context.getString(R.string.suggestions),
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Success -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
resource.items,
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Empty -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
update(R.string.suggestions, state)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch suggestions")
|
||||
update(
|
||||
R.string.suggestions,
|
||||
HomeRowLoadingState.Error(
|
||||
title = context.getString(R.string.suggestions),
|
||||
exception = ex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0..<jobs.size) {
|
||||
val result = jobs[i].await()
|
||||
if (result.completed) {
|
||||
Timber.v("First success")
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
): HomeRowLoadingState {
|
||||
rows.update { current ->
|
||||
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val rowTitles =
|
||||
listOf(
|
||||
R.string.recently_released,
|
||||
R.string.recently_added,
|
||||
R.string.suggestions,
|
||||
R.string.top_unwatched,
|
||||
).mapIndexed { index, i -> i to index }.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecommendedMusic(
|
||||
preferences: UserPreferences,
|
||||
parentId: UUID,
|
||||
onFocusPosition: (RowColumn) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RecommendedMusicViewModel =
|
||||
hiltViewModel<RecommendedMusicViewModel, RecommendedMusicViewModel.Factory>(
|
||||
creationCallback = { it.create(parentId) },
|
||||
),
|
||||
) {
|
||||
RecommendedContent(
|
||||
preferences = preferences,
|
||||
viewModel = viewModel,
|
||||
onFocusPosition = onFocusPosition,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
||||
import com.github.damontecres.wholphin.ui.components.RecommendedMusic
|
||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
||||
import com.github.damontecres.wholphin.ui.data.AlbumSortOptions
|
||||
import com.github.damontecres.wholphin.ui.data.ArtistSortOptions
|
||||
import com.github.damontecres.wholphin.ui.data.SongSortOptions
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.logTab
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CollectionFolderMusicViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val musicService: MusicService,
|
||||
) : ViewModel() {
|
||||
fun play(item: BaseItem) {
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
viewModelScope.launchDefault {
|
||||
musicService.setQueue(listOf(item), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderMusic(
|
||||
preferences: UserPreferences,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CollectionFolderMusicViewModel = hiltViewModel(),
|
||||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val rememberedTabIndex =
|
||||
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||
|
||||
val tabs =
|
||||
listOf(
|
||||
stringResource(R.string.recommended),
|
||||
stringResource(R.string.albums),
|
||||
stringResource(R.string.artists),
|
||||
stringResource(R.string.genres),
|
||||
stringResource(R.string.songs),
|
||||
)
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||
|
||||
val firstTabFocusRequester = remember { FocusRequester() }
|
||||
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
|
||||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("music", selectedTabIndex)
|
||||
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||
preferencesViewModel.backdropService.clearBackdrop()
|
||||
}
|
||||
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.focusRequester(firstTabFocusRequester),
|
||||
tabs = tabs,
|
||||
onClick = { selectedTabIndex = it },
|
||||
focusRequesters = tabFocusRequesters,
|
||||
)
|
||||
}
|
||||
when (selectedTabIndex) {
|
||||
// Recommended
|
||||
0 -> {
|
||||
RecommendedMusic(
|
||||
preferences = preferences,
|
||||
parentId = destination.itemId,
|
||||
onFocusPosition = { pos ->
|
||||
showHeader = pos.row < 1
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
// Albums
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_albums",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
),
|
||||
),
|
||||
showTitle = false,
|
||||
recursive = true,
|
||||
sortOptions = AlbumSortOptions,
|
||||
defaultViewOptions = ViewOptionsSquare,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = true,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
// Artists
|
||||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_artists",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
|
||||
),
|
||||
),
|
||||
showTitle = false,
|
||||
recursive = true,
|
||||
sortOptions = ArtistSortOptions,
|
||||
defaultViewOptions = ViewOptionsSquare,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
// Genres
|
||||
3 -> {
|
||||
GenreCardGrid(
|
||||
itemId = destination.itemId,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
// Songs
|
||||
4 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
viewModel.play(item)
|
||||
},
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_songs",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
),
|
||||
),
|
||||
showTitle = false,
|
||||
recursive = true,
|
||||
sortOptions = SongSortOptions,
|
||||
defaultViewOptions = ViewOptionsSquare,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = true,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -170,6 +170,38 @@ fun buildMoreDialogItems(
|
|||
actions.onClickFavorite.invoke(item.id, !favorite)
|
||||
},
|
||||
)
|
||||
item.data.albumId?.let { albumId ->
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_album),
|
||||
R.string.fa_compact_disc,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.MediaItem(
|
||||
albumId,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_artist),
|
||||
R.string.fa_user,
|
||||
) {
|
||||
actions.navigateTo(
|
||||
Destination.MediaItem(
|
||||
artistId,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
seriesId?.let {
|
||||
add(
|
||||
DialogItem(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
|
|
@ -16,11 +17,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
|
@ -44,8 +45,8 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -60,12 +61,19 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.MusicServiceState
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -73,72 +81,92 @@ 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.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
@HiltViewModel
|
||||
@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
|
||||
class PlaylistViewModel
|
||||
@Inject
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
navigationManager: NavigationManager,
|
||||
musicService: MusicService,
|
||||
mediaManagementService: MediaManagementService,
|
||||
private val backdropService: BackdropService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
) : ItemViewModel(api) {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
||||
val filterAndSort =
|
||||
MutableStateFlow<FilterAndSort>(
|
||||
FilterAndSort(
|
||||
filter = GetItemsFilter(),
|
||||
sortAndDirection =
|
||||
SortAndDirection(
|
||||
ItemSortBy.DEFAULT,
|
||||
SortOrder.ASCENDING,
|
||||
),
|
||||
),
|
||||
)
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: UUID,
|
||||
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: UUID): PlaylistViewModel
|
||||
}
|
||||
|
||||
fun init(playlistId: UUID) {
|
||||
loading.value = LoadingState.Loading
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
|
||||
) {
|
||||
val playlist = fetchItem(playlistId)
|
||||
val state = MutableStateFlow(PlaylistDetailsState())
|
||||
val musicState = musicService.state
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
val playlist =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId)
|
||||
.content
|
||||
.let { BaseItem(it, false) }
|
||||
state.update { it.copy(playlist = playlist) }
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
libraryDisplayInfoDao.getItem(user, itemId)
|
||||
|
|
@ -149,23 +177,28 @@ class PlaylistViewModel
|
|||
ItemSortBy.DEFAULT,
|
||||
SortOrder.ASCENDING,
|
||||
)
|
||||
loadItems(filter, sortAndDirection)
|
||||
loadItems(filter, sortAndDirection).join()
|
||||
determineMediaType()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching playlist %s", itemId)
|
||||
state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadItems(
|
||||
filter: GetItemsFilter,
|
||||
sortAndDirection: SortAndDirection,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
) = viewModelScope.launchIO {
|
||||
backdropService.clearBackdrop()
|
||||
loading.setValueOnMain(LoadingState.Loading)
|
||||
this@PlaylistViewModel.filterAndSort.update {
|
||||
FilterAndSort(filter, sortAndDirection)
|
||||
state.update {
|
||||
it.copy(
|
||||
loading = LoadingState.Loading,
|
||||
filterAndSort = FilterAndSort(filter, sortAndDirection),
|
||||
)
|
||||
}
|
||||
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val playlistId = item.value!!.id
|
||||
viewModelScope.launchIO {
|
||||
val libraryDisplayInfo =
|
||||
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
||||
|
|
@ -175,7 +208,7 @@ class PlaylistViewModel
|
|||
)
|
||||
?: LibraryDisplayInfo(
|
||||
userId = user.rowId,
|
||||
itemId = itemId,
|
||||
itemId = itemId.toServerString(),
|
||||
sort = sortAndDirection.sort,
|
||||
direction = sortAndDirection.direction,
|
||||
filter = filter,
|
||||
|
|
@ -187,7 +220,7 @@ class PlaylistViewModel
|
|||
val request =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = playlistId,
|
||||
parentId = itemId,
|
||||
userId = user.id,
|
||||
fields = DefaultItemFields,
|
||||
sortBy = listOf(sortAndDirection.sort),
|
||||
|
|
@ -202,27 +235,77 @@ class PlaylistViewModel
|
|||
GetItemsRequestHandler,
|
||||
viewModelScope,
|
||||
).init()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.value = pager
|
||||
loading.value = LoadingState.Success
|
||||
state.update {
|
||||
it.copy(
|
||||
items = pager,
|
||||
loading = LoadingState.Success,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching playlist %s", itemId)
|
||||
withContext(Dispatchers.Main) {
|
||||
items.value = listOf()
|
||||
loading.value = LoadingState.Error(ex)
|
||||
state.update {
|
||||
it.copy(
|
||||
items = emptyList(),
|
||||
loading = LoadingState.Error(ex),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method tries to determine the [MediaType] of a playlist
|
||||
*
|
||||
* In theory, the server will set the type, but sometime it doesn't
|
||||
*/
|
||||
private suspend fun determineMediaType() {
|
||||
// Use the type the server says
|
||||
var mediaType =
|
||||
state.value.playlist
|
||||
?.data
|
||||
?.mediaType ?: MediaType.UNKNOWN
|
||||
mediaType =
|
||||
if (mediaType == MediaType.UNKNOWN) {
|
||||
// Otherwise, if a most of the list is one type, we can assume that type
|
||||
val pager = (state.value.items as? ApiRequestPager<*>)
|
||||
if (pager != null && pager.size <= 50) {
|
||||
val types =
|
||||
(0..<50.coerceAtMost(pager.size)).groupBy { index ->
|
||||
val pagerItem = pager.getBlocking(index)
|
||||
when (pagerItem?.type) {
|
||||
BaseItemKind.AUDIO -> MediaType.AUDIO
|
||||
|
||||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.EPISODE,
|
||||
BaseItemKind.MOVIE,
|
||||
BaseItemKind.BOX_SET,
|
||||
-> MediaType.VIDEO
|
||||
|
||||
else -> MediaType.UNKNOWN
|
||||
}
|
||||
}
|
||||
if (types.keys.size == 1) {
|
||||
types.keys.first()
|
||||
} else {
|
||||
MediaType.UNKNOWN
|
||||
}
|
||||
} else {
|
||||
MediaType.UNKNOWN
|
||||
}
|
||||
} else {
|
||||
mediaType
|
||||
}
|
||||
Timber.d("mediaType=%s", mediaType)
|
||||
state.update {
|
||||
it.copy(mediaType = mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
FilterUtils.getFilterOptionValues(
|
||||
api,
|
||||
serverRepository.currentUser.value?.id,
|
||||
itemUuid,
|
||||
itemId,
|
||||
filterOption,
|
||||
)
|
||||
|
||||
|
|
@ -231,6 +314,24 @@ class PlaylistViewModel
|
|||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setWatched(
|
||||
itemId: UUID,
|
||||
played: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setWatched(itemId, played)
|
||||
}
|
||||
|
||||
fun setFavorite(
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
}
|
||||
|
||||
fun sendMediaReport(itemId: UUID) {
|
||||
viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) }
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
@ -239,52 +340,111 @@ data class FilterAndSort(
|
|||
val sortAndDirection: SortAndDirection,
|
||||
)
|
||||
|
||||
data class PlaylistDetailsState(
|
||||
val playlist: BaseItem? = null,
|
||||
val mediaType: MediaType = MediaType.UNKNOWN,
|
||||
val items: List<BaseItem?> = emptyList(),
|
||||
val filterAndSort: FilterAndSort =
|
||||
FilterAndSort(
|
||||
filter = GetItemsFilter(),
|
||||
sortAndDirection =
|
||||
SortAndDirection(
|
||||
ItemSortBy.DEFAULT,
|
||||
SortOrder.ASCENDING,
|
||||
),
|
||||
),
|
||||
val loading: LoadingState = LoadingState.Pending,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetails(
|
||||
preferences: UserPreferences,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlaylistViewModel = hiltViewModel(),
|
||||
viewModel: PlaylistViewModel =
|
||||
hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>(
|
||||
creationCallback = { it.create(destination.itemId) },
|
||||
),
|
||||
addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(destination.itemId)
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val playlist by viewModel.item.observeAsState(null)
|
||||
val items by viewModel.items.observeAsState(listOf())
|
||||
val filterAndSort by viewModel.filterAndSort.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
val musicState by viewModel.musicState.collectAsState()
|
||||
|
||||
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
val goToString = stringResource(R.string.go_to)
|
||||
val playFromHereString = stringResource(R.string.play_from_here)
|
||||
|
||||
PlaylistDetailsContent(
|
||||
loadingState = loading,
|
||||
playlist = playlist,
|
||||
items = items,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
onClickIndex = { index, _ ->
|
||||
fun play(
|
||||
index: Int,
|
||||
item: BaseItem,
|
||||
shuffle: Boolean,
|
||||
mediaTypeOverride: MediaType? = null,
|
||||
) {
|
||||
when (mediaTypeOverride ?: state.mediaType) {
|
||||
MediaType.VIDEO -> {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = destination.itemId,
|
||||
startIndex = index,
|
||||
shuffle = false,
|
||||
filter = filterAndSort.filter,
|
||||
sortAndDirection = filterAndSort.sortAndDirection,
|
||||
shuffle = shuffle,
|
||||
filter = state.filterAndSort.filter,
|
||||
sortAndDirection = state.filterAndSort.sortAndDirection,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
MediaType.AUDIO -> {
|
||||
viewModel.play(item, index, shuffle)
|
||||
}
|
||||
|
||||
else -> {
|
||||
showConfirmTypeDialog = Triple(index, item, shuffle)
|
||||
}
|
||||
}
|
||||
}
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val musicMoreActions =
|
||||
MusicMoreDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
|
||||
onClickPlayNext = { index, item -> viewModel.playNext(item) },
|
||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
|
||||
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||
onClickAddPlaylist = { itemId ->
|
||||
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onClickRemoveFromQueue = {},
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
)
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
|
||||
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||
onClickAddPlaylist = { itemId ->
|
||||
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel::sendMediaReport,
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
)
|
||||
|
||||
PlaylistDetailsContent(
|
||||
loadingState = state.loading,
|
||||
playlist = state.playlist,
|
||||
items = state.items,
|
||||
musicState = musicState,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
onClickIndex = { index, item ->
|
||||
play(index, item, false)
|
||||
},
|
||||
onClickPlay = { shuffle ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = destination.itemId,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
filter = filterAndSort.filter,
|
||||
sortAndDirection = filterAndSort.sortAndDirection,
|
||||
),
|
||||
)
|
||||
state.playlist?.let {
|
||||
play(0, it, shuffle)
|
||||
}
|
||||
},
|
||||
onLongClickIndex = { index, item ->
|
||||
longClickDialog =
|
||||
|
|
@ -292,31 +452,30 @@ fun PlaylistDetails(
|
|||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
goToString,
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
DialogItem(
|
||||
playFromHereString,
|
||||
Icons.Default.PlayArrow,
|
||||
) {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = destination.itemId,
|
||||
startIndex = index,
|
||||
shuffle = false,
|
||||
filter = filterAndSort.filter,
|
||||
sortAndDirection = filterAndSort.sortAndDirection,
|
||||
),
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = musicMoreActions,
|
||||
item = item,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
)
|
||||
} else {
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
item = item,
|
||||
seriesId = item.data.seriesId,
|
||||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions = moreActions,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
filterAndSort = filterAndSort,
|
||||
filterAndSort = state.filterAndSort,
|
||||
onFilterAndSortChange = viewModel::loadItems,
|
||||
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
||||
modifier = modifier,
|
||||
|
|
@ -327,12 +486,46 @@ fun PlaylistDetails(
|
|||
onDismissRequest = { longClickDialog = null },
|
||||
)
|
||||
}
|
||||
showConfirmTypeDialog?.let { (index, item, shuffle) ->
|
||||
ConfirmMediaTypeDialog(
|
||||
onConfirm = { mediaType -> play(index, item, shuffle, mediaType) },
|
||||
onCancel = { showConfirmTypeDialog = null },
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
PlaylistDialog(
|
||||
title = stringResource(R.string.add_to_playlist),
|
||||
state = addPlaylistState,
|
||||
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||
onClick = {
|
||||
addToPlaylistViewModel.addToPlaylist(it.id, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
createEnabled = true,
|
||||
onCreatePlaylist = {
|
||||
addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailsContent(
|
||||
playlist: BaseItem?,
|
||||
items: List<BaseItem?>,
|
||||
musicState: MusicServiceState,
|
||||
onClickIndex: (Int, BaseItem) -> Unit,
|
||||
onLongClickIndex: (Int, BaseItem) -> Unit,
|
||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||
|
|
@ -449,10 +642,18 @@ fun PlaylistDetailsContent(
|
|||
onLongClickIndex.invoke(index, item)
|
||||
}
|
||||
},
|
||||
isPlaying =
|
||||
equalsNotNull(
|
||||
musicState.currentItemId,
|
||||
item?.id,
|
||||
),
|
||||
isQueued = item?.id in musicState.queuedIds,
|
||||
modifier =
|
||||
Modifier
|
||||
.height(80.dp)
|
||||
.ifElse(
|
||||
item?.type != BaseItemKind.AUDIO,
|
||||
Modifier.height(80.dp),
|
||||
).ifElse(
|
||||
index == savedIndex,
|
||||
Modifier.focusRequester(focus),
|
||||
).onFocusChanged {
|
||||
|
|
@ -582,6 +783,8 @@ fun PlaylistItem(
|
|||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
isPlaying: Boolean = false,
|
||||
isQueued: Boolean = false,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
ListItem(
|
||||
|
|
@ -592,14 +795,12 @@ fun PlaylistItem(
|
|||
headlineContent = {
|
||||
Text(
|
||||
text = item?.title ?: "",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = item?.subtitle ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
)
|
||||
},
|
||||
|
|
@ -615,12 +816,14 @@ fun PlaylistItem(
|
|||
Text(
|
||||
text = duration.toString(),
|
||||
)
|
||||
if (item.type != BaseItemKind.AUDIO) {
|
||||
Text(
|
||||
text = stringResource(R.string.ends_at, endTimeStr),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Row(
|
||||
|
|
@ -631,6 +834,12 @@ fun PlaylistItem(
|
|||
text = "${index + 1}.",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
if (item?.type == BaseItemKind.AUDIO) {
|
||||
MusicQueueMarker(
|
||||
isPlaying = isPlaying,
|
||||
isQueued = isQueued,
|
||||
)
|
||||
} else {
|
||||
ItemCardImage(
|
||||
item = item,
|
||||
name = item?.name,
|
||||
|
|
@ -644,7 +853,52 @@ fun PlaylistItem(
|
|||
useFallbackText = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmMediaTypeDialog(
|
||||
onConfirm: (MediaType) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(),
|
||||
elevation = 3.dp,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.play_as_type),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillParentMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(
|
||||
stringRes = R.string.audio,
|
||||
onClick = { onConfirm.invoke(MediaType.AUDIO) },
|
||||
)
|
||||
TextButton(
|
||||
stringRes = R.string.video,
|
||||
onClick = { onConfirm.invoke(MediaType.VIDEO) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,721 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class)
|
||||
class AlbumViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
api: ApiClient,
|
||||
musicService: MusicService,
|
||||
navigationManager: NavigationManager,
|
||||
mediaManagementService: MediaManagementService,
|
||||
val serverRepository: ServerRepository,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
@Assisted itemId: UUID,
|
||||
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: UUID): AlbumViewModel
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(AlbumState.EMPTY)
|
||||
val state: StateFlow<AlbumState> = _state
|
||||
|
||||
val currentMusic = musicService.state
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val itemDeferred =
|
||||
async {
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = itemId)
|
||||
.content
|
||||
.let { BaseItem(it, false) }
|
||||
}
|
||||
val songsDeferred = async { getPagerForAlbum(api, itemId) }
|
||||
val album = itemDeferred.await()
|
||||
val songs = songsDeferred.await()
|
||||
val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY)
|
||||
val allArtists =
|
||||
album.data.artists.orEmpty() +
|
||||
album.data.albumArtists
|
||||
?.map { it.name }
|
||||
.orEmpty()
|
||||
val isVariousArtists =
|
||||
allArtists.firstOrNull { it?.lowercase() == "various artists" } != null ||
|
||||
album.data.artists
|
||||
.orEmpty()
|
||||
.size > 1
|
||||
_state.update {
|
||||
it.copy(
|
||||
album = album,
|
||||
isVariousArtists = isVariousArtists,
|
||||
imageUrl = imageUrl,
|
||||
songs = songs,
|
||||
loading = LoadingState.Success,
|
||||
)
|
||||
}
|
||||
updateBackDrop()
|
||||
if (state.value.similar.isEmpty()) {
|
||||
viewModelScope.launchIO {
|
||||
val similar =
|
||||
api.libraryApi
|
||||
.getSimilarItems(
|
||||
GetSimilarItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
itemId = itemId,
|
||||
excludeArtistIds = album.data.albumArtists?.map { it.id },
|
||||
fields = SlimItemFields,
|
||||
limit = 25,
|
||||
),
|
||||
).content.items
|
||||
.map { BaseItem.from(it, api) }
|
||||
_state.update { it.copy(similar = similar) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
albumIds = listOf(itemId),
|
||||
parentId = null,
|
||||
fields = DefaultItemFields,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO),
|
||||
)
|
||||
val musicVideos =
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
if (musicVideos.isNotEmpty()) {
|
||||
_state.update {
|
||||
it.copy(musicVideos = musicVideos)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackDrop() {
|
||||
state.value.album?.let { album ->
|
||||
viewModelScope.launchDefault {
|
||||
getBackdropItemForAlbum(api, album)?.let {
|
||||
backdropService.submit(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
val album =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = itemId)
|
||||
.content
|
||||
.let { BaseItem(it, false) }
|
||||
_state.update { it.copy(album = album) }
|
||||
}
|
||||
|
||||
fun play(
|
||||
shuffled: Boolean,
|
||||
startIndex: Int = 0,
|
||||
) {
|
||||
val songs = state.value.songs as ApiRequestPager<*>
|
||||
play(songs, startIndex, shuffled)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBackdropItemForAlbum(
|
||||
api: ApiClient,
|
||||
album: BaseItem,
|
||||
): BaseItem? {
|
||||
if (album.data.backdropImageTags?.isNotEmpty() == true) {
|
||||
return album
|
||||
} else {
|
||||
val artistIds =
|
||||
album.data.albumArtists
|
||||
?.shuffled()
|
||||
?.take(50)
|
||||
?.map { it.id }
|
||||
return api.itemsApi
|
||||
.getItems(
|
||||
ids = artistIds,
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
?.let { BaseItem(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
data class AlbumState(
|
||||
val album: BaseItem?,
|
||||
val isVariousArtists: Boolean,
|
||||
val imageUrl: String?,
|
||||
val songs: List<BaseItem?>,
|
||||
val similar: List<BaseItem>,
|
||||
val loading: LoadingState,
|
||||
val musicVideos: List<BaseItem?> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
private const val SONG_ROW = HEADER_ROW + 1
|
||||
private const val MUSIC_VIDEO_ROW = SONG_ROW + 1
|
||||
private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun AlbumDetailsPage(
|
||||
itemId: UUID,
|
||||
preferences: UserPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AlbumViewModel =
|
||||
hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>(
|
||||
creationCallback = { it.create(itemId) },
|
||||
),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by viewModel.state.collectAsState()
|
||||
val currentMusic by viewModel.currentMusic.collectAsState()
|
||||
|
||||
var position by rememberPosition(0, 0)
|
||||
val focusRequesters =
|
||||
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val moreDialogActions =
|
||||
remember {
|
||||
MusicMoreDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, _ -> viewModel.play(false, index) },
|
||||
onClickPlayNext = { _, song -> viewModel.playNext(song) },
|
||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
||||
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onClickRemoveFromQueue = {},
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
)
|
||||
}
|
||||
when (val loading = state.loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(loading, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
val album = state.album!!
|
||||
|
||||
val firstFocusRequester = remember { FocusRequester() }
|
||||
val firstBringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val listState = rememberLazyListState()
|
||||
val itemsBefore = 2
|
||||
|
||||
val songFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (position.row == SONG_ROW) {
|
||||
songFocusRequester.tryRequestFocus()
|
||||
} else {
|
||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||
}
|
||||
viewModel.updateBackDrop()
|
||||
}
|
||||
val backHandlerActive by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > itemsBefore
|
||||
}
|
||||
}
|
||||
BackHandler(backHandlerActive) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(itemsBefore)
|
||||
firstBringIntoViewRequester.bringIntoView()
|
||||
firstFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(bringIntoViewRequester)
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
AlbumHeader(
|
||||
album = album,
|
||||
imageUrl = state.imageUrl,
|
||||
overviewOnClick = {},
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MusicExpandableButtons(
|
||||
actions =
|
||||
remember {
|
||||
MusicButtonActions(
|
||||
onClickPlay = { viewModel.play(it, 0) },
|
||||
onClickInstantMix = { viewModel.startInstantMix(album.id) },
|
||||
onClickFavorite = {
|
||||
viewModel.setFavorite(
|
||||
album.id,
|
||||
!album.favorite,
|
||||
)
|
||||
},
|
||||
onClickMore = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = album.name + " (${album.data.productionYear ?: ""})",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = album,
|
||||
index = 0,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
album,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
favorite = album.favorite,
|
||||
buttonOnFocusChanged = {
|
||||
if (it.isFocused) {
|
||||
position = RowColumn(HEADER_ROW, 0)
|
||||
scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||
}.focusRequester(focusRequesters[HEADER_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.songs) + " (${state.songs.size})",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
itemsIndexed(state.songs) { index, song ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SongListItem(
|
||||
song = song,
|
||||
onClick = {
|
||||
position = RowColumn(SONG_ROW, index)
|
||||
viewModel.play(false, index)
|
||||
},
|
||||
onLongClick = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = true,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClickMore = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
showArtist = state.isVariousArtists,
|
||||
isPlaying = song != null && currentMusic.currentItemId == song.id,
|
||||
isQueued = song != null && song.id in currentMusic.queuedIds,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.75f)
|
||||
.ifElse(
|
||||
index == 0,
|
||||
Modifier
|
||||
.focusRequester(firstFocusRequester)
|
||||
.bringIntoViewRequester(firstBringIntoViewRequester),
|
||||
).ifElse(
|
||||
position.row == SONG_ROW && position.column == index,
|
||||
Modifier.focusRequester(songFocusRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.musicVideos.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.music_videos),
|
||||
items = state.musicVideos,
|
||||
onClickItem = { index, item ->
|
||||
position = RowColumn(MUSIC_VIDEO_ROW, index)
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
// TODO
|
||||
},
|
||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
BannerCardWithTitle(
|
||||
title = item?.name,
|
||||
subtitle = item?.data?.productionYear?.toString(),
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
aspectRatio = AspectRatios.WIDE,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.similar.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.more_like_this),
|
||||
items = state.similar,
|
||||
onClickItem = { index, item ->
|
||||
position = RowColumn(SIMILAR_ROW, index)
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = item,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
item,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
BannerCardWithTitle(
|
||||
title = item?.name,
|
||||
subtitle = item?.data?.productionYear?.toString(),
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
PlaylistDialog(
|
||||
title = stringResource(R.string.add_to_playlist),
|
||||
state = playlistState,
|
||||
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||
onClick = {
|
||||
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
createEnabled = true,
|
||||
onCreatePlaylist = {
|
||||
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AlbumHeader(
|
||||
album: BaseItem,
|
||||
imageUrl: String?,
|
||||
overviewOnClick: () -> Unit,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier.padding(top = 32.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.20f)
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Artist
|
||||
Text(
|
||||
text = album.artistsString ?: "",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.75f)
|
||||
.padding(start = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = album.name ?: "",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.75f)
|
||||
.padding(start = 8.dp),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
QuickDetails(
|
||||
album.ui.quickDetails,
|
||||
null,
|
||||
Modifier.padding(start = 8.dp),
|
||||
)
|
||||
|
||||
album.data.genres?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
// Description
|
||||
album.data.overview?.let { overview ->
|
||||
OverviewText(
|
||||
overview = overview,
|
||||
maxLines = 3,
|
||||
onClick = overviewOnClick,
|
||||
textBoxHeight = Dp.Unspecified,
|
||||
modifier =
|
||||
Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,750 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.Optional
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = ArtistViewModel.Factory::class)
|
||||
class ArtistViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
api: ApiClient,
|
||||
musicService: MusicService,
|
||||
navigationManager: NavigationManager,
|
||||
mediaManagementService: MediaManagementService,
|
||||
val serverRepository: ServerRepository,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
@Assisted itemId: UUID,
|
||||
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: UUID): ArtistViewModel
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(ArtistState.EMPTY)
|
||||
val state: StateFlow<ArtistState> = _state
|
||||
|
||||
val currentMusic = musicService.state
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val itemDeferred =
|
||||
async {
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = itemId)
|
||||
.content
|
||||
.let { BaseItem(it, false) }
|
||||
}
|
||||
val albumsDeferred =
|
||||
async {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = itemId,
|
||||
fields = DefaultItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
sortBy =
|
||||
listOf(
|
||||
ItemSortBy.PREMIERE_DATE,
|
||||
ItemSortBy.SORT_NAME,
|
||||
),
|
||||
sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING),
|
||||
)
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||
}
|
||||
val artist = itemDeferred.await()
|
||||
val albums = albumsDeferred.await()
|
||||
val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY)
|
||||
_state.update {
|
||||
it.copy(
|
||||
artist = artist,
|
||||
imageUrl = imageUrl,
|
||||
albums = albums,
|
||||
loading = LoadingState.Success,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launchIO {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
artistIds = listOf(itemId),
|
||||
fields = DefaultItemFields,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
minCommunityRating = 1.0,
|
||||
sortBy =
|
||||
listOf(
|
||||
ItemSortBy.COMMUNITY_RATING,
|
||||
),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
limit = 10,
|
||||
)
|
||||
val topSongs =
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
if (topSongs.isNotEmpty()) {
|
||||
_state.update {
|
||||
it.copy(topSongs = topSongs)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.value.similar.isEmpty()) {
|
||||
viewModelScope.launchIO {
|
||||
val similar =
|
||||
api.libraryApi
|
||||
.getSimilarItems(
|
||||
GetSimilarItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
itemId = itemId,
|
||||
excludeArtistIds = listOf(itemId),
|
||||
fields = SlimItemFields,
|
||||
limit = 25,
|
||||
),
|
||||
).content.items
|
||||
.map { BaseItem.from(it, api) }
|
||||
_state.update { it.copy(similar = similar) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
artistIds = listOf(itemId),
|
||||
parentId = null,
|
||||
fields = DefaultItemFields,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO),
|
||||
)
|
||||
val musicVideos =
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
if (musicVideos.isNotEmpty()) {
|
||||
_state.update {
|
||||
it.copy(musicVideos = musicVideos)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launchDefault {
|
||||
state.value.artist?.let {
|
||||
backdropService.submit(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
val artist =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = itemId)
|
||||
.content
|
||||
.let { BaseItem(it, false) }
|
||||
_state.update { it.copy(artist = artist) }
|
||||
}
|
||||
}
|
||||
|
||||
data class ArtistState(
|
||||
val artist: BaseItem?,
|
||||
val imageUrl: String?,
|
||||
val topSongs: List<BaseItem?>,
|
||||
val albums: List<BaseItem?>,
|
||||
val similar: List<BaseItem>,
|
||||
val loading: LoadingState,
|
||||
val musicVideos: List<BaseItem?>,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY =
|
||||
ArtistState(
|
||||
null,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
LoadingState.Pending,
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
private const val SONG_ROW = HEADER_ROW + 1
|
||||
private const val ALBUM_ROW = SONG_ROW + 1
|
||||
private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1
|
||||
private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailsPage(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ArtistViewModel =
|
||||
hiltViewModel<ArtistViewModel, ArtistViewModel.Factory>(
|
||||
creationCallback = { it.create(itemId) },
|
||||
),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by viewModel.state.collectAsState()
|
||||
val currentMusic by viewModel.currentMusic.collectAsState()
|
||||
var position by rememberPosition(0, 0)
|
||||
val focusRequesters =
|
||||
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val moreDialogActions =
|
||||
remember {
|
||||
MusicMoreDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, item -> viewModel.play(item) },
|
||||
onClickPlayNext = { _, item -> viewModel.playNext(item) },
|
||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
||||
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onClickRemoveFromQueue = {},
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
)
|
||||
}
|
||||
|
||||
when (val loading = state.loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(loading, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
val artist = state.artist!!
|
||||
val songFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (position.row == SONG_ROW) {
|
||||
songFocusRequester.tryRequestFocus()
|
||||
} else {
|
||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||
}
|
||||
viewModel.refresh()
|
||||
}
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(bringIntoViewRequester)
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
ArtistHeader(
|
||||
artist = artist,
|
||||
imageUrl = state.imageUrl,
|
||||
overviewOnClick = {},
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MusicExpandableButtons(
|
||||
actions =
|
||||
remember {
|
||||
MusicButtonActions(
|
||||
onClickPlay = { shuffled ->
|
||||
viewModel.play(artist, shuffled = shuffled)
|
||||
},
|
||||
onClickInstantMix = { viewModel.startInstantMix(artist.id) },
|
||||
onClickFavorite = {
|
||||
viewModel.setFavorite(
|
||||
artist.id,
|
||||
!artist.favorite,
|
||||
)
|
||||
},
|
||||
onClickMore = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = artist.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = artist,
|
||||
index = 0,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
artist,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
favorite = artist.favorite,
|
||||
buttonOnFocusChanged = {
|
||||
if (it.isFocused) {
|
||||
position = RowColumn(HEADER_ROW, 0)
|
||||
scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||
}.focusRequester(focusRequesters[HEADER_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.topSongs.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.popular_songs),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
itemsIndexed(state.topSongs) { index, song ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SongListItem(
|
||||
song = song,
|
||||
onClick = {
|
||||
position = RowColumn(SONG_ROW, index)
|
||||
song?.let { viewModel.play(it) }
|
||||
},
|
||||
onLongClick = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = true,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClickMore = {
|
||||
if (song != null) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = song.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
song,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
showArtist = false,
|
||||
isPlaying = song != null && currentMusic.currentItemId == song.id,
|
||||
isQueued = song != null && song.id in currentMusic.queuedIds,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.75f)
|
||||
.ifElse(
|
||||
position.row == SONG_ROW && position.column == index,
|
||||
Modifier.focusRequester(songFocusRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.albums),
|
||||
items = state.albums,
|
||||
onClickItem = { index, album ->
|
||||
position = RowColumn(ALBUM_ROW, index)
|
||||
viewModel.navigationManager.navigateTo(album.destination())
|
||||
},
|
||||
onLongClickItem = { index, album ->
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = true,
|
||||
title = album.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = album,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
album,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
BannerCardWithTitle(
|
||||
title = album?.name,
|
||||
subtitle = album?.data?.productionYear?.toString(),
|
||||
item = album,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]),
|
||||
)
|
||||
}
|
||||
if (state.musicVideos.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.music_videos),
|
||||
items = state.musicVideos,
|
||||
onClickItem = { index, item ->
|
||||
position = RowColumn(MUSIC_VIDEO_ROW, index)
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
// TODO
|
||||
},
|
||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
BannerCardWithTitle(
|
||||
title = item?.name,
|
||||
subtitle = item?.data?.productionYear?.toString(),
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
aspectRatio = AspectRatios.WIDE,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.similar.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.more_like_this),
|
||||
items = state.similar,
|
||||
onClickItem = { index, item ->
|
||||
position = RowColumn(SIMILAR_ROW, index)
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = true,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
buildMoreDialogForMusic(
|
||||
context = context,
|
||||
actions = moreDialogActions,
|
||||
item = item,
|
||||
index = index,
|
||||
canRemove = false,
|
||||
canDelete =
|
||||
viewModel.canDelete(
|
||||
item,
|
||||
preferences.appPreferences,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
BannerCardWithTitle(
|
||||
title = item?.name,
|
||||
subtitle = item?.data?.productionYear?.toString(),
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
PlaylistDialog(
|
||||
title = stringResource(R.string.add_to_playlist),
|
||||
state = playlistState,
|
||||
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||
onClick = {
|
||||
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
createEnabled = true,
|
||||
onCreatePlaylist = {
|
||||
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistHeader(
|
||||
artist: BaseItem,
|
||||
imageUrl: String?,
|
||||
overviewOnClick: () -> Unit,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier.padding(top = 32.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.20f)
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Artist
|
||||
Text(
|
||||
text = artist.artistsString ?: "",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.75f)
|
||||
.padding(start = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = artist.name ?: "",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.75f)
|
||||
.padding(start = 8.dp),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
QuickDetails(
|
||||
artist.ui.quickDetails,
|
||||
null,
|
||||
Modifier.padding(start = 8.dp),
|
||||
)
|
||||
|
||||
artist.data.genres?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
// Description
|
||||
artist.data.overview?.let { overview ->
|
||||
OverviewText(
|
||||
overview = overview,
|
||||
maxLines = 3,
|
||||
onClick = overviewOnClick,
|
||||
textBoxHeight = Dp.Unspecified,
|
||||
modifier =
|
||||
Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
||||
@Composable
|
||||
fun BarVisualizer(
|
||||
data: IntArray,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.onSizeChanged { size = it },
|
||||
) {
|
||||
val size = with(LocalDensity.current) { DpSize(size.width.toDp(), size.height.toDp()) }
|
||||
val padding = 1.dp
|
||||
val width = size.width / data.size
|
||||
|
||||
data.forEachIndexed { index, data ->
|
||||
val height by animateDpAsState(
|
||||
targetValue = size.height * data / 256f,
|
||||
animationSpec = tween(easing = LinearEasing),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.height(height)
|
||||
.width(width)
|
||||
.padding(start = if (index == 0) 0.dp else padding)
|
||||
.background(MaterialTheme.colorScheme.border),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll
|
||||
import org.jellyfin.sdk.model.api.LyricDto
|
||||
import org.jellyfin.sdk.model.api.LyricLine
|
||||
|
||||
@Composable
|
||||
fun LyricsContent(
|
||||
lyricsHaveFocus: Boolean,
|
||||
lyrics: LyricDto?,
|
||||
currentLyricPosition: Int?,
|
||||
onClick: (LyricLine) -> Unit,
|
||||
onFocusLyrics: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequesters =
|
||||
remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } }
|
||||
val listState = rememberLazyListState(currentLyricPosition ?: 0)
|
||||
|
||||
val scrollConnection = rememberDelayedNestedScroll(yDelay = .66f)
|
||||
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
if (!lyricsHaveFocus) {
|
||||
LaunchedEffect(currentLyricPosition) {
|
||||
if (currentLyricPosition != null) {
|
||||
listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let {
|
||||
if (currentLyricPosition !in listState.firstVisibleItemIndex..it) {
|
||||
listState.animateScrollToItem(currentLyricPosition)
|
||||
}
|
||||
}
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.nestedScroll(scrollConnection),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusProperties {
|
||||
onEnter = {
|
||||
if (currentLyricPosition != null) {
|
||||
currentLyricPosition.let {
|
||||
focusRequesters
|
||||
.getOrNull(currentLyricPosition)
|
||||
?.tryRequestFocus()
|
||||
}
|
||||
} else {
|
||||
focusRequesters.getOrNull(0)?.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
}.onFocusChanged {
|
||||
onFocusLyrics.invoke(it.hasFocus)
|
||||
},
|
||||
) {
|
||||
if (lyrics?.lyrics?.isNotEmpty() == true) {
|
||||
itemsIndexed(lyrics.lyrics) { index, lyric ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val color by animateColorAsState(
|
||||
if (index == currentLyricPosition || currentLyricPosition == null) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = .4f)
|
||||
},
|
||||
animationSpec = tween(durationMillis = 500, easing = LinearEasing),
|
||||
)
|
||||
Surface(
|
||||
onClick = { onClick.invoke(lyric) },
|
||||
interactionSource = interactionSource,
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.border.copy(alpha = .33f),
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
|
||||
scale =
|
||||
ClickableSurfaceDefaults.scale(
|
||||
focusedScale = 1f,
|
||||
pressedScale = .9f,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index]),
|
||||
) {
|
||||
val text =
|
||||
remember(lyric.text) { lyric.text.ifBlank { " " } }
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (focused) MaterialTheme.colorScheme.onSurface else color,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.ifElse(
|
||||
index == currentLyricPosition,
|
||||
Modifier.bringIntoViewRequester(bringIntoViewRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusState
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Composable
|
||||
fun MusicExpandableButtons(
|
||||
actions: MusicButtonActions,
|
||||
favorite: Boolean,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
item("play") {
|
||||
ExpandablePlayButton(
|
||||
title = R.string.play,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
onClick = { actions.onClickPlay.invoke(false) },
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
item("shuffle") {
|
||||
ExpandableFaButton(
|
||||
title = R.string.shuffle,
|
||||
iconStringRes = R.string.fa_shuffle,
|
||||
onClick = { actions.onClickPlay.invoke(true) },
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
item("instant_mix") {
|
||||
ExpandableFaButton(
|
||||
title = R.string.instant_mix,
|
||||
iconStringRes = R.string.fa_compass,
|
||||
onClick = actions.onClickInstantMix,
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
item("favorite") {
|
||||
ExpandableFaButton(
|
||||
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||
iconStringRes = R.string.fa_heart,
|
||||
onClick = actions.onClickFavorite,
|
||||
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
item("more") {
|
||||
ExpandablePlayButton(
|
||||
title = R.string.more,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.MoreVert,
|
||||
onClick = { actions.onClickMore.invoke() },
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MusicButtonActions(
|
||||
val onClickPlay: (shuffle: Boolean) -> Unit,
|
||||
val onClickInstantMix: () -> Unit,
|
||||
val onClickFavorite: () -> Unit,
|
||||
val onClickMore: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||
|
||||
fun getMusicPreferences() =
|
||||
listOf(
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_album_cover,
|
||||
defaultValue = false,
|
||||
getter = { it.musicPreferences.showAlbumArt },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateMusicPreferences { showAlbumArt = value }
|
||||
},
|
||||
),
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_visualizer,
|
||||
defaultValue = false,
|
||||
getter = { it.musicPreferences.showVisualizer },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateMusicPreferences { showVisualizer = value }
|
||||
},
|
||||
),
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_backdrop,
|
||||
defaultValue = false,
|
||||
getter = { it.musicPreferences.showBackdrop },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateMusicPreferences { showBackdrop = value }
|
||||
},
|
||||
),
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_lyrics,
|
||||
defaultValue = false,
|
||||
getter = { it.musicPreferences.showLyrics },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateMusicPreferences { showLyrics = value }
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.BlockingList
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
abstract class MusicViewModel(
|
||||
internal val itemId: UUID,
|
||||
internal val context: Context,
|
||||
internal val api: ApiClient,
|
||||
internal val musicService: MusicService,
|
||||
internal val navigationManager: NavigationManager,
|
||||
internal val mediaManagementService: MediaManagementService,
|
||||
) : ViewModel() {
|
||||
fun play(
|
||||
pager: ApiRequestPager<*>,
|
||||
startIndex: Int = 0,
|
||||
shuffled: Boolean = false,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
musicService.setQueue(pager, startIndex, shuffled)
|
||||
}
|
||||
}
|
||||
|
||||
fun play(
|
||||
item: BaseItem,
|
||||
startIndex: Int = 0,
|
||||
shuffled: Boolean = false,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("Playing %s %s", item.type, item.id)
|
||||
when (item.type) {
|
||||
BaseItemKind.AUDIO -> {
|
||||
musicService.setQueue(listOf(item), shuffled)
|
||||
}
|
||||
|
||||
BaseItemKind.MUSIC_ALBUM -> {
|
||||
val pager = getPagerForAlbum(api, item.id)
|
||||
musicService.setQueue(pager, startIndex, shuffled)
|
||||
}
|
||||
|
||||
BaseItemKind.MUSIC_ARTIST -> {
|
||||
val pager = getPagerForArtist(api, item.id)
|
||||
musicService.setQueue(pager, startIndex, shuffled)
|
||||
}
|
||||
|
||||
BaseItemKind.PLAYLIST -> {
|
||||
val pager = getPagerForPlaylist(api, item.id)
|
||||
musicService.setQueue(pager, startIndex, shuffled)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.w("Unknown item type to play for music: %s", item.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playNext(song: BaseItem) {
|
||||
viewModelScope.launchDefault {
|
||||
musicService.playNext(song)
|
||||
}
|
||||
}
|
||||
|
||||
fun addToQueue(
|
||||
item: BaseItem,
|
||||
index: Int,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("addToQueue %s %s", item.type, item.id)
|
||||
when (item.type) {
|
||||
BaseItemKind.AUDIO -> {
|
||||
musicService.addAllToQueue(BlockingList.of(listOf(item)), 0)
|
||||
}
|
||||
|
||||
BaseItemKind.MUSIC_ALBUM -> {
|
||||
val pager = getPagerForAlbum(api, item.id)
|
||||
musicService.addAllToQueue(pager, 0)
|
||||
}
|
||||
|
||||
BaseItemKind.MUSIC_ARTIST -> {
|
||||
val pager = getPagerForArtist(api, item.id)
|
||||
musicService.addAllToQueue(pager, 0)
|
||||
}
|
||||
|
||||
BaseItemKind.PLAYLIST -> {
|
||||
val pager = getPagerForPlaylist(api, item.id)
|
||||
musicService.addAllToQueue(pager, 0)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.w("Unknown item type to queue for music: %s", item.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startInstantMix(itemId: UUID) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("Starting instant mix for %s", itemId)
|
||||
musicService.startInstantMix(itemId)
|
||||
}
|
||||
viewModelScope.launchDefault {
|
||||
// TODO better way to wait for query above to start
|
||||
delay(250)
|
||||
navigationManager.navigateTo(Destination.NowPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
|
||||
fun deleteItem(item: BaseItem) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
if (item.id == itemId) {
|
||||
navigationManager.goBack()
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal abstract fun init()
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
||||
@Composable
|
||||
fun MusicViewOptionsDialog(
|
||||
appPreferences: AppPreferences,
|
||||
onDismissRequest: () -> Unit,
|
||||
onViewOptionsChange: (AppPreferences) -> Unit,
|
||||
onEnableVisualizer: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
val columnState = rememberLazyListState()
|
||||
val items = remember { getMusicPreferences() }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||
dialogWindowProvider?.window?.let { window ->
|
||||
window.setGravity(Gravity.END)
|
||||
window.setDimAmount(0f)
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.view_options),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = columnState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(256.dp)
|
||||
.heightIn(max = 380.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
) {
|
||||
items(items) { pref ->
|
||||
pref as AppPreference<AppPreferences, Any>
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val value = pref.getter.invoke(appPreferences)
|
||||
// Using title is a bit hacky
|
||||
when (pref.title) {
|
||||
R.string.show_visualizer -> {
|
||||
ComposablePreference(
|
||||
preference = pref,
|
||||
value = value,
|
||||
onNavigate = {},
|
||||
onValueChange = { newValue ->
|
||||
newValue as Boolean
|
||||
if (newValue) {
|
||||
onEnableVisualizer.invoke()
|
||||
} else {
|
||||
onViewOptionsChange.invoke(
|
||||
pref.setter(
|
||||
appPreferences,
|
||||
newValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier,
|
||||
onClickPreference = { pref ->
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ComposablePreference(
|
||||
preference = pref,
|
||||
value = value,
|
||||
onNavigate = {},
|
||||
onValueChange = { newValue ->
|
||||
onViewOptionsChange.invoke(pref.setter(appPreferences, newValue))
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier,
|
||||
onClickPreference = { pref ->
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.state.rememberNextButtonState
|
||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
||||
import androidx.media3.ui.compose.state.rememberRepeatButtonState
|
||||
import androidx.media3.ui.compose.state.rememberShuffleButtonState
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
||||
import com.github.damontecres.wholphin.ui.playback.buttonSpacing
|
||||
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun NowPlayingButtons(
|
||||
player: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
initialFocusRequester: FocusRequester,
|
||||
onClickMore: () -> Unit,
|
||||
onClickStop: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playPauseState = rememberPlayPauseButtonState(player)
|
||||
val previousState = rememberPreviousButtonState(player)
|
||||
val nextState = rememberNextButtonState(player)
|
||||
val shuffleState = rememberShuffleButtonState(player)
|
||||
val repeatState = rememberRepeatButtonState(player)
|
||||
|
||||
val onControllerInteraction = remember { { controllerViewState.pulseControls() } }
|
||||
Box(
|
||||
modifier = modifier.focusGroup(),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
) {
|
||||
PlaybackButton(
|
||||
iconRes = R.drawable.baseline_more_vert_96,
|
||||
onClick = {
|
||||
onControllerInteraction.invoke()
|
||||
onClickMore.invoke()
|
||||
},
|
||||
enabled = true,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
modifier = Modifier,
|
||||
)
|
||||
PlaybackButton(
|
||||
iconRes = R.drawable.baseline_stop_24,
|
||||
onClick = {
|
||||
onClickStop.invoke()
|
||||
},
|
||||
enabled = true,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
PlaybackButtons(
|
||||
player = player,
|
||||
initialFocusRequester = initialFocusRequester,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
onPlaybackActionClick = {
|
||||
when (it) {
|
||||
PlaybackAction.Next -> {
|
||||
nextState.onClick()
|
||||
}
|
||||
|
||||
PlaybackAction.Previous -> {
|
||||
previousState.onClick()
|
||||
}
|
||||
|
||||
is PlaybackAction.ToggleCaptions -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
showPlay = playPauseState.showPlay,
|
||||
previousEnabled = previousState.isEnabled,
|
||||
nextEnabled = nextState.isEnabled,
|
||||
seekBack = 10.seconds,
|
||||
skipBackOnResume = null,
|
||||
seekForward = 30.seconds, // TODO
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
) {
|
||||
ShuffleButton(
|
||||
active = shuffleState.shuffleOn,
|
||||
enabled = shuffleState.isEnabled,
|
||||
onClick = {
|
||||
shuffleState.onClick()
|
||||
},
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
)
|
||||
RepeatButton(
|
||||
repeatMode = repeatState.repeatModeState,
|
||||
enabled = repeatState.isEnabled,
|
||||
onClick = {
|
||||
repeatState.onClick()
|
||||
},
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShuffleButton(
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onControllerInteraction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val selectedColor = MaterialTheme.colorScheme.border
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
Button(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
// shape = ButtonDefaults.shape(CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = AppColors.TransparentBlack25,
|
||||
focusedContainerColor = selectedColor,
|
||||
),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
modifier
|
||||
.size(36.dp, 36.dp)
|
||||
.onFocusChanged { onControllerInteraction.invoke() },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_shuffle),
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontAwesome,
|
||||
textAlign = TextAlign.Center,
|
||||
color =
|
||||
when {
|
||||
focused && active -> MaterialTheme.colorScheme.onSurface
|
||||
focused && !active -> MaterialTheme.colorScheme.surface
|
||||
!focused && active -> MaterialTheme.colorScheme.onSurface
|
||||
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RepeatButton(
|
||||
repeatMode: Int,
|
||||
onClick: () -> Unit,
|
||||
onControllerInteraction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val selectedColor = MaterialTheme.colorScheme.border
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
Button(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
// shape = ButtonDefaults.shape(CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = AppColors.TransparentBlack25,
|
||||
focusedContainerColor = selectedColor,
|
||||
),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
modifier
|
||||
.size(36.dp, 36.dp)
|
||||
.onFocusChanged { onControllerInteraction.invoke() },
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterVertically),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_repeat),
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontAwesome,
|
||||
textAlign = TextAlign.Center,
|
||||
color =
|
||||
if (focused) {
|
||||
when (repeatMode) {
|
||||
Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface
|
||||
Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
} else {
|
||||
when (repeatMode) {
|
||||
Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface
|
||||
Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface
|
||||
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
if (repeatMode == Player.REPEAT_MODE_ONE) {
|
||||
Text(
|
||||
text = "1",
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier =
|
||||
Modifier
|
||||
.offset(x = 4.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(1.dp),
|
||||
).align(Alignment.BottomStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
fun ShuffleButtonPreview() {
|
||||
val source = remember { PreviewInteractionSource() }
|
||||
WholphinTheme {
|
||||
Column {
|
||||
Row {
|
||||
RepeatButton(
|
||||
repeatMode = Player.REPEAT_MODE_OFF,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
)
|
||||
RepeatButton(
|
||||
repeatMode = Player.REPEAT_MODE_ALL,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
)
|
||||
RepeatButton(
|
||||
repeatMode = Player.REPEAT_MODE_ONE,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
)
|
||||
}
|
||||
Row {
|
||||
RepeatButton(
|
||||
repeatMode = Player.REPEAT_MODE_OFF,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
interactionSource = source,
|
||||
)
|
||||
RepeatButton(
|
||||
repeatMode = Player.REPEAT_MODE_ALL,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
interactionSource = source,
|
||||
)
|
||||
RepeatButton(
|
||||
repeatMode = Player.REPEAT_MODE_ONE,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
interactionSource = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.playback.SeekBar
|
||||
import com.github.damontecres.wholphin.ui.preferences.MoveButton
|
||||
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun NowPlayingOverlay(
|
||||
state: NowPlayingState,
|
||||
player: Player,
|
||||
current: AudioItem?,
|
||||
queue: List<AudioItem>,
|
||||
controllerViewState: ControllerViewState,
|
||||
onClickSong: (Int, AudioItem) -> Unit,
|
||||
onLongClickSong: (Int, AudioItem) -> Unit,
|
||||
onClickMore: () -> Unit,
|
||||
onMoveQueue: (Int, MoveDirection) -> Unit,
|
||||
onClickMoreItem: (Int, AudioItem) -> Unit,
|
||||
onClickStop: () -> Unit,
|
||||
lyricsFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
|
||||
var queueHasFocus by remember { mutableStateOf(false) }
|
||||
val height by animateFloatAsState(
|
||||
if (queueHasFocus) {
|
||||
1f
|
||||
} else {
|
||||
.33f
|
||||
},
|
||||
animationSpec = tween(durationMillis = 500),
|
||||
)
|
||||
val listState = rememberLazyListState()
|
||||
var showButtons by remember { mutableStateOf(true) }
|
||||
|
||||
val firstFocusRequester = remember { FocusRequester() }
|
||||
BackHandler(!showButtons) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
firstFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxHeight(height),
|
||||
) {
|
||||
SeekBar(
|
||||
player = player,
|
||||
controllerViewState = controllerViewState,
|
||||
onSeekProgress = {},
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
isEnabled = false,
|
||||
intervals = 0,
|
||||
seekBack = Duration.ZERO,
|
||||
seekForward = Duration.ZERO,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth(.95f),
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = showButtons,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
NowPlayingButtons(
|
||||
player = player,
|
||||
controllerViewState = controllerViewState,
|
||||
initialFocusRequester = focusRequester,
|
||||
onClickMore = onClickMore,
|
||||
onClickStop = onClickStop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
Text("No items")
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.queue),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onFocusChanged {
|
||||
queueHasFocus = it.hasFocus
|
||||
}.focusProperties {
|
||||
onExit = {
|
||||
if (requestedFocusDirection == FocusDirection.Up) focusRequester.requestFocus()
|
||||
}
|
||||
},
|
||||
) {
|
||||
itemsIndexed(queue, key = { _, song -> song.key }) { index, song ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = .5f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
).padding(end = 8.dp)
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) showButtons = index < 3
|
||||
controllerViewState.pulseControls()
|
||||
}.animateItem(),
|
||||
) {
|
||||
SongListItem(
|
||||
title = song.title,
|
||||
artist = song.artistNames,
|
||||
indexNumber = index + 1,
|
||||
runtime = song.runtime?.roundSeconds,
|
||||
showArtist = true,
|
||||
isPlaying = current?.id == song.id,
|
||||
onClick = { onClickSong.invoke(index, song) },
|
||||
onLongClick = { onLongClickSong.invoke(index, song) },
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(firstFocusRequester),
|
||||
),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
MoveButton(
|
||||
icon = R.string.fa_caret_up,
|
||||
enabled = index > 0,
|
||||
onClick = { onMoveQueue.invoke(index, MoveDirection.UP) },
|
||||
)
|
||||
MoveButton(
|
||||
icon = R.string.fa_caret_down,
|
||||
enabled = index < queue.lastIndex,
|
||||
onClick = { onMoveQueue.invoke(index, MoveDirection.DOWN) },
|
||||
)
|
||||
Button(
|
||||
onClick = { onClickMoreItem.invoke(index, song) },
|
||||
enabled = true,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.more),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.Manifest
|
||||
import android.view.Gravity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.rememberQueue
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.findActivity
|
||||
import com.github.damontecres.wholphin.ui.nav.Backdrop
|
||||
import com.github.damontecres.wholphin.ui.playback.BottomDialog
|
||||
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
|
||||
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun NowPlayingPage(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: NowPlayingViewModel =
|
||||
hiltViewModel<NowPlayingViewModel, NowPlayingViewModel.Factory>(
|
||||
creationCallback = { it.create() },
|
||||
),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsState()
|
||||
val player = viewModel.player
|
||||
val queue =
|
||||
rememberQueue(
|
||||
player,
|
||||
state.musicServiceState.queueVersion,
|
||||
state.musicServiceState.queueSize,
|
||||
)
|
||||
val current = queue.getOrNull(state.musicServiceState.currentIndex)
|
||||
val viz by viewModel.viz.collectAsState()
|
||||
|
||||
val controllerViewState = viewModel.controllerViewState
|
||||
val preferences =
|
||||
viewModel.userPreferencesService.flow
|
||||
.collectAsState(
|
||||
UserPreferences(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
),
|
||||
).value.appPreferences
|
||||
val musicPrefs = preferences.musicPreferences
|
||||
|
||||
val keyHandler =
|
||||
remember(preferences) {
|
||||
PlaybackKeyHandler(
|
||||
player = player,
|
||||
controlsEnabled = true,
|
||||
skipWithLeftRight = false,
|
||||
seekForward = 30.seconds,
|
||||
seekBack = 10.seconds,
|
||||
// seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
// seekBack = preferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
controllerViewState = controllerViewState,
|
||||
updateSkipIndicator = {},
|
||||
skipBackOnResume = null,
|
||||
// skipBackOnResume = preferences.playbackPreferences.skipBackOnResume,
|
||||
onInteraction = viewModel::reportInteraction,
|
||||
oneClickPause = preferences.playbackPreferences.oneClickPause,
|
||||
onStop = {
|
||||
viewModel.stop()
|
||||
},
|
||||
onPlaybackDialogTypeClick = { },
|
||||
getDurationMs = { player.duration },
|
||||
)
|
||||
}
|
||||
|
||||
val actions =
|
||||
remember {
|
||||
MusicQueueDialogActions(
|
||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||
onClickPlay = { index, _ -> viewModel.play(index) },
|
||||
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
|
||||
onClickRemoveFromQueue = { index, _ -> viewModel.removeFromQueue(index) },
|
||||
)
|
||||
}
|
||||
|
||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var lyricsHaveFocus by remember { mutableStateOf(false) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
val lyricsFocusRequester = remember { FocusRequester() }
|
||||
val hasLyrics = musicPrefs.showLyrics && state.hasLyrics
|
||||
|
||||
LaunchedEffect(lyricsHaveFocus) {
|
||||
if (lyricsHaveFocus) {
|
||||
controllerViewState.hideControls()
|
||||
}
|
||||
}
|
||||
BackHandler(lyricsHaveFocus) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
|
||||
var showRationaleDialog by remember { mutableStateOf(false) }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
viewModel.startVisualizer(isGranted, true)
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
Backdrop(
|
||||
backdrop = state.backdropResult,
|
||||
drawerIsOpen = false,
|
||||
backdropStyle = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enableTopScrim = false,
|
||||
useExistingImageAsPlaceholder = true,
|
||||
crossfadeDuration = 1.5.seconds,
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onPreviewKeyEvent { key ->
|
||||
if (!controllerViewState.controlsVisible &&
|
||||
key.type == KeyEventType.KeyUp &&
|
||||
isUp(key) &&
|
||||
hasLyrics
|
||||
) {
|
||||
lyricsFocusRequester.tryRequestFocus()
|
||||
true
|
||||
} else {
|
||||
keyHandler.onKeyEvent(key)
|
||||
}
|
||||
}.focusRequester(focusRequester)
|
||||
.focusable(),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
val enter =
|
||||
remember {
|
||||
expandHorizontally(expandFrom = Alignment.Start) + fadeIn()
|
||||
}
|
||||
val exit =
|
||||
remember {
|
||||
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = musicPrefs.showAlbumArt,
|
||||
enter = enter,
|
||||
exit = exit,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(32.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(.5f),
|
||||
) {
|
||||
AsyncImage(
|
||||
contentDescription = null,
|
||||
model = current?.imageUrl,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(240.dp)
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
)
|
||||
current?.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
current?.albumTitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
current?.artistNames?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = musicPrefs.showVisualizer,
|
||||
enter = enter,
|
||||
exit = exit,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
) {
|
||||
val visualizerWidth by animateFloatAsState(if (musicPrefs.showLyrics && state.hasLyrics) .5f else 1f)
|
||||
BarVisualizer(
|
||||
data = viz,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight(.75f)
|
||||
.fillMaxWidth(visualizerWidth)
|
||||
.align(Alignment.CenterStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = musicPrefs.showLyrics && state.hasLyrics,
|
||||
enter = expandHorizontally(expandFrom = Alignment.End),
|
||||
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(lyricsFocusRequester),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 32.dp, vertical = 100.dp)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
LyricsContent(
|
||||
lyrics = state.lyrics,
|
||||
currentLyricPosition = state.currentLyricIndex,
|
||||
lyricsHaveFocus = lyricsHaveFocus,
|
||||
onFocusLyrics = { lyricsHaveFocus = it },
|
||||
onClick = {
|
||||
it.start
|
||||
?.ticks
|
||||
?.inWholeMilliseconds
|
||||
?.let { player.seekTo(it) }
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
// .width(360.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val showContextForItem =
|
||||
remember {
|
||||
{ fromLongClick: Boolean, index: Int, song: AudioItem ->
|
||||
itemMoreDialog =
|
||||
DialogParams(
|
||||
title = song.title ?: "",
|
||||
fromLongClick = fromLongClick,
|
||||
items =
|
||||
buildMoreDialogForMusicQueue(
|
||||
context = context,
|
||||
actions = actions,
|
||||
item = song,
|
||||
index = index,
|
||||
canRemove = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(controllerViewState.controlsVisible) {
|
||||
controllerViewState.hideControls()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter),
|
||||
) {
|
||||
NowPlayingOverlay(
|
||||
state = state,
|
||||
player = player,
|
||||
current = current,
|
||||
queue = queue,
|
||||
controllerViewState = controllerViewState,
|
||||
onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) },
|
||||
onClickMore = { showViewOptionsDialog = true },
|
||||
onClickSong = { index, _ -> viewModel.play(index) },
|
||||
onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) },
|
||||
onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) },
|
||||
onClickStop = { viewModel.stop() },
|
||||
lyricsFocusRequester = lyricsFocusRequester,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.drawBehind {
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black),
|
||||
startY = 0f,
|
||||
endY = size.height,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.musicServiceState.loadingState is LoadingState.Loading) {
|
||||
LoadingPage(focusEnabled = false)
|
||||
}
|
||||
}
|
||||
itemMoreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { itemMoreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
if (showViewOptionsDialog) {
|
||||
MusicViewOptionsDialog(
|
||||
appPreferences = preferences,
|
||||
onDismissRequest = { showViewOptionsDialog = false },
|
||||
onViewOptionsChange = { viewModel.updatePreferences(it) },
|
||||
onEnableVisualizer = {
|
||||
val showRationale =
|
||||
context
|
||||
.findActivity()
|
||||
?.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) == true
|
||||
when {
|
||||
!state.visualizerPermissions && showRationale -> {
|
||||
showRationaleDialog = true
|
||||
}
|
||||
|
||||
!state.visualizerPermissions -> {
|
||||
launcher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
else -> {
|
||||
viewModel.startVisualizer(true, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showRationaleDialog) {
|
||||
RecordAudioRationaleDialog(
|
||||
onDismissRequest = { showRationaleDialog = false },
|
||||
onClick = {
|
||||
showRationaleDialog = false
|
||||
launcher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NowPlayingBottomDialog(
|
||||
showDebugInfo: Boolean,
|
||||
lyricsActive: Boolean,
|
||||
songHasLyrics: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
onClickShowDebug: () -> Unit,
|
||||
onClickLyrics: () -> Unit,
|
||||
) {
|
||||
val choices =
|
||||
mapOf(
|
||||
BottomDialogItem(
|
||||
data = 0,
|
||||
headline = stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
|
||||
supporting = null,
|
||||
) to onClickShowDebug,
|
||||
BottomDialogItem(
|
||||
data = 0,
|
||||
headline = stringResource(if (lyricsActive) R.string.hide_lyrics else R.string.show_lyrics),
|
||||
supporting = if (songHasLyrics) stringResource(R.string.song_has_lyrics) else null,
|
||||
) to onClickLyrics,
|
||||
)
|
||||
|
||||
BottomDialog(
|
||||
choices = choices.keys.toList(),
|
||||
onDismissRequest = {
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
onSelectChoice = { _, choice ->
|
||||
choices[choice]?.invoke()
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
gravity = Gravity.START,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordAudioRationaleDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
BasicDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.visualizer_rationale),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.continue_string),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.github.damontecres.wholphin.services.BackdropResult
|
||||
import com.github.damontecres.wholphin.services.MusicServiceState
|
||||
import org.jellyfin.sdk.model.api.LyricDto
|
||||
|
||||
@Stable
|
||||
data class NowPlayingState(
|
||||
val musicServiceState: MusicServiceState,
|
||||
val lyrics: LyricDto? = null,
|
||||
val currentLyricIndex: Int? = null,
|
||||
val visualizerPermissions: Boolean = false,
|
||||
val backdropResult: BackdropResult = BackdropResult.NONE,
|
||||
) {
|
||||
val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty()
|
||||
}
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.audiofx.Visualizer
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropResult
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.NowPlayingStatus
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.lyricsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.LyricDto
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@UnstableApi
|
||||
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
|
||||
class NowPlayingViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val musicService: MusicService,
|
||||
private val backdropService: BackdropService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
val navigationManager: NavigationManager,
|
||||
val userPreferencesService: UserPreferencesService,
|
||||
) : ViewModel(),
|
||||
Visualizer.OnDataCaptureListener,
|
||||
Player.Listener {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(): NowPlayingViewModel
|
||||
}
|
||||
|
||||
private val visualizerMutex = Mutex()
|
||||
private var visualizer: Visualizer? = null
|
||||
|
||||
val controllerViewState =
|
||||
ControllerViewState(
|
||||
AppPreference.ControllerTimeout.defaultValue,
|
||||
true,
|
||||
)
|
||||
|
||||
val state = MutableStateFlow(NowPlayingState(musicService.state.value))
|
||||
val player get() = musicService.player
|
||||
|
||||
val viz = MutableStateFlow<IntArray>(IntArray(0))
|
||||
|
||||
private val lyricCache =
|
||||
InMemoryKache<UUID, LyricDto>(20) {
|
||||
creationScope = CoroutineScope(Dispatchers.IO)
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(this)
|
||||
addCloseable {
|
||||
player.removeListener(this)
|
||||
visualizer?.release()
|
||||
}
|
||||
val visualizerPermissions =
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
startVisualizer(visualizerPermissions, false)
|
||||
viewModelScope.launchDefault {
|
||||
musicService.state.collectLatest { musicServiceState ->
|
||||
if (musicServiceState.status != NowPlayingStatus.IDLE) {
|
||||
visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING
|
||||
}
|
||||
|
||||
state.update { it.copy(musicServiceState = musicServiceState) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchDefault {
|
||||
viewModelScope
|
||||
.launchDefault {
|
||||
controllerViewState.observe()
|
||||
}.join()
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
viewModelScope.launchDefault {
|
||||
backdropService.clearBackdrop()
|
||||
updateBackdrop(getCurrent())
|
||||
}
|
||||
playbackLoop()
|
||||
}
|
||||
|
||||
fun reportInteraction() {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
|
||||
private suspend fun getCurrent(): AudioItem? {
|
||||
val mediaItem =
|
||||
onMain {
|
||||
player.currentMediaItemIndex
|
||||
.takeIf { it in 0..<player.mediaItemCount }
|
||||
?.let { player.getMediaItemAt(it) }
|
||||
}
|
||||
return mediaItem?.localConfiguration?.tag as? AudioItem
|
||||
}
|
||||
|
||||
private fun playbackLoop() {
|
||||
viewModelScope.launchDefault {
|
||||
while (isActive) {
|
||||
val position = onMain { player.currentPosition }.milliseconds
|
||||
// Timber.v("playbackLoop: %s", position)
|
||||
getCurrent()?.let { audio ->
|
||||
// Timber.v("Got current %s", audio.id)
|
||||
if (audio.hasLyrics) {
|
||||
val lyrics =
|
||||
lyricCache.getOrPut(audio.id) {
|
||||
// TODO remote lyrics?
|
||||
api.lyricsApi.getLyrics(audio.id).content
|
||||
}
|
||||
val lyricIndex =
|
||||
if (lyrics != null) {
|
||||
val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO
|
||||
val lyricPosition = offset + position
|
||||
lyrics.lyrics
|
||||
.indexOfLast {
|
||||
it.start?.ticks?.let { lyricPosition >= it } == true
|
||||
}.takeIf { it >= 0 }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Timber.v("lyricIndex=$lyricIndex")
|
||||
state.update {
|
||||
it.copy(
|
||||
lyrics = lyrics,
|
||||
currentLyricIndex = lyricIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delay(150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
val audio = mediaItem?.localConfiguration?.tag as? AudioItem
|
||||
Timber.v("onMediaItemTransition to %s", audio?.id)
|
||||
updateBackdrop(audio)
|
||||
viewModelScope.launchDefault {
|
||||
state.update {
|
||||
it.copy(
|
||||
lyrics = null,
|
||||
currentLyricIndex = null,
|
||||
)
|
||||
}
|
||||
audio?.let { audio ->
|
||||
if (audio.hasLyrics) {
|
||||
val lyrics =
|
||||
lyricCache.getOrPut(audio.id) {
|
||||
// TODO remote lyrics?
|
||||
api.lyricsApi.getLyrics(audio.id).content
|
||||
}
|
||||
Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null)
|
||||
state.update {
|
||||
it.copy(
|
||||
lyrics = lyrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var backDropJob: Job? = null
|
||||
|
||||
private fun updateBackdrop(audio: AudioItem?) {
|
||||
backDropJob?.cancel()
|
||||
backDropJob =
|
||||
viewModelScope.launchDefault {
|
||||
val showBackdrop =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.musicPreferences.showBackdrop
|
||||
if (showBackdrop) {
|
||||
var backdropItem: BaseItem? = null
|
||||
try {
|
||||
if (audio?.artistId != null) {
|
||||
api.userLibraryApi.getItem(audio.artistId).content.let {
|
||||
if (it.backdropImageTags?.isNotEmpty() == true) {
|
||||
backdropItem = BaseItem(it, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backdropItem == null && audio?.albumId != null) {
|
||||
api.userLibraryApi.getItem(audio.albumId).content.let {
|
||||
backdropItem =
|
||||
getBackdropItemForAlbum(api, BaseItem(it, false))
|
||||
}
|
||||
}
|
||||
if (backdropItem != null) {
|
||||
doUpdateBackdrop(backdropItem)
|
||||
} else {
|
||||
doUpdateBackdropRandom()
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching backdrop")
|
||||
doUpdateBackdropRandom()
|
||||
}
|
||||
delay(60.seconds)
|
||||
doUpdateBackdropRandom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doUpdateBackdropRandom() {
|
||||
val randomArtist =
|
||||
api.itemsApi
|
||||
.getItems(
|
||||
recursive = true,
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
limit = 1,
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
if (randomArtist != null) {
|
||||
doUpdateBackdrop(BaseItem(randomArtist))
|
||||
} else {
|
||||
clearBackdrop()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doUpdateBackdrop(item: BaseItem) {
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
val (primaryColor, secondaryColor, tertiaryColor) =
|
||||
backdropService.extractColorsFromBackdrop(
|
||||
imageUrl,
|
||||
)
|
||||
val backdropResult =
|
||||
BackdropResult(
|
||||
itemId = item.id.toString(),
|
||||
imageUrl = imageUrl,
|
||||
primaryColor = primaryColor,
|
||||
secondaryColor = secondaryColor,
|
||||
tertiaryColor = tertiaryColor,
|
||||
)
|
||||
state.update { it.copy(backdropResult = backdropResult) }
|
||||
}
|
||||
|
||||
private fun clearBackdrop() {
|
||||
state.update { it.copy(backdropResult = BackdropResult.NONE) }
|
||||
}
|
||||
|
||||
fun moveQueue(
|
||||
index: Int,
|
||||
direction: MoveDirection,
|
||||
) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) }
|
||||
|
||||
fun play(index: Int) = viewModelScope.launchDefault { musicService.playIndex(index) }
|
||||
|
||||
fun playNext(index: Int) = viewModelScope.launchDefault { musicService.moveQueue(index, 1) }
|
||||
|
||||
fun removeFromQueue(index: Int) = viewModelScope.launchDefault { musicService.removeFromQueue(index) }
|
||||
|
||||
fun stop() {
|
||||
viewModelScope.launchDefault {
|
||||
musicService.stop()
|
||||
navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFftDataCapture(
|
||||
visualizer: Visualizer,
|
||||
fft: ByteArray,
|
||||
samplingRate: Int,
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onWaveFormDataCapture(
|
||||
visualizer: Visualizer,
|
||||
waveform: ByteArray,
|
||||
samplingRate: Int,
|
||||
) {
|
||||
val resolution = 96
|
||||
val captureSize =
|
||||
Visualizer.getCaptureSizeRange()[1]
|
||||
val groupSize = (captureSize / resolution.toFloat()).toInt()
|
||||
val processed =
|
||||
waveform
|
||||
.toList()
|
||||
.chunked(groupSize)
|
||||
.map { it.average().toInt() + 128 }
|
||||
.toIntArray()
|
||||
viz.update { processed }
|
||||
}
|
||||
|
||||
fun updatePreferences(prefs: AppPreferences) {
|
||||
viewModelScope.launchDefault {
|
||||
var backdropChanged = false
|
||||
preferencesDataStore.updateData {
|
||||
backdropChanged =
|
||||
it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop
|
||||
prefs
|
||||
}
|
||||
if (backdropChanged) {
|
||||
if (prefs.musicPreferences.showBackdrop) {
|
||||
updateBackdrop(getCurrent())
|
||||
} else {
|
||||
clearBackdrop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initVisualizer() {
|
||||
viewModelScope.launchDefault {
|
||||
visualizerMutex.withLock {
|
||||
val prefs = preferencesDataStore.data.first()
|
||||
if (visualizer == null &&
|
||||
state.value.visualizerPermissions &&
|
||||
prefs.musicPreferences.showVisualizer
|
||||
) {
|
||||
Timber.v("Creating visualizer")
|
||||
visualizer =
|
||||
Visualizer(onMain { player.audioSessionId }).apply {
|
||||
captureSize = Visualizer.getCaptureSizeRange()[1]
|
||||
setDataCaptureListener(
|
||||
this@NowPlayingViewModel,
|
||||
Visualizer.getMaxCaptureRate() / 3,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startVisualizer(
|
||||
permissionGranted: Boolean,
|
||||
updatePreferences: Boolean,
|
||||
) {
|
||||
Timber.v("startVisualizer: permissionGranted=%s", permissionGranted)
|
||||
state.update {
|
||||
it.copy(
|
||||
visualizerPermissions = permissionGranted,
|
||||
)
|
||||
}
|
||||
viewModelScope.launchDefault {
|
||||
if (updatePreferences || !permissionGranted) {
|
||||
preferencesDataStore.updateData {
|
||||
it.updateMusicPreferences { showVisualizer = permissionGranted }
|
||||
}
|
||||
}
|
||||
initVisualizer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.ListItemDefaults
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun SongListItem(
|
||||
song: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onClickMore: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showArtist: Boolean = false,
|
||||
isPlaying: Boolean = false,
|
||||
isQueued: Boolean = false,
|
||||
) = SongListItem(
|
||||
title = song?.title,
|
||||
artist = if (showArtist) song?.data?.albumArtist else null,
|
||||
indexNumber = song?.data?.indexNumber,
|
||||
runtime =
|
||||
remember(song) {
|
||||
song
|
||||
?.data
|
||||
?.runTimeTicks
|
||||
?.ticks
|
||||
?.roundSeconds
|
||||
},
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
showArtist = showArtist,
|
||||
isPlaying = isPlaying,
|
||||
isQueued = isQueued,
|
||||
showMoreButton = true,
|
||||
onClickMore = onClickMore,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SongListItem(
|
||||
title: String?,
|
||||
artist: String?,
|
||||
indexNumber: Int?,
|
||||
runtime: Duration?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showArtist: Boolean = false,
|
||||
isPlaying: Boolean = false,
|
||||
isQueued: Boolean = false,
|
||||
showMoreButton: Boolean = false,
|
||||
onClickMore: () -> Unit = {},
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val leadingContent: @Composable (BoxScope.() -> Unit) = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = indexNumber?.toString() ?: "",
|
||||
)
|
||||
MusicQueueMarker(
|
||||
isPlaying = isPlaying,
|
||||
isQueued = isQueued,
|
||||
)
|
||||
}
|
||||
}
|
||||
val headlineContent = @Composable {
|
||||
Text(
|
||||
text = title ?: "",
|
||||
maxLines = 1,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
)
|
||||
}
|
||||
val trailingContent = @Composable {
|
||||
Text(
|
||||
text = runtime.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
if (showArtist) {
|
||||
// TODO use dense?
|
||||
ListItem(
|
||||
selected = isPlaying,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
leadingContent = leadingContent,
|
||||
headlineContent = headlineContent,
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = artist ?: "",
|
||||
)
|
||||
},
|
||||
trailingContent = trailingContent,
|
||||
scale = ListItemDefaults.scale(1f, 1f, .95f),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
ListItem(
|
||||
selected = isPlaying,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
leadingContent = leadingContent,
|
||||
headlineContent = headlineContent,
|
||||
supportingContent = null,
|
||||
trailingContent = trailingContent,
|
||||
scale = ListItemDefaults.scale(1f, 1f, .95f),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (showMoreButton) {
|
||||
Button(
|
||||
onClick = onClickMore,
|
||||
enabled = true,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.more),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") }
|
||||
|
||||
/**
|
||||
* Add an indicator for if the item is currently playing or queued
|
||||
*/
|
||||
@Composable
|
||||
fun MusicQueueMarker(
|
||||
isPlaying: Boolean,
|
||||
isQueued: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (isPlaying) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else if (isQueued) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(LocalContentColor.current)
|
||||
.size(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
fun SongListItemPreview() {
|
||||
WholphinTheme {
|
||||
Column {
|
||||
SongListItem(
|
||||
title = "Song title",
|
||||
artist = "Artists",
|
||||
indexNumber = 1,
|
||||
runtime = 2.minutes + 30.seconds,
|
||||
onClick = {},
|
||||
onLongClick = { },
|
||||
modifier = Modifier,
|
||||
showArtist = false,
|
||||
)
|
||||
SongListItem(
|
||||
title = "Song title",
|
||||
artist = "Artists",
|
||||
indexNumber = 1,
|
||||
runtime = 2.minutes + 30.seconds,
|
||||
onClick = {},
|
||||
onLongClick = { },
|
||||
modifier = Modifier,
|
||||
showArtist = false,
|
||||
isQueued = true,
|
||||
showMoreButton = true,
|
||||
)
|
||||
SongListItem(
|
||||
title = "Song title",
|
||||
artist = "Artists",
|
||||
indexNumber = 2,
|
||||
runtime = 2.minutes + 30.seconds,
|
||||
onClick = {},
|
||||
onLongClick = { },
|
||||
modifier = Modifier,
|
||||
showArtist = true,
|
||||
isPlaying = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.music
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import java.util.UUID
|
||||
|
||||
data class MusicMoreDialogActions(
|
||||
val onNavigate: (Destination) -> Unit,
|
||||
val onClickPlay: (Int, BaseItem) -> Unit,
|
||||
val onClickPlayNext: (Int, BaseItem) -> Unit,
|
||||
val onClickAddToQueue: (Int, BaseItem) -> Unit,
|
||||
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||
val onClickAddPlaylist: (UUID) -> Unit,
|
||||
val onClickGoToAlbum: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
||||
},
|
||||
val onClickGoToArtist: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
||||
},
|
||||
val onClickRemoveFromQueue: (Int) -> Unit,
|
||||
val onClickDelete: (BaseItem) -> Unit,
|
||||
)
|
||||
|
||||
fun buildMoreDialogForMusic(
|
||||
context: Context,
|
||||
actions: MusicMoreDialogActions,
|
||||
item: BaseItem,
|
||||
index: Int,
|
||||
canRemove: Boolean,
|
||||
canDelete: Boolean,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlay(index, item)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play_next),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlayNext(index, item)
|
||||
},
|
||||
)
|
||||
if (canRemove) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.remove_from_queue),
|
||||
Icons.Default.Delete,
|
||||
) {
|
||||
actions.onClickRemoveFromQueue(index)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.add_to_queue),
|
||||
Icons.Default.Add,
|
||||
) {
|
||||
actions.onClickAddToQueue(index, item)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.add_to_playlist,
|
||||
iconStringRes = R.string.fa_list_ul,
|
||||
) {
|
||||
actions.onClickAddPlaylist.invoke(item.id)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||
iconStringRes = R.string.fa_heart,
|
||||
iconColor = if (item.favorite) Color.Red else Color.Unspecified,
|
||||
) {
|
||||
actions.onClickFavorite.invoke(item.id, !item.favorite)
|
||||
},
|
||||
)
|
||||
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_album),
|
||||
R.string.fa_compact_disc,
|
||||
) {
|
||||
actions.onClickGoToAlbum.invoke(item.data.albumId!!)
|
||||
},
|
||||
)
|
||||
}
|
||||
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_artist),
|
||||
R.string.fa_user,
|
||||
) {
|
||||
actions.onClickGoToArtist.invoke(
|
||||
item.data.artistItems!!
|
||||
.first()
|
||||
.id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class MusicQueueDialogActions(
|
||||
val onNavigate: (Destination) -> Unit,
|
||||
val onClickPlay: (Int, AudioItem) -> Unit,
|
||||
val onClickPlayNext: (Int, AudioItem) -> Unit,
|
||||
val onClickGoToAlbum: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
||||
},
|
||||
val onClickGoToArtist: (UUID) -> Unit = {
|
||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
||||
},
|
||||
val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
|
||||
)
|
||||
|
||||
fun buildMoreDialogForMusicQueue(
|
||||
context: Context,
|
||||
actions: MusicQueueDialogActions,
|
||||
item: AudioItem,
|
||||
index: Int,
|
||||
canRemove: Boolean,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlay(index, item)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play_next),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickPlayNext(index, item)
|
||||
},
|
||||
)
|
||||
if (canRemove) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.remove_from_queue),
|
||||
Icons.Default.Delete,
|
||||
) {
|
||||
actions.onClickRemoveFromQueue(index, item)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (item.albumId != null) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_album),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.onClickGoToAlbum.invoke(item.albumId)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (item.artistId != null) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.go_to_artist),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.onClickGoToArtist.invoke(item.artistId)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ViewModel.getPagerForAlbum(
|
||||
api: ApiClient,
|
||||
albumId: UUID,
|
||||
): ApiRequestPager<GetItemsRequest> {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = albumId,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
fields = DefaultItemFields,
|
||||
sortBy =
|
||||
listOf(
|
||||
ItemSortBy.PARENT_INDEX_NUMBER,
|
||||
ItemSortBy.INDEX_NUMBER,
|
||||
ItemSortBy.SORT_NAME,
|
||||
),
|
||||
)
|
||||
return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||
}
|
||||
|
||||
suspend fun ViewModel.getPagerForArtist(
|
||||
api: ApiClient,
|
||||
artistId: UUID,
|
||||
): ApiRequestPager<GetItemsRequest> {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
artistIds = listOf(artistId),
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
fields = DefaultItemFields,
|
||||
// TODO better sort
|
||||
sortBy =
|
||||
listOf(
|
||||
ItemSortBy.PARENT_INDEX_NUMBER,
|
||||
ItemSortBy.INDEX_NUMBER,
|
||||
ItemSortBy.SORT_NAME,
|
||||
),
|
||||
)
|
||||
return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||
}
|
||||
|
||||
suspend fun ViewModel.getPagerForPlaylist(
|
||||
api: ApiClient,
|
||||
playlistId: UUID,
|
||||
): ApiRequestPager<GetItemsRequest> {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = playlistId,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
fields = DefaultItemFields,
|
||||
sortBy =
|
||||
listOf(
|
||||
ItemSortBy.DEFAULT,
|
||||
),
|
||||
)
|
||||
return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
|
|
@ -76,6 +77,8 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -100,10 +103,15 @@ class SearchViewModel
|
|||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val albums = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
|
||||
private var currentQuery: String? = null
|
||||
|
||||
private val semaphore = Semaphore(4)
|
||||
|
||||
fun search(query: String?) {
|
||||
if (currentQuery == query) {
|
||||
return
|
||||
|
|
@ -117,6 +125,9 @@ class SearchViewModel
|
|||
searchInternal(query, BaseItemKind.MOVIE, movies)
|
||||
searchInternal(query, BaseItemKind.SERIES, series)
|
||||
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
||||
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
|
||||
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
|
||||
searchInternal(query, BaseItemKind.AUDIO, songs)
|
||||
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
||||
searchSeerr(query)
|
||||
} else {
|
||||
|
|
@ -135,6 +146,7 @@ class SearchViewModel
|
|||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
try {
|
||||
semaphore.withPermit {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
searchTerm = query,
|
||||
|
|
@ -149,6 +161,7 @@ class SearchViewModel
|
|||
withContext(Dispatchers.Main) {
|
||||
target.value = SearchResult.Success(pager)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception searching for $type")
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -202,10 +215,13 @@ sealed interface SearchResult {
|
|||
|
||||
private const val SEARCH_ROW = 0
|
||||
private const val MOVIE_ROW = SEARCH_ROW + 1
|
||||
private const val COLLECTION_ROW = MOVIE_ROW + 1
|
||||
private const val SERIES_ROW = COLLECTION_ROW + 1
|
||||
private const val SERIES_ROW = MOVIE_ROW + 1
|
||||
private const val EPISODE_ROW = SERIES_ROW + 1
|
||||
private const val SEERR_ROW = EPISODE_ROW + 1
|
||||
private const val ALBUM_ROW = EPISODE_ROW + 1
|
||||
private const val ARTIST_ROW = ALBUM_ROW + 1
|
||||
private const val SONG_ROW = ARTIST_ROW + 1
|
||||
private const val COLLECTION_ROW = SONG_ROW + 1
|
||||
private const val SEERR_ROW = COLLECTION_ROW + 1
|
||||
|
||||
/** Delay for focus to settle after voice search dialog dismisses. */
|
||||
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
|
||||
|
|
@ -223,6 +239,9 @@ fun SearchPage(
|
|||
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
||||
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
||||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
||||
val albums by viewModel.albums.observeAsState(SearchResult.NoQuery)
|
||||
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
|
||||
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
|
||||
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
||||
|
||||
// val query = rememberTextFieldState()
|
||||
|
|
@ -367,16 +386,6 @@ fun SearchPage(
|
|||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.collections),
|
||||
result = collections,
|
||||
rowIndex = COLLECTION_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.tv_shows),
|
||||
result = series,
|
||||
|
|
@ -409,6 +418,85 @@ fun SearchPage(
|
|||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.albums),
|
||||
result = albums,
|
||||
rowIndex = ALBUM_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[ALBUM_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
SeasonCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
position = RowColumn(ALBUM_ROW, index)
|
||||
onClick.invoke()
|
||||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.artists),
|
||||
result = artists,
|
||||
rowIndex = COLLECTION_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
SeasonCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
position = RowColumn(ALBUM_ROW, index)
|
||||
onClick.invoke()
|
||||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.songs),
|
||||
result = songs,
|
||||
rowIndex = SONG_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[SONG_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
SeasonCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
position = RowColumn(ALBUM_ROW, index)
|
||||
onClick.invoke()
|
||||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.collections),
|
||||
result = collections,
|
||||
rowIndex = COLLECTION_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.discover),
|
||||
result = seerrResults,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ data class HomeRowPresets(
|
|||
val tvLibrary: HomeRowViewOptions,
|
||||
val videoLibrary: HomeRowViewOptions,
|
||||
val photoLibrary: HomeRowViewOptions,
|
||||
val musicLibrary: HomeRowViewOptions,
|
||||
val playlist: HomeRowViewOptions,
|
||||
val liveTv: HomeRowViewOptions,
|
||||
val genreSize: Int,
|
||||
|
|
@ -51,8 +52,9 @@ data class HomeRowPresets(
|
|||
|
||||
CollectionType.LIVETV -> liveTv
|
||||
|
||||
CollectionType.MUSIC -> musicLibrary
|
||||
|
||||
CollectionType.UNKNOWN,
|
||||
CollectionType.MUSIC,
|
||||
CollectionType.BOOKS,
|
||||
CollectionType.PLAYLISTS,
|
||||
CollectionType.FOLDERS,
|
||||
|
|
@ -74,6 +76,10 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
|
|
@ -111,6 +117,11 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
|
|
@ -154,6 +165,11 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
|
|
@ -198,6 +214,11 @@ data class HomeRowPresets(
|
|||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
musicLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ val favoriteOptions by lazy {
|
|||
BaseItemKind.VIDEO to R.string.videos,
|
||||
BaseItemKind.PLAYLIST to R.string.playlists,
|
||||
BaseItemKind.PERSON to R.string.people,
|
||||
BaseItemKind.MUSIC_ARTIST to R.string.artists,
|
||||
BaseItemKind.MUSIC_ALBUM to R.string.albums,
|
||||
)
|
||||
}
|
||||
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionEx
|
|||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.services.tvAccess
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
|
|
@ -267,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) {
|
||||
|
|
@ -276,7 +295,7 @@ class HomeSettingsViewModel
|
|||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = RecentlyAdded(library.itemId),
|
||||
config = RecentlyAdded(library.itemId, viewOptions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +310,7 @@ class HomeSettingsViewModel
|
|||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = RecentlyReleased(library.itemId),
|
||||
config = RecentlyReleased(library.itemId, viewOptions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -310,7 +329,7 @@ class HomeSettingsViewModel
|
|||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = Suggestions(library.itemId),
|
||||
config = Suggestions(library.itemId, viewOptions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -671,6 +690,7 @@ class HomeSettingsViewModel
|
|||
BaseItemKind.VIDEO -> preset.videoLibrary
|
||||
BaseItemKind.PLAYLIST -> preset.playlist
|
||||
BaseItemKind.PERSON -> preset.movieLibrary
|
||||
BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary
|
||||
else -> preset.movieLibrary
|
||||
}
|
||||
it.config.updateViewOptions(viewOptions)
|
||||
|
|
|
|||
|
|
@ -1,53 +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.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.NavEntry
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
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
|
||||
|
|
@ -79,144 +54,17 @@ fun ApplicationContent(
|
|||
enableTopScrim: Boolean = true,
|
||||
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
||||
) {
|
||||
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
||||
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
val baseBackgroundColor = MaterialTheme.colorScheme.background
|
||||
if (backdrop.hasColors &&
|
||||
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
|
||||
) {
|
||||
val animPrimary by animateColorAsState(
|
||||
backdrop.primaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_primary",
|
||||
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
|
||||
Backdrop(
|
||||
drawerIsOpen = drawerState.isOpen,
|
||||
backdropStyle = backdropStyle,
|
||||
enableTopScrim = enableTopScrim,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
val animSecondary by animateColorAsState(
|
||||
backdrop.secondaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_secondary",
|
||||
)
|
||||
val animTertiary by animateColorAsState(
|
||||
backdrop.tertiaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_tertiary",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind {
|
||||
drawRect(color = baseBackgroundColor)
|
||||
// Top Left (Vibrant/Muted)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animSecondary, Color.Transparent),
|
||||
center = Offset(0f, 0f),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Bottom Right (DarkVibrant/DarkMuted)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animPrimary, Color.Transparent),
|
||||
center = Offset(size.width, size.height),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Bottom Left (Dark / Bridge)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors =
|
||||
listOf(
|
||||
baseBackgroundColor,
|
||||
Color.Transparent,
|
||||
),
|
||||
center = Offset(0f, size.height),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Top Right (Under Image - Vibrant/Bright)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animTertiary, Color.Transparent),
|
||||
center = Offset(size.width, 0f),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(backdrop.imageUrl)
|
||||
.transitionFactory(CrossFadeFactory(800.milliseconds))
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.TopEnd,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.fillMaxHeight(.7f)
|
||||
.fillMaxWidth(.7f)
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
if (drawerState.isOpen) {
|
||||
drawRect(
|
||||
brush = SolidColor(Color.Black),
|
||||
alpha = .75f,
|
||||
)
|
||||
}
|
||||
// Subtle top scrim for system UI readability (clock, tabs)
|
||||
if (enableTopScrim) {
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colorStops =
|
||||
arrayOf(
|
||||
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
|
||||
TOP_SCRIM_END_FRACTION to Color.Transparent,
|
||||
),
|
||||
),
|
||||
blendMode = BlendMode.Multiply,
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black),
|
||||
startX = 0f,
|
||||
endX = size.width * 0.6f,
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Black, Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height,
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val navDrawerListState = rememberLazyListState()
|
||||
NavDisplay(
|
||||
backStack = navigationManager.backStack,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
@file:OptIn(ExperimentalCoilApi::class)
|
||||
|
||||
package com.github.damontecres.wholphin.ui.nav
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.useExistingImageAsPlaceholder
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transitionFactory
|
||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||
import com.github.damontecres.wholphin.services.BackdropResult
|
||||
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun Backdrop(
|
||||
drawerIsOpen: Boolean,
|
||||
backdropStyle: BackdropStyle,
|
||||
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier,
|
||||
enableTopScrim: Boolean = true,
|
||||
useExistingImageAsPlaceholder: Boolean = false,
|
||||
crossfadeDuration: Duration = 800.milliseconds,
|
||||
) {
|
||||
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
||||
Backdrop(
|
||||
backdrop = backdrop,
|
||||
drawerIsOpen = drawerIsOpen,
|
||||
backdropStyle = backdropStyle,
|
||||
modifier = modifier,
|
||||
enableTopScrim = enableTopScrim,
|
||||
useExistingImageAsPlaceholder = useExistingImageAsPlaceholder,
|
||||
crossfadeDuration = crossfadeDuration,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Backdrop(
|
||||
backdrop: BackdropResult,
|
||||
drawerIsOpen: Boolean,
|
||||
backdropStyle: BackdropStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
enableTopScrim: Boolean = true,
|
||||
useExistingImageAsPlaceholder: Boolean = false,
|
||||
crossfadeDuration: Duration = 800.milliseconds,
|
||||
) {
|
||||
val baseBackgroundColor = MaterialTheme.colorScheme.background
|
||||
if (backdrop.hasColors &&
|
||||
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
|
||||
) {
|
||||
val animPrimary by animateColorAsState(
|
||||
backdrop.primaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_primary",
|
||||
)
|
||||
val animSecondary by animateColorAsState(
|
||||
backdrop.secondaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_secondary",
|
||||
)
|
||||
val animTertiary by animateColorAsState(
|
||||
backdrop.tertiaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_tertiary",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind {
|
||||
drawRect(color = baseBackgroundColor)
|
||||
// Top Left (Vibrant/Muted)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animSecondary, Color.Transparent),
|
||||
center = Offset(0f, 0f),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Bottom Right (DarkVibrant/DarkMuted)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animPrimary, Color.Transparent),
|
||||
center = Offset(size.width, size.height),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Bottom Left (Dark / Bridge)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors =
|
||||
listOf(
|
||||
baseBackgroundColor,
|
||||
Color.Transparent,
|
||||
),
|
||||
center = Offset(0f, size.height),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Top Right (Under Image - Vibrant/Bright)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animTertiary, Color.Transparent),
|
||||
center = Offset(size.width, 0f),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(backdrop.imageUrl)
|
||||
.useExistingImageAsPlaceholder(useExistingImageAsPlaceholder)
|
||||
.transitionFactory(CrossFadeFactory(crossfadeDuration))
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.TopEnd,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.fillMaxHeight(.7f)
|
||||
.fillMaxWidth(.7f)
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
if (drawerIsOpen) {
|
||||
drawRect(
|
||||
brush = SolidColor(Color.Black),
|
||||
alpha = .75f,
|
||||
)
|
||||
}
|
||||
// Subtle top scrim for system UI readability (clock, tabs)
|
||||
if (enableTopScrim) {
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colorStops =
|
||||
arrayOf(
|
||||
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
|
||||
TOP_SCRIM_END_FRACTION to Color.Transparent,
|
||||
),
|
||||
),
|
||||
blendMode = BlendMode.Multiply,
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black),
|
||||
startX = 0f,
|
||||
endX = size.width * 0.6f,
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Black, Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height,
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +129,9 @@ sealed class Destination(
|
|||
val item: DiscoverItem,
|
||||
) : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data object NowPlaying : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data object UpdateApp : Destination(true)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ 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
|
||||
|
|
@ -28,6 +29,9 @@ 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
|
||||
|
|
@ -154,6 +158,7 @@ fun DestinationContent(
|
|||
BaseItemKind.PLAYLIST -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
PlaylistDetails(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
@ -214,6 +219,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 +290,10 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
Destination.NowPlaying -> {
|
||||
NowPlayingPage(modifier)
|
||||
}
|
||||
|
||||
Destination.UpdateApp -> {
|
||||
InstallUpdatePage(preferences, modifier)
|
||||
}
|
||||
|
|
@ -386,6 +413,14 @@ fun CollectionFolder(
|
|||
)
|
||||
}
|
||||
|
||||
CollectionType.MUSIC -> {
|
||||
CollectionFolderMusic(
|
||||
preferences,
|
||||
destination,
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
|
||||
CollectionType.HOMEVIDEOS,
|
||||
CollectionType.PHOTOS,
|
||||
-> {
|
||||
|
|
@ -398,7 +433,6 @@ fun CollectionFolder(
|
|||
}
|
||||
|
||||
CollectionType.MUSICVIDEOS,
|
||||
CollectionType.MUSIC,
|
||||
CollectionType.BOOKS,
|
||||
-> {
|
||||
CollectionFolderGeneric(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav
|
|||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.animateIntOffsetAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
|
|
@ -24,6 +27,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
|
|||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
|
|
@ -104,6 +109,7 @@ class NavDrawerViewModel
|
|||
val navigationManager: NavigationManager,
|
||||
val setupNavigationManager: SetupNavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
private val musicService: MusicService,
|
||||
) : ViewModel() {
|
||||
val state = navDrawerService.state
|
||||
|
||||
|
|
@ -178,9 +184,11 @@ class NavDrawerViewModel
|
|||
if (key is Destination) {
|
||||
val index =
|
||||
if (key is Destination.Home) {
|
||||
-1
|
||||
HOME_INDEX
|
||||
} else if (key is Destination.Search) {
|
||||
-2
|
||||
SEARCH_INDEX
|
||||
} else if (key is Destination.NowPlaying) {
|
||||
NOW_PLAYING_INDEX
|
||||
} else {
|
||||
val idx = asDestinations.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
|
|
@ -198,6 +206,13 @@ class NavDrawerViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToSetup(userList: SetupDestination) {
|
||||
viewModelScope.launchDefault {
|
||||
musicService.stop()
|
||||
setupNavigationManager.navigateTo(userList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface NavDrawerItem {
|
||||
|
|
@ -242,6 +257,10 @@ data class ServerNavDrawerItem(
|
|||
}
|
||||
}
|
||||
|
||||
private const val HOME_INDEX = -1
|
||||
private const val SEARCH_INDEX = -2
|
||||
private const val NOW_PLAYING_INDEX = -3
|
||||
|
||||
/**
|
||||
* Display the left side navigation drawer with [DestinationContent] on the right
|
||||
*/
|
||||
|
|
@ -324,12 +343,37 @@ fun NavDrawer(
|
|||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
viewModel.setupNavigationManager.navigateTo(
|
||||
viewModel.navigateToSetup(
|
||||
SetupDestination.UserList(server),
|
||||
)
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = state.nowPlayingEnabled,
|
||||
enter = expandVertically(expandFrom = Alignment.Top),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top),
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
IconNavItem(
|
||||
text = stringResource(R.string.now_playing),
|
||||
subtext = state.nowPlayingTitle,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
selected = selectedIndex == NOW_PLAYING_INDEX,
|
||||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
viewModel.setIndex(NOW_PLAYING_INDEX)
|
||||
viewModel.navigationManager.navigateTo(Destination.NowPlaying)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
selectedIndex == NOW_PLAYING_INDEX,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = navDrawerListState,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
|
|
@ -353,18 +397,18 @@ fun NavDrawer(
|
|||
IconNavItem(
|
||||
text = stringResource(R.string.search),
|
||||
icon = Icons.Default.Search,
|
||||
selected = selectedIndex == -2,
|
||||
selected = selectedIndex == SEARCH_INDEX,
|
||||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
viewModel.setIndex(-2)
|
||||
viewModel.setIndex(SEARCH_INDEX)
|
||||
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(searchFocusRequester)
|
||||
.ifElse(
|
||||
selectedIndex == -2,
|
||||
selectedIndex == SEARCH_INDEX,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
|
|
@ -374,11 +418,11 @@ fun NavDrawer(
|
|||
IconNavItem(
|
||||
text = stringResource(R.string.home),
|
||||
icon = Icons.Default.Home,
|
||||
selected = selectedIndex == -1,
|
||||
selected = selectedIndex == HOME_INDEX,
|
||||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
viewModel.setIndex(-1)
|
||||
viewModel.setIndex(HOME_INDEX)
|
||||
if (destination is Destination.Home) {
|
||||
viewModel.navigationManager.reloadHome()
|
||||
} else {
|
||||
|
|
@ -388,7 +432,7 @@ fun NavDrawer(
|
|||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
selectedIndex == -1,
|
||||
selectedIndex == HOME_INDEX,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.playback
|
|||
import android.view.Gravity
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
|
@ -38,10 +39,15 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
|
|
@ -57,6 +63,7 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
|
|
@ -284,7 +291,7 @@ fun SeekBar(
|
|||
}
|
||||
}
|
||||
|
||||
private val buttonSpacing = 12.dp
|
||||
val buttonSpacing = 12.dp
|
||||
|
||||
@Composable
|
||||
fun LeftPlaybackButtons(
|
||||
|
|
@ -460,6 +467,54 @@ fun PlaybackButton(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaybackFaButton(
|
||||
@StringRes iconRes: Int,
|
||||
onClick: () -> Unit,
|
||||
onControllerInteraction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
textColor: Color = Color.Unspecified,
|
||||
) {
|
||||
val selectedColor = MaterialTheme.colorScheme.border
|
||||
Button(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
// shape = ButtonDefaults.shape(CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = AppColors.TransparentBlack25,
|
||||
focusedContainerColor = selectedColor,
|
||||
),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
modifier
|
||||
.size(36.dp, 36.dp)
|
||||
.onFocusChanged { onControllerInteraction.invoke() },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(iconRes),
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontAwesome,
|
||||
textAlign = TextAlign.Center,
|
||||
color =
|
||||
if (textColor.isSpecified) {
|
||||
textColor
|
||||
} else if (LocalTheme.current == AppThemeColors.OLED_BLACK) {
|
||||
LocalContentColor.current
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> BottomDialog(
|
||||
choices: List<BottomDialogItem<T>>,
|
||||
|
|
@ -554,12 +609,18 @@ data class BottomDialogItem<T>(
|
|||
@Composable
|
||||
private fun ButtonPreview() {
|
||||
WholphinTheme {
|
||||
Row {
|
||||
Row(Modifier.background(Color.Red)) {
|
||||
PlaybackButton(
|
||||
iconRes = R.drawable.baseline_play_arrow_24,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
)
|
||||
PlaybackFaButton(
|
||||
iconRes = R.string.fa_shuffle,
|
||||
onClick = {},
|
||||
onControllerInteraction = {},
|
||||
textColor = Color.Green,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -142,6 +144,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,
|
||||
|
|
@ -270,6 +273,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) {
|
||||
|
|
@ -734,12 +738,12 @@ class PlaybackViewModel
|
|||
player.removeListener(it)
|
||||
}
|
||||
|
||||
val playbackItemState = PlaybackItemState(playback, currentItemPlayback)
|
||||
val activityListener =
|
||||
TrackActivityPlaybackListener(
|
||||
api = api,
|
||||
player = player,
|
||||
playback = playback,
|
||||
itemPlayback = currentItemPlayback,
|
||||
getState = { playbackItemState },
|
||||
)
|
||||
player.addListener(activityListener)
|
||||
this@PlaybackViewModel.activityListener = activityListener
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog
|
|||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -85,11 +86,6 @@ data class NavDrawerPin(
|
|||
}
|
||||
}
|
||||
|
||||
enum class MoveDirection {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
private fun <T> List<T>.move(
|
||||
direction: MoveDirection,
|
||||
index: Int,
|
||||
|
|
@ -246,7 +242,7 @@ fun NavDrawerPreferenceListItem(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MoveButton(
|
||||
fun MoveButton(
|
||||
@StringRes icon: Int,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,26 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import java.util.function.IntFunction
|
||||
import java.util.function.Predicate
|
||||
|
||||
interface BlockingList<T> : List<T> {
|
||||
suspend fun getBlocking(index: Int): T
|
||||
|
||||
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
|
||||
|
||||
companion object {
|
||||
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
|
||||
}
|
||||
}
|
||||
|
||||
private class BlockingListWrapper<T>(
|
||||
private val list: List<T>,
|
||||
) : BlockingList<T>,
|
||||
List<T> by list {
|
||||
override suspend fun getBlocking(index: Int): T = get(index)
|
||||
|
||||
override suspend fun indexOfBlocking(predicate: Predicate<T>): Int = indexOfFirst { predicate.test(it) }
|
||||
|
||||
@Deprecated("Deprecated")
|
||||
override fun <T> toArray(generator: IntFunction<Array<out T?>?>): Array<out T?> = super<List>.toArray(generator)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ val supportedCollectionTypes =
|
|||
CollectionType.LIVETV,
|
||||
CollectionType.MUSICVIDEOS,
|
||||
CollectionType.FOLDERS,
|
||||
CollectionType.MUSIC,
|
||||
null, // Mixed
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
|
|
@ -20,6 +21,7 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks
|
|||
import timber.log.Timber
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -30,8 +32,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
class TrackActivityPlaybackListener(
|
||||
private val api: ApiClient,
|
||||
private val player: Player,
|
||||
val playback: CurrentPlayback,
|
||||
val itemPlayback: ItemPlayback,
|
||||
private val getState: () -> PlaybackItemState?,
|
||||
) : Player.Listener {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main)
|
||||
private val task: TimerTask =
|
||||
|
|
@ -50,46 +51,52 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
fun init() {
|
||||
launch("reportPlaybackStart") {
|
||||
Timber.v("reportPlaybackStart for ${itemPlayback.itemId}")
|
||||
getState.invoke()?.let { state ->
|
||||
Timber.v("reportPlaybackStart for ${state.itemId}")
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
canSeek = true,
|
||||
itemId = itemPlayback.itemId,
|
||||
itemId = state.itemId,
|
||||
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
|
||||
playMethod = playback.playMethod,
|
||||
playMethod = state.playMethod,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
isMuted = false,
|
||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||
playSessionId = playback.playSessionId,
|
||||
liveStreamId = playback.liveStreamId,
|
||||
audioStreamIndex = state.audioStreamIndex,
|
||||
subtitleStreamIndex = state.subtitleStreamIndex,
|
||||
playSessionId = state.playSessionId,
|
||||
liveStreamId = state.liveStreamId,
|
||||
),
|
||||
)
|
||||
|
||||
val delay = 5.seconds.inWholeMilliseconds
|
||||
// Every x seconds, check if the video is playing
|
||||
TIMER.schedule(task, delay, delay)
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
// player.removeListener(this)
|
||||
task.cancel()
|
||||
TIMER.purge()
|
||||
val position = player.currentPosition.milliseconds
|
||||
launch("reportPlaybackStopped") {
|
||||
Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position")
|
||||
getState.invoke()?.let { state ->
|
||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = itemPlayback.itemId,
|
||||
itemId = state.itemId,
|
||||
positionTicks = position.inWholeTicks,
|
||||
failed = false,
|
||||
playSessionId = playback.playSessionId,
|
||||
liveStreamId = playback.liveStreamId,
|
||||
playSessionId = state.playSessionId,
|
||||
liveStreamId = state.liveStreamId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (initialized) {
|
||||
|
|
@ -108,32 +115,34 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
private fun saveActivity(position: Long) {
|
||||
launch("saveActivity") {
|
||||
getState.invoke()?.let { state ->
|
||||
val calcPosition =
|
||||
withContext(Dispatchers.Main) {
|
||||
(if (position >= 0) position else player.currentPosition)
|
||||
}
|
||||
if (calcPosition > 0) {
|
||||
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
|
||||
Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition")
|
||||
Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition")
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = itemPlayback.itemId,
|
||||
itemId = state.itemId,
|
||||
positionTicks = calcPosition.milliseconds.inWholeTicks,
|
||||
canSeek = true,
|
||||
isPaused = isPaused,
|
||||
isMuted = false,
|
||||
playMethod = playback.playMethod,
|
||||
playMethod = state.playMethod,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||
playSessionId = playback.playSessionId,
|
||||
liveStreamId = playback.liveStreamId,
|
||||
audioStreamIndex = state.audioStreamIndex,
|
||||
subtitleStreamIndex = state.subtitleStreamIndex,
|
||||
playSessionId = state.playSessionId,
|
||||
liveStreamId = state.liveStreamId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launch(
|
||||
name: String,
|
||||
|
|
@ -143,7 +152,7 @@ class TrackActivityPlaybackListener(
|
|||
try {
|
||||
block.invoke(this)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId)
|
||||
Timber.w(ex, "Exception during %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -154,3 +163,24 @@ class TrackActivityPlaybackListener(
|
|||
private val TIMER by lazy { Timer("$TAG-timer", true) }
|
||||
}
|
||||
}
|
||||
|
||||
data class PlaybackItemState(
|
||||
val itemId: UUID,
|
||||
val playMethod: PlayMethod,
|
||||
val audioStreamIndex: Int? = null,
|
||||
val subtitleStreamIndex: Int? = null,
|
||||
val playSessionId: String? = null,
|
||||
val liveStreamId: String? = null,
|
||||
) {
|
||||
constructor(
|
||||
playback: CurrentPlayback,
|
||||
itemPlayback: ItemPlayback,
|
||||
) : this(
|
||||
itemId = itemPlayback.itemId,
|
||||
playMethod = playback.playMethod,
|
||||
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
|
||||
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
|
||||
playSessionId = playback.playSessionId,
|
||||
liveStreamId = playback.liveStreamId,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,13 @@ message PhotoPreferences{
|
|||
bool slideshow_play_videos = 2;
|
||||
}
|
||||
|
||||
message MusicPreferences {
|
||||
bool show_lyrics = 1;
|
||||
bool show_visualizer = 2;
|
||||
bool show_album_Art = 3;
|
||||
bool show_backdrop = 4;
|
||||
}
|
||||
|
||||
message AppPreferences {
|
||||
// The currently signed in server and user IDs, mostly for restoring a session
|
||||
string current_server_id = 1;
|
||||
|
|
@ -194,4 +201,5 @@ message AppPreferences {
|
|||
AdvancedPreferences advanced_preferences = 10;
|
||||
bool sign_in_automatically = 11;
|
||||
PhotoPreferences photo_preferences = 12;
|
||||
MusicPreferences music_preferences = 13;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,4 +53,7 @@
|
|||
<string name="fa_check" translatable="false"></string>
|
||||
<string name="fa_cloud_arrow_up" translatable="false"></string>
|
||||
<string name="fa_cloud_arrow_down" translatable="false"></string>
|
||||
<string name="fa_compass" translatable="false"></string>
|
||||
<string name="fa_repeat" translatable="false"></string>
|
||||
<string name="fa_compact_disc" translatable="false"></string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -504,6 +504,14 @@
|
|||
<string name="send_media_info_log_to_server">Send media info log to server</string>
|
||||
<string name="no_limit">No limit</string>
|
||||
<string name="max_days_next_up">Max days in Next Up</string>
|
||||
<string name="albums">Albums</string>
|
||||
<string name="artists">Artists</string>
|
||||
<string name="songs">Songs</string>
|
||||
<string name="go_to_artist">Go to artist</string>
|
||||
<string name="now_playing">Now playing</string>
|
||||
<string name="instant_mix">Instant mix</string>
|
||||
<string name="go_to_album">Go to album</string>
|
||||
<string name="add_to_queue">Add to queue</string>
|
||||
|
||||
<string name="add_row">Add row</string>
|
||||
<string name="genres_in">Genres in %1$s</string>
|
||||
|
|
@ -707,6 +715,8 @@
|
|||
<item>@string/photos</item>
|
||||
</string-array>
|
||||
|
||||
<string name="search_for">Search %s</string>
|
||||
|
||||
<string name="actor">Actor</string>
|
||||
<string name="composer">Composer</string>
|
||||
<string name="writer">Writer</string>
|
||||
|
|
@ -719,7 +729,22 @@
|
|||
<string name="mixer">Mixer</string>
|
||||
<string name="creator">Creator</string>
|
||||
<string name="artist">Artist</string>
|
||||
<string name="search_for">Search %s</string>
|
||||
<string name="album_artist">Album artist</string>
|
||||
<string name="album">Album</string>
|
||||
<string name="popular_songs">Popular songs</string>
|
||||
<string name="play_next">Play next</string>
|
||||
<string name="music_videos">Music videos</string>
|
||||
<string name="lyrics">Lyrics</string>
|
||||
<string name="hide_lyrics">Hide lyrics</string>
|
||||
<string name="show_lyrics">Show lyrics</string>
|
||||
<string name="song_has_lyrics">Song has lyrics</string>
|
||||
<string name="remove_from_queue">Remove from queue</string>
|
||||
<string name="show_album_cover">Show album cover</string>
|
||||
<string name="show_visualizer">Show visualizer</string>
|
||||
<string name="show_backdrop">Show backdrop</string>
|
||||
<string name="play_as_type">Play as?</string>
|
||||
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
|
||||
<string name="continue_string">Continue</string>
|
||||
<string name="select_all">Select all</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue