mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Basic music playback
This commit is contained in:
parent
e8eb975966
commit
ee0c73f0e2
10 changed files with 355 additions and 44 deletions
|
|
@ -51,9 +51,13 @@ class NavDrawerItemRepository
|
||||||
|
|
||||||
val builtins =
|
val builtins =
|
||||||
if (seerrServerRepository.active.first()) {
|
if (seerrServerRepository.active.first()) {
|
||||||
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
listOf(
|
||||||
|
NavDrawerItem.Favorites,
|
||||||
|
NavDrawerItem.Discover,
|
||||||
|
NavDrawerItem.NowPlaying,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
listOf(NavDrawerItem.Favorites)
|
listOf(NavDrawerItem.Favorites, NavDrawerItem.NowPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
val libraries =
|
val libraries =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
data class AudioItem(
|
||||||
|
val id: UUID,
|
||||||
|
val albumId: UUID?,
|
||||||
|
val title: String?,
|
||||||
|
val albumTitle: String?,
|
||||||
|
val artistNames: String?,
|
||||||
|
val runtime: Duration?,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(item: BaseItem): AudioItem =
|
||||||
|
AudioItem(
|
||||||
|
id = item.id,
|
||||||
|
albumId = item.data.albumId,
|
||||||
|
title = item.title,
|
||||||
|
albumTitle = item.data.album,
|
||||||
|
artistNames = item.data.albumArtist,
|
||||||
|
runtime = item.data.runTimeTicks?.ticks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,15 +8,22 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.profile.Codec
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -29,7 +36,10 @@ class MusicService
|
||||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
private val player: Player by lazy {
|
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
||||||
|
val state: StateFlow<MusicServiceState> = _state
|
||||||
|
|
||||||
|
val player: Player by lazy {
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.setMediaSourceFactory(
|
.setMediaSourceFactory(
|
||||||
|
|
@ -37,31 +47,35 @@ class MusicService
|
||||||
OkHttpDataSource.Factory(authOkHttpClient),
|
OkHttpDataSource.Factory(authOkHttpClient),
|
||||||
),
|
),
|
||||||
).build()
|
).build()
|
||||||
|
.also {
|
||||||
|
it.addListener(MusicPlayerListener(it, _state))
|
||||||
|
it.prepare()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueue(
|
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
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addToQueue(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
position: Int? = null,
|
position: Int? = null,
|
||||||
) {
|
) {
|
||||||
if (item.type == BaseItemKind.AUDIO) {
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
val url =
|
val mediaItem = convert(item)
|
||||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
withContext(Dispatchers.Main) {
|
||||||
itemId = item.id,
|
|
||||||
container =
|
|
||||||
listOf(
|
|
||||||
Codec.Audio.OPUS,
|
|
||||||
Codec.Audio.MP3,
|
|
||||||
Codec.Audio.AAC,
|
|
||||||
Codec.Audio.FLAC,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val mediaItem =
|
|
||||||
MediaItem
|
|
||||||
.Builder()
|
|
||||||
.setUri(url)
|
|
||||||
.setMediaId(item.id.toServerString())
|
|
||||||
.setTag(item)
|
|
||||||
.build()
|
|
||||||
player.addMediaItem(mediaItem)
|
player.addMediaItem(mediaItem)
|
||||||
if (player.mediaItemCount == 1) {
|
if (player.mediaItemCount == 1) {
|
||||||
// Start playing if this was the first time added
|
// Start playing if this was the first time added
|
||||||
|
|
@ -70,3 +84,92 @@ class MusicService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return MediaItem
|
||||||
|
.Builder()
|
||||||
|
.setUri(url)
|
||||||
|
.setMediaId(audio.id.toServerString())
|
||||||
|
.setTag(AudioItem.from(audio))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MusicServiceState(
|
||||||
|
val queue: List<AudioItem>,
|
||||||
|
val currentIndex: Int,
|
||||||
|
val isPlaying: Boolean,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = MusicServiceState(emptyList(), 0, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
queue = PlayerMediaItemList(player),
|
||||||
|
currentIndex = player.currentMediaItemIndex,
|
||||||
|
isPlaying = player.isPlaying,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
Timber.v("MusicPlayerListener onIsPlayingChanged")
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
// TODO this is hack to force the UI to update, but is very inefficient
|
||||||
|
queue = PlayerMediaItemList(player),
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaItemTransition(
|
||||||
|
mediaItem: MediaItem?,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
Timber.v("MusicPlayerListener onMediaItemTransition")
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
queue = PlayerMediaItemList(player),
|
||||||
|
currentIndex = player.currentMediaItemIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PlayerMediaItemList(
|
||||||
|
private val player: Player,
|
||||||
|
) : AbstractList<AudioItem>() {
|
||||||
|
override fun get(index: Int): AudioItem {
|
||||||
|
Timber.v("get %s", index)
|
||||||
|
return player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
|
||||||
|
}
|
||||||
|
|
||||||
|
override val size: Int
|
||||||
|
get() {
|
||||||
|
Timber.v("size %s", player.mediaItemCount)
|
||||||
|
return player.mediaItemCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,19 +40,14 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.ExtrasService
|
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
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.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -79,10 +74,12 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -94,17 +91,12 @@ class AlbumViewModel
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackRepository: ItemPlaybackRepository,
|
|
||||||
val streamChoiceService: StreamChoiceService,
|
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
private val themeSongPlayer: ThemeSongPlayer,
|
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val peopleFavorites: PeopleFavorites,
|
|
||||||
private val trailerService: TrailerService,
|
|
||||||
private val extrasService: ExtrasService,
|
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val imageUrlService: ImageUrlService,
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val musicService: MusicService,
|
||||||
@Assisted val itemId: UUID,
|
@Assisted val itemId: UUID,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -151,11 +143,43 @@ class AlbumViewModel
|
||||||
loading = LoadingState.Success,
|
loading = LoadingState.Success,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (album.data.imageTags?.contains(ImageType.BACKDROP) == true) {
|
||||||
|
backdropService.submit(album)
|
||||||
|
} else {
|
||||||
|
val artistIds =
|
||||||
|
album.data.albumArtists
|
||||||
|
?.shuffled()
|
||||||
|
?.take(50)
|
||||||
|
?.map { it.id }
|
||||||
|
api.itemsApi
|
||||||
|
.getItems(
|
||||||
|
ids = artistIds,
|
||||||
|
imageTypes = listOf(ImageType.BACKDROP),
|
||||||
|
).content.items
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { backdropService.submit(BaseItem(it, false)) }
|
||||||
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun play(
|
||||||
|
shuffled: Boolean,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
Timber.v("Playing album %s from %s", itemId, startIndex)
|
||||||
|
val songs = state.value.songs as ApiRequestPager<*>
|
||||||
|
val songsToAdd =
|
||||||
|
(startIndex..songs.lastIndex)
|
||||||
|
.mapNotNull { idx ->
|
||||||
|
songs.getBlocking(idx)
|
||||||
|
}
|
||||||
|
musicService.setQueue(songsToAdd, shuffled)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AlbumState(
|
data class AlbumState(
|
||||||
|
|
@ -178,6 +202,7 @@ fun AlbumDetails(
|
||||||
creationCallback = { it.create(itemId) },
|
creationCallback = { it.create(itemId) },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
when (val loading = state.loading) {
|
when (val loading = state.loading) {
|
||||||
|
|
@ -215,19 +240,22 @@ fun AlbumDetails(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
AlbumButtons(
|
AlbumButtons(
|
||||||
onClickPlay = {},
|
onClickPlay = { viewModel.play(it, 0) },
|
||||||
onClickAddToPlaylist = {},
|
onClickAddToPlaylist = {},
|
||||||
onClickGoToArtist = {},
|
onClickGoToArtist = {},
|
||||||
onClickMore = { },
|
onClickMore = { },
|
||||||
buttonOnFocusChanged = {},
|
buttonOnFocusChanged = {},
|
||||||
modifier = Modifier,
|
modifier =
|
||||||
|
Modifier.onFocusChanged {
|
||||||
|
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemsIndexed(state.songs) { index, song ->
|
itemsIndexed(state.songs) { index, song ->
|
||||||
SongListItem(
|
SongListItem(
|
||||||
song = song,
|
song = song,
|
||||||
onClick = {},
|
onClick = { viewModel.play(false, index) },
|
||||||
onClickAddToQueue = {},
|
onClickAddToQueue = {},
|
||||||
onClickAddToPlaylist = {},
|
onClickAddToPlaylist = {},
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
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.tv.material3.Text
|
||||||
|
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.MusicService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
|
||||||
|
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
|
||||||
|
class NowPlayingViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val musicService: MusicService,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(): NowPlayingViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
val state get() = musicService.state
|
||||||
|
val player get() = musicService.player
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun NowPlayingPage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: NowPlayingViewModel =
|
||||||
|
hiltViewModel<NowPlayingViewModel, NowPlayingViewModel.Factory>(
|
||||||
|
creationCallback = { it.create() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val player = viewModel.player
|
||||||
|
val current = state.queue.getOrNull(state.currentIndex)
|
||||||
|
|
||||||
|
val playPauseState = rememberPlayPauseButtonState(player)
|
||||||
|
val previousState = rememberPreviousButtonState(player)
|
||||||
|
val nextState = rememberNextButtonState(player)
|
||||||
|
|
||||||
|
Column(modifier = modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = current?.title ?: "",
|
||||||
|
)
|
||||||
|
Row {
|
||||||
|
PlaybackButtons(
|
||||||
|
player = player,
|
||||||
|
initialFocusRequester = remember { FocusRequester() },
|
||||||
|
onControllerInteraction = {},
|
||||||
|
onPlaybackActionClick = {},
|
||||||
|
showPlay = playPauseState.showPlay,
|
||||||
|
previousEnabled = previousState.isEnabled,
|
||||||
|
nextEnabled = nextState.isEnabled,
|
||||||
|
seekBack = 10.seconds,
|
||||||
|
skipBackOnResume = null,
|
||||||
|
seekForward = 30.seconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.queue.isEmpty()) {
|
||||||
|
Text("No items")
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.queue) { index, song ->
|
||||||
|
SongListItem(
|
||||||
|
title = song.title,
|
||||||
|
artist = song.artistNames,
|
||||||
|
indexNumber = index + 1,
|
||||||
|
runtime = song.runtime?.roundSeconds,
|
||||||
|
showArtist = true,
|
||||||
|
isPlaying = state.currentIndex == index,
|
||||||
|
onClick = {
|
||||||
|
player.seekTo(index, 0L)
|
||||||
|
},
|
||||||
|
onClickAddToQueue = {},
|
||||||
|
onClickAddToPlaylist = {},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ fun SongListItem(
|
||||||
showArtist: Boolean = false,
|
showArtist: Boolean = false,
|
||||||
) = SongListItem(
|
) = SongListItem(
|
||||||
title = song?.title,
|
title = song?.title,
|
||||||
artist = if (showArtist) song?.artistsString else null,
|
artist = if (showArtist) song?.data?.albumArtist else null,
|
||||||
indexNumber = song?.data?.indexNumber,
|
indexNumber = song?.data?.indexNumber,
|
||||||
runtime =
|
runtime =
|
||||||
song
|
song
|
||||||
|
|
@ -88,13 +88,14 @@ fun SongListItem(
|
||||||
onClickAddToPlaylist: () -> Unit,
|
onClickAddToPlaylist: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showArtist: Boolean = false,
|
showArtist: Boolean = false,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
selected = false,
|
selected = isPlaying,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -121,7 +122,7 @@ fun SongListItem(
|
||||||
text = runtime.toString(),
|
text = runtime.toString(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scale = ListItemDefaults.scale(1f, 1f),
|
scale = ListItemDefaults.scale(1f, 1f, .95f),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,9 @@ sealed class Destination(
|
||||||
val item: DiscoverItem,
|
val item: DiscoverItem,
|
||||||
) : Destination(false)
|
) : Destination(false)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object NowPlaying : Destination(true)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object UpdateApp : Destination(true)
|
data object UpdateApp : Destination(true)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetails
|
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||||
|
|
@ -270,6 +271,10 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Destination.NowPlaying -> {
|
||||||
|
NowPlayingPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
Destination.UpdateApp -> {
|
Destination.UpdateApp -> {
|
||||||
InstallUpdatePage(preferences, modifier)
|
InstallUpdatePage(preferences, modifier)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,11 @@ class NavDrawerViewModel
|
||||||
setIndex(index)
|
setIndex(index)
|
||||||
navigationManager.navigateToFromDrawer(item.destination)
|
navigationManager.navigateToFromDrawer(item.destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavDrawerItem.NowPlaying -> {
|
||||||
|
setIndex(index)
|
||||||
|
navigationManager.navigateToFromDrawer(Destination.NowPlaying)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +251,13 @@ sealed interface NavDrawerItem {
|
||||||
|
|
||||||
override fun name(context: Context): String = context.getString(R.string.discover)
|
override fun name(context: Context): String = context.getString(R.string.discover)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object NowPlaying : NavDrawerItem {
|
||||||
|
override val id: String
|
||||||
|
get() = "a_nowplaying"
|
||||||
|
|
||||||
|
override fun name(context: Context): String = context.getString(R.string.now_playing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ServerNavDrawerItem(
|
data class ServerNavDrawerItem(
|
||||||
|
|
@ -625,6 +637,10 @@ fun NavigationDrawerScope.NavItem(
|
||||||
else -> R.string.fa_film
|
else -> R.string.fa_film
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavDrawerItem.NowPlaying -> {
|
||||||
|
R.string.fa_circle_play
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,7 @@
|
||||||
<string name="artists">Artists</string>
|
<string name="artists">Artists</string>
|
||||||
<string name="songs">Songs</string>
|
<string name="songs">Songs</string>
|
||||||
<string name="go_to_artist">Go to artist</string>
|
<string name="go_to_artist">Go to artist</string>
|
||||||
|
<string name="now_playing">Now playing</string>
|
||||||
|
|
||||||
<string-array name="theme_song_volume">
|
<string-array name="theme_song_volume">
|
||||||
<item>Disabled</item>
|
<item>Disabled</item>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue