mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
WIP UI
This commit is contained in:
parent
724d4d0da3
commit
e8eb975966
9 changed files with 1102 additions and 1 deletions
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Singleton
|
||||||
|
class MusicService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
|
private val api: ApiClient,
|
||||||
|
) {
|
||||||
|
private val player: Player by lazy {
|
||||||
|
ExoPlayer
|
||||||
|
.Builder(context)
|
||||||
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(
|
||||||
|
OkHttpDataSource.Factory(authOkHttpClient),
|
||||||
|
),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToQueue(
|
||||||
|
item: BaseItem,
|
||||||
|
position: Int? = null,
|
||||||
|
) {
|
||||||
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
|
val url =
|
||||||
|
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||||
|
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)
|
||||||
|
if (player.mediaItemCount == 1) {
|
||||||
|
// Start playing if this was the first time added
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
|
@ -114,6 +115,8 @@ abstract class RecommendedViewModel(
|
||||||
HomeRowLoadingState.Error(titleStr, null, ex)
|
HomeRowLoadingState.Error(titleStr, null, ex)
|
||||||
}
|
}
|
||||||
update(title, row)
|
update(title, row)
|
||||||
|
// TODO
|
||||||
|
loading.setValueOnMain(LoadingState.Success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
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.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.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.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.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,
|
||||||
|
) : RecommendedViewModel(
|
||||||
|
context,
|
||||||
|
navigationManager,
|
||||||
|
favoriteWatchManager,
|
||||||
|
mediaReportService,
|
||||||
|
backdropService,
|
||||||
|
) {
|
||||||
|
@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()
|
||||||
|
|
||||||
|
update(R.string.recently_released) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(R.string.recently_added) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(R.string.top_unwatched) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||||
|
loading.setValueOnMain(LoadingState.Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(
|
||||||
|
@StringRes title: Int,
|
||||||
|
row: HomeRowLoadingState,
|
||||||
|
) {
|
||||||
|
rows.update { current ->
|
||||||
|
current.toMutableList().apply { set(rowTitles[title]!!, 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
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.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
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.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.MovieSortOptions
|
||||||
|
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||||
|
import com.github.damontecres.wholphin.ui.logTab
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionFolderMusic(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
destination: Destination.MediaItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
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.playlists),
|
||||||
|
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 = slideInVertically() + fadeIn(),
|
||||||
|
exit = slideOutVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
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 = MovieSortOptions,
|
||||||
|
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 = VideoSortOptions,
|
||||||
|
defaultViewOptions = ViewOptionsSquare,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
positionCallback = { columns, position ->
|
||||||
|
showHeader = position < columns
|
||||||
|
},
|
||||||
|
playEnabled = false,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlists
|
||||||
|
3 -> {
|
||||||
|
// TODO
|
||||||
|
Text("TODO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genres
|
||||||
|
4 -> {
|
||||||
|
GenreCardGrid(
|
||||||
|
itemId = destination.itemId,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM, BaseItemKind.AUDIO),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Songs
|
||||||
|
5 -> {
|
||||||
|
CollectionFolderGrid(
|
||||||
|
preferences = preferences,
|
||||||
|
onClickItem = { _, item ->
|
||||||
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
},
|
||||||
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_songs",
|
||||||
|
initialFilter =
|
||||||
|
CollectionFolderFilter(
|
||||||
|
filter =
|
||||||
|
GetItemsFilter(
|
||||||
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showTitle = false,
|
||||||
|
recursive = true,
|
||||||
|
sortOptions = MovieSortOptions,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
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.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.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.ViewModel
|
||||||
|
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.ItemPlaybackRepository
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
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.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
|
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.ui.DefaultItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
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.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.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class)
|
||||||
|
class AlbumViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val itemPlaybackRepository: ItemPlaybackRepository,
|
||||||
|
val streamChoiceService: StreamChoiceService,
|
||||||
|
val mediaReportService: MediaReportService,
|
||||||
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val peopleFavorites: PeopleFavorites,
|
||||||
|
private val trailerService: TrailerService,
|
||||||
|
private val extrasService: ExtrasService,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
@Assisted val itemId: UUID,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(itemId: UUID): AlbumViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AlbumState.EMPTY)
|
||||||
|
val state: StateFlow<AlbumState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
val itemDeferred =
|
||||||
|
async {
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
}
|
||||||
|
val songsDeferred =
|
||||||
|
async {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = itemId,
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
sortBy =
|
||||||
|
listOf(
|
||||||
|
ItemSortBy.PARENT_INDEX_NUMBER,
|
||||||
|
ItemSortBy.INDEX_NUMBER,
|
||||||
|
ItemSortBy.SORT_NAME,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||||
|
}
|
||||||
|
val album = itemDeferred.await()
|
||||||
|
val songs = songsDeferred.await()
|
||||||
|
val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY)
|
||||||
|
_state.update {
|
||||||
|
AlbumState(
|
||||||
|
album = album,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
songs = songs,
|
||||||
|
loading = LoadingState.Success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlbumState(
|
||||||
|
val album: BaseItem?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val songs: List<BaseItem?>,
|
||||||
|
val loading: LoadingState,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = AlbumState(null, null, emptyList(), LoadingState.Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumDetails(
|
||||||
|
itemId: UUID,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AlbumViewModel =
|
||||||
|
hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(itemId) },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
when (val loading = state.loading) {
|
||||||
|
is LoadingState.Error -> {
|
||||||
|
ErrorMessage(loading, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Loading,
|
||||||
|
LoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Success -> {
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
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 = state.album!!,
|
||||||
|
imageUrl = state.imageUrl,
|
||||||
|
overviewOnClick = {},
|
||||||
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
AlbumButtons(
|
||||||
|
onClickPlay = {},
|
||||||
|
onClickAddToPlaylist = {},
|
||||||
|
onClickGoToArtist = {},
|
||||||
|
onClickMore = { },
|
||||||
|
buttonOnFocusChanged = {},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(state.songs) { index, song ->
|
||||||
|
SongListItem(
|
||||||
|
song = song,
|
||||||
|
onClick = {},
|
||||||
|
onClickAddToQueue = {},
|
||||||
|
onClickAddToPlaylist = {},
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
showArtist = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
modifier = Modifier.fillMaxWidth(.25f),
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumButtons(
|
||||||
|
onClickPlay: (Boolean) -> Unit,
|
||||||
|
onClickAddToPlaylist: () -> Unit,
|
||||||
|
onClickGoToArtist: () -> Unit,
|
||||||
|
onClickMore: () -> Unit,
|
||||||
|
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 {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.play,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.PlayArrow,
|
||||||
|
onClick = { onClickPlay.invoke(false) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged)
|
||||||
|
.focusRequester(firstFocus),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.shuffle,
|
||||||
|
iconStringRes = R.string.fa_shuffle,
|
||||||
|
onClick = { onClickPlay.invoke(true) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.go_to_artist,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.AccountCircle,
|
||||||
|
onClick = { onClickGoToArtist.invoke() },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
|
import androidx.tv.material3.ListItemDefaults
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
|
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 SongListDisplay(
|
||||||
|
songs: List<BaseItem?>,
|
||||||
|
showArtist: Boolean,
|
||||||
|
onClick: (Int, BaseItem) -> Unit,
|
||||||
|
onClickAddToQueue: (Int, BaseItem) -> Unit,
|
||||||
|
onClickAddToPlaylist: (Int, BaseItem) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
itemsIndexed(songs) { index, song ->
|
||||||
|
SongListItem(
|
||||||
|
song = song,
|
||||||
|
onClick = { song?.let { onClick.invoke(index, song) } },
|
||||||
|
onClickAddToQueue = { song?.let { onClick.invoke(index, song) } },
|
||||||
|
onClickAddToPlaylist = { song?.let { onClick.invoke(index, song) } },
|
||||||
|
showArtist = showArtist,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SongListItem(
|
||||||
|
song: BaseItem?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onClickAddToQueue: () -> Unit,
|
||||||
|
onClickAddToPlaylist: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showArtist: Boolean = false,
|
||||||
|
) = SongListItem(
|
||||||
|
title = song?.title,
|
||||||
|
artist = if (showArtist) song?.artistsString else null,
|
||||||
|
indexNumber = song?.data?.indexNumber,
|
||||||
|
runtime =
|
||||||
|
song
|
||||||
|
?.data
|
||||||
|
?.runTimeTicks
|
||||||
|
?.ticks
|
||||||
|
?.roundMinutes,
|
||||||
|
onClick = onClick,
|
||||||
|
onClickAddToQueue = onClickAddToQueue,
|
||||||
|
onClickAddToPlaylist = onClickAddToPlaylist,
|
||||||
|
modifier = modifier,
|
||||||
|
showArtist = showArtist,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SongListItem(
|
||||||
|
title: String?,
|
||||||
|
artist: String?,
|
||||||
|
indexNumber: Int?,
|
||||||
|
runtime: Duration?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onClickAddToQueue: () -> Unit,
|
||||||
|
onClickAddToPlaylist: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showArtist: Boolean = false,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
selected = false,
|
||||||
|
onClick = onClick,
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = indexNumber?.toString() ?: "",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = title ?: "",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent =
|
||||||
|
if (showArtist && artist.isNotNullOrBlank()) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = artist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Text(
|
||||||
|
text = runtime.toString(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scale = ListItemDefaults.scale(1f, 1f),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onClickAddToQueue,
|
||||||
|
shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Add to queue",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") }
|
||||||
|
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
fun SongListItemPreview() {
|
||||||
|
WholphinTheme {
|
||||||
|
Column {
|
||||||
|
SongListItem(
|
||||||
|
title = "Song title",
|
||||||
|
artist = "Artists",
|
||||||
|
indexNumber = 1,
|
||||||
|
runtime = 2.minutes + 30.seconds,
|
||||||
|
onClick = {},
|
||||||
|
onClickAddToQueue = { },
|
||||||
|
onClickAddToPlaylist = {},
|
||||||
|
modifier = Modifier,
|
||||||
|
showArtist = false,
|
||||||
|
)
|
||||||
|
SongListItem(
|
||||||
|
title = "Song title",
|
||||||
|
artist = "Artists",
|
||||||
|
indexNumber = 1,
|
||||||
|
runtime = 2.minutes + 30.seconds,
|
||||||
|
onClick = {},
|
||||||
|
onClickAddToQueue = { },
|
||||||
|
onClickAddToPlaylist = {},
|
||||||
|
modifier = Modifier,
|
||||||
|
showArtist = 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.CollectionFolderGeneric
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
|
||||||
|
|
@ -28,6 +29,7 @@ import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetails
|
||||||
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
|
||||||
|
|
@ -207,6 +209,14 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ALBUM -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
|
AlbumDetails(
|
||||||
|
itemId = destination.itemId,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unsupported item type: ${destination.type}")
|
Timber.w("Unsupported item type: ${destination.type}")
|
||||||
Text("Unsupported item type: ${destination.type}")
|
Text("Unsupported item type: ${destination.type}")
|
||||||
|
|
@ -379,9 +389,16 @@ fun CollectionFolder(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CollectionType.MUSIC -> {
|
||||||
|
CollectionFolderMusic(
|
||||||
|
preferences,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
CollectionType.HOMEVIDEOS,
|
CollectionType.HOMEVIDEOS,
|
||||||
CollectionType.MUSICVIDEOS,
|
CollectionType.MUSICVIDEOS,
|
||||||
CollectionType.MUSIC,
|
|
||||||
CollectionType.BOOKS,
|
CollectionType.BOOKS,
|
||||||
CollectionType.PHOTOS,
|
CollectionType.PHOTOS,
|
||||||
-> {
|
-> {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ val supportedCollectionTypes =
|
||||||
CollectionType.LIVETV,
|
CollectionType.LIVETV,
|
||||||
CollectionType.MUSICVIDEOS,
|
CollectionType.MUSICVIDEOS,
|
||||||
CollectionType.FOLDERS,
|
CollectionType.FOLDERS,
|
||||||
|
CollectionType.MUSIC,
|
||||||
null, // Mixed
|
null, // Mixed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,10 @@
|
||||||
<string name="send_media_info_log_to_server">Send media info log to server</string>
|
<string name="send_media_info_log_to_server">Send media info log to server</string>
|
||||||
<string name="no_limit">No limit</string>
|
<string name="no_limit">No limit</string>
|
||||||
<string name="max_days_next_up">Max days in Next Up</string>
|
<string name="max_days_next_up">Max days in Next Up</string>
|
||||||
|
<string name="albums">Albums</string>
|
||||||
|
<string name="artists">Artists</string>
|
||||||
|
<string name="songs">Songs</string>
|
||||||
|
<string name="go_to_artist">Go to artist</string>
|
||||||
|
|
||||||
<string-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