diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/NavigationRoot.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/NavigationRoot.kt index 6f46219a17..19d71959ff 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/NavigationRoot.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/NavigationRoot.kt @@ -23,6 +23,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.navigation import dev.jdtech.jellyfin.presentation.film.HomeScreen +import dev.jdtech.jellyfin.presentation.film.MediaScreen import dev.jdtech.jellyfin.presentation.setup.addserver.AddServerScreen import dev.jdtech.jellyfin.presentation.setup.login.LoginScreen import dev.jdtech.jellyfin.presentation.setup.servers.ServersScreen @@ -52,6 +53,9 @@ data object FilmGraphRoute @Serializable data object HomeRoute +@Serializable +data object MediaRoute + data class TabBarItem( val title: String, @DrawableRes val icon: Int, @@ -59,10 +63,10 @@ data class TabBarItem( ) val homeTab = TabBarItem(title = "Home", icon = CoreR.drawable.ic_home, route = HomeRoute) -// val mediaTab = TabBarItem(title = "Media", icon = CoreR.drawable.ic_library, route = Unit) +val mediaTab = TabBarItem(title = "Media", icon = CoreR.drawable.ic_library, route = MediaRoute) // val downloadsTab = TabBarItem(title = "Downloads", icon = CoreR.drawable.ic_download, route = Unit) -val tabBarItems = listOf(homeTab) +val tabBarItems = listOf(homeTab, mediaTab) @Composable fun NavigationRoot( @@ -211,6 +215,9 @@ fun NavigationRoot( composable { HomeScreen() } + composable { + MediaScreen() + } } } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/CollectionListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/CollectionListAdapter.kt deleted file mode 100644 index 2075e314df..0000000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/CollectionListAdapter.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.jdtech.jellyfin.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dev.jdtech.jellyfin.bindCardItemImage -import dev.jdtech.jellyfin.databinding.CollectionItemBinding -import dev.jdtech.jellyfin.models.FindroidCollection - -class CollectionListAdapter( - private val onClickListener: (collection: FindroidCollection) -> Unit, -) : ListAdapter(DiffCallback) { - class CollectionViewHolder(private var binding: CollectionItemBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(collection: FindroidCollection) { - binding.collectionName.text = collection.name - bindCardItemImage(binding.collectionImage, collection) - } - } - - companion object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: FindroidCollection, newItem: FindroidCollection): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: FindroidCollection, newItem: FindroidCollection): Boolean { - return oldItem == newItem - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CollectionViewHolder { - return CollectionViewHolder( - CollectionItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - ) - } - - override fun onBindViewHolder(holder: CollectionViewHolder, position: Int) { - val collection = getItem(position) - holder.itemView.setOnClickListener { - onClickListener(collection) - } - holder.bind(collection) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt deleted file mode 100644 index eb30bc1ba4..0000000000 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt +++ /dev/null @@ -1,164 +0,0 @@ -package dev.jdtech.jellyfin.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import androidx.appcompat.widget.SearchView -import androidx.core.view.MenuHost -import androidx.core.view.MenuProvider -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import dagger.hilt.android.AndroidEntryPoint -import dev.jdtech.jellyfin.adapters.CollectionListAdapter -import dev.jdtech.jellyfin.databinding.FragmentMediaBinding -import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment -import dev.jdtech.jellyfin.models.FindroidCollection -import dev.jdtech.jellyfin.utils.checkIfLoginRequired -import dev.jdtech.jellyfin.utils.safeNavigate -import dev.jdtech.jellyfin.viewmodels.MediaViewModel -import kotlinx.coroutines.launch -import timber.log.Timber -import dev.jdtech.jellyfin.core.R as CoreR - -@AndroidEntryPoint -class MediaFragment : Fragment() { - - private lateinit var binding: FragmentMediaBinding - private val viewModel: MediaViewModel by viewModels() - - private var originalSoftInputMode: Int? = null - - private lateinit var errorDialog: ErrorDialogFragment - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentMediaBinding.inflate(inflater, container, false) - - binding.viewsRecyclerView.adapter = - CollectionListAdapter { library -> - navigateToLibraryFragment(library) - } - - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { uiState -> - Timber.d("$uiState") - when (uiState) { - is MediaViewModel.UiState.Normal -> bindUiStateNormal(uiState) - is MediaViewModel.UiState.Loading -> bindUiStateLoading() - is MediaViewModel.UiState.Error -> bindUiStateError(uiState) - } - } - } - } - - binding.errorLayout.errorRetryButton.setOnClickListener { - viewModel.loadData() - } - - binding.errorLayout.errorDetailsButton.setOnClickListener { - errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val menuHost: MenuHost = requireActivity() - menuHost.addMenuProvider( - object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(CoreR.menu.media_menu, menu) - - val search = menu.findItem(CoreR.id.action_search) - val searchView = search.actionView as SearchView - searchView.queryHint = getString(CoreR.string.search_hint) - - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(p0: String?): Boolean { - if (p0 != null) { - navigateToSearchResultFragment(p0) - } - return true - } - - override fun onQueryTextChange(p0: String?): Boolean { - return false - } - }) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return true - } - }, - viewLifecycleOwner, - Lifecycle.State.RESUMED, - ) - } - - override fun onStart() { - super.onStart() - requireActivity().window.let { - originalSoftInputMode = it.attributes?.softInputMode - it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) - } - } - - override fun onStop() { - super.onStop() - originalSoftInputMode?.let { activity?.window?.setSoftInputMode(it) } - } - - private fun bindUiStateNormal(uiState: MediaViewModel.UiState.Normal) { - binding.loadingIndicator.isVisible = false - binding.viewsRecyclerView.isVisible = true - binding.errorLayout.errorPanel.isVisible = false - val adapter = binding.viewsRecyclerView.adapter as CollectionListAdapter - adapter.submitList(uiState.collections) - } - - private fun bindUiStateLoading() { - binding.loadingIndicator.isVisible = true - binding.errorLayout.errorPanel.isVisible = false - } - - private fun bindUiStateError(uiState: MediaViewModel.UiState.Error) { - errorDialog = ErrorDialogFragment.newInstance(uiState.error) - binding.loadingIndicator.isVisible = false - binding.viewsRecyclerView.isVisible = false - binding.errorLayout.errorPanel.isVisible = true - checkIfLoginRequired(uiState.error.message) - } - - private fun navigateToLibraryFragment(library: FindroidCollection) { - findNavController().safeNavigate( - MediaFragmentDirections.actionNavigationMediaToLibraryFragment( - libraryId = library.id, - libraryName = library.name, - libraryType = library.type, - ), - ) - } - - private fun navigateToSearchResultFragment(query: String) { - findNavController().safeNavigate( - MediaFragmentDirections.actionNavigationMediaToSearchResultFragment(query), - ) - } -} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/HomeScreen.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/HomeScreen.kt index 80df01cd62..0e0bc68930 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/HomeScreen.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/HomeScreen.kt @@ -1,6 +1,5 @@ package dev.jdtech.jellyfin.presentation.film -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,11 +16,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SearchBar -import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -34,7 +29,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics @@ -51,6 +45,7 @@ import dev.jdtech.jellyfin.film.presentation.home.HomeViewModel import dev.jdtech.jellyfin.presentation.components.ErrorDialog import dev.jdtech.jellyfin.presentation.film.components.Direction import dev.jdtech.jellyfin.presentation.film.components.ErrorCard +import dev.jdtech.jellyfin.presentation.film.components.FilmSearchBar import dev.jdtech.jellyfin.presentation.film.components.ItemCard import dev.jdtech.jellyfin.presentation.theme.FindroidTheme import dev.jdtech.jellyfin.presentation.theme.spacings @@ -84,28 +79,18 @@ private fun HomeScreenLayout( val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current - val startPadding = with(density) { WindowInsets.safeDrawing.getLeft(this, layoutDirection).toDp() + MaterialTheme.spacings.default } - val endPadding = with(density) { WindowInsets.safeDrawing.getRight(this, layoutDirection).toDp() + MaterialTheme.spacings.default } + val safePaddingStart = with(density) { WindowInsets.safeDrawing.getLeft(this, layoutDirection).toDp() } + val safePaddingEnd = with(density) { WindowInsets.safeDrawing.getRight(this, layoutDirection).toDp() } - val itemsPadding = PaddingValues( - start = startPadding, - end = endPadding, - ) + val paddingStart = safePaddingStart + MaterialTheme.spacings.default + val paddingEnd = safePaddingEnd + MaterialTheme.spacings.default - var searchQuery by rememberSaveable { mutableStateOf("") } - var expanded by rememberSaveable { mutableStateOf(false) } - - val searchBarPaddingStart by animateDpAsState( - targetValue = if (expanded) 0.dp else startPadding, - label = "search_bar_padding_start", - ) - - val searchBarPaddingEnd by animateDpAsState( - targetValue = if (expanded) 0.dp else endPadding, - label = "search_bar_padding_end", + val itemsPadding = PaddingValues( + start = paddingStart, + end = paddingEnd, ) - val contentTopPadding by animateDpAsState( + val contentPaddingTop by animateDpAsState( targetValue = if (state.error != null) { with(density) { WindowInsets.safeDrawing.getTop(this).toDp() + 136.dp } } else { @@ -121,85 +106,21 @@ private fun HomeScreenLayout( .fillMaxSize() .semantics { isTraversalGroup = true }, ) { - SearchBar( - inputField = { - SearchBarDefaults.InputField( - query = searchQuery, - onQueryChange = { searchQuery = it }, - onSearch = { expanded = true }, - expanded = expanded, - onExpandedChange = { expanded = it }, - placeholder = { Text(stringResource(FilmR.string.search_placeholder)) }, - leadingIcon = { - AnimatedContent( - targetState = expanded, - label = "search_to_back", - ) { targetExpanded -> - if (targetExpanded) { - IconButton( - onClick = { - expanded = false - }, - ) { - Icon( - painter = painterResource(CoreR.drawable.ic_arrow_left), - contentDescription = null, - ) - } - } else { - Icon( - painter = painterResource(CoreR.drawable.ic_search), - contentDescription = null, - ) - } - } - }, - trailingIcon = { - AnimatedContent( - targetState = expanded, - label = "search_to_back", - ) { targetExpanded -> - if (targetExpanded) { - IconButton( - onClick = { - searchQuery = "" - }, - ) { - Icon( - painter = painterResource(CoreR.drawable.ic_x), - contentDescription = null, - ) - } - } else { - IconButton( - onClick = {}, - ) { - Icon( - painter = painterResource(CoreR.drawable.ic_user), - contentDescription = null, - ) - } - } - } - }, - ) - }, - expanded = expanded, - onExpandedChange = { expanded = it }, + FilmSearchBar( modifier = Modifier .fillMaxWidth() - .padding( - start = searchBarPaddingStart, - end = searchBarPaddingEnd, - ) .semantics { traversalIndex = 0f }, - ) { } + paddingStart = paddingStart, + paddingEnd = paddingEnd, + inputPaddingStart = safePaddingStart, + inputPaddingEnd = safePaddingEnd, + ) LazyColumn( modifier = Modifier .fillMaxSize() .semantics { traversalIndex = 1f }, contentPadding = PaddingValues( - top = contentTopPadding, + top = contentPaddingTop, bottom = with(density) { WindowInsets.safeDrawing.getBottom(this).toDp() + MaterialTheme.spacings.default }, ), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small), @@ -236,7 +157,9 @@ private fun HomeScreenLayout( } } items(state.views, key = { it.id }) { view -> - Column { + Column( + modifier = Modifier.animateItem(), + ) { Box( modifier = Modifier .fillMaxWidth() @@ -282,9 +205,9 @@ private fun HomeScreenLayout( modifier = Modifier .fillMaxWidth() .padding( - start = startPadding, + start = paddingStart, top = with(density) { WindowInsets.safeDrawing.getTop(this).toDp() + 80.dp }, - end = endPadding, + end = paddingEnd, ), ) if (showErrorDialog) { diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/MediaScreen.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/MediaScreen.kt new file mode 100644 index 0000000000..405ff8e437 --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/MediaScreen.kt @@ -0,0 +1,178 @@ +package dev.jdtech.jellyfin.presentation.film + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowWidthSizeClass +import dev.jdtech.jellyfin.core.presentation.dummy.dummyCollections +import dev.jdtech.jellyfin.film.presentation.media.MediaAction +import dev.jdtech.jellyfin.film.presentation.media.MediaState +import dev.jdtech.jellyfin.film.presentation.media.MediaViewModel +import dev.jdtech.jellyfin.presentation.components.ErrorDialog +import dev.jdtech.jellyfin.presentation.film.components.Direction +import dev.jdtech.jellyfin.presentation.film.components.ErrorCard +import dev.jdtech.jellyfin.presentation.film.components.FavoritesCard +import dev.jdtech.jellyfin.presentation.film.components.FilmSearchBar +import dev.jdtech.jellyfin.presentation.film.components.ItemCard +import dev.jdtech.jellyfin.presentation.theme.FindroidTheme +import dev.jdtech.jellyfin.presentation.theme.spacings + +@Composable +fun MediaScreen( + viewModel: MediaViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(true) { + viewModel.loadData() + } + + MediaScreenLayout( + state = state, + onAction = { action -> + viewModel.onAction(action) + }, + ) +} + +@Composable +private fun MediaScreenLayout( + state: MediaState, + onAction: (MediaAction) -> Unit, +) { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + + val safePaddingStart = with(density) { WindowInsets.safeDrawing.getLeft(this, layoutDirection).toDp() } + val safePaddingTop = with(density) { WindowInsets.safeDrawing.getTop(this).toDp() } + val safePaddingEnd = with(density) { WindowInsets.safeDrawing.getRight(this, layoutDirection).toDp() } + val safePaddingBottom = with(density) { WindowInsets.safeDrawing.getBottom(this).toDp() } + + val paddingStart = safePaddingStart + MaterialTheme.spacings.default + val paddingEnd = safePaddingEnd + MaterialTheme.spacings.default + val paddingBottom = safePaddingBottom + MaterialTheme.spacings.default + + val contentPaddingTop by animateDpAsState( + targetValue = if (state.error != null) { + safePaddingTop + 142.dp + } else { + safePaddingTop + 88.dp + }, + label = "content_padding", + ) + + var showErrorDialog by rememberSaveable { mutableStateOf(false) } + + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val minColumnSize = when (windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.EXPANDED -> 320.dp + WindowWidthSizeClass.MEDIUM -> 240.dp + else -> 160.dp + } + + Box( + modifier = Modifier + .fillMaxSize(), + ) { + FilmSearchBar( + modifier = Modifier.fillMaxWidth(), + paddingStart = paddingStart, + paddingEnd = paddingEnd, + inputPaddingStart = safePaddingStart, + inputPaddingEnd = safePaddingEnd, + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = minColumnSize), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = paddingStart, + top = contentPaddingTop, + end = paddingEnd, + bottom = paddingBottom, + ), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + ) { + item( + span = { GridItemSpan(maxLineSpan) }, + ) { + FavoritesCard( + onClick = {}, + ) + } + items(state.libraries, key = { it.id }) { library -> + ItemCard( + item = library, + direction = Direction.HORIZONTAL, + onClick = { + onAction(MediaAction.OnItemClick(library)) + }, + modifier = Modifier + .animateItem(), + ) + } + } + if (state.error != null) { + ErrorCard( + onShowStacktrace = { + showErrorDialog = true + }, + onRetryClick = { + onAction(MediaAction.OnRetryClick) + }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = paddingStart, + top = safePaddingTop + 80.dp, + end = paddingEnd, + ), + ) + if (showErrorDialog) { + ErrorDialog( + exception = state.error!!, + onDismissRequest = { showErrorDialog = false }, + ) + } + } + } +} + +@PreviewScreenSizes +@Composable +private fun MediaScreenLayoutPreview() { + FindroidTheme { + MediaScreenLayout( + state = MediaState( + libraries = dummyCollections, + error = Exception("Failed to load data"), + ), + onAction = {}, + ) + } +} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/components/FavoritesCard.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/components/FavoritesCard.kt new file mode 100644 index 0000000000..7218856497 --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/components/FavoritesCard.kt @@ -0,0 +1,56 @@ +package dev.jdtech.jellyfin.presentation.film.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dev.jdtech.jellyfin.presentation.theme.FindroidTheme +import dev.jdtech.jellyfin.presentation.theme.spacings +import dev.jdtech.jellyfin.core.R as CoreR + +@Composable +fun FavoritesCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + onClick = onClick, + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(MaterialTheme.spacings.medium), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(CoreR.drawable.ic_star), + contentDescription = null, + ) + Text( + text = stringResource(CoreR.string.title_favorite), + ) + } + } +} + +@Preview +@Composable +private fun FavoritesCardPreview() { + FindroidTheme { + FavoritesCard( + onClick = {}, + modifier = Modifier.width(320.dp), + ) + } +} diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/components/SearchBar.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/components/SearchBar.kt new file mode 100644 index 0000000000..739d06fc2a --- /dev/null +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/presentation/film/components/SearchBar.kt @@ -0,0 +1,137 @@ +package dev.jdtech.jellyfin.presentation.film.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.film.R as FilmR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilmSearchBar( + modifier: Modifier = Modifier, + paddingStart: Dp = 0.dp, + paddingEnd: Dp = 0.dp, + inputPaddingStart: Dp = 0.dp, + inputPaddingEnd: Dp = 0.dp, +) { + var searchQuery by rememberSaveable { mutableStateOf("") } + var expanded by rememberSaveable { mutableStateOf(false) } + + val searchBarPaddingStart by animateDpAsState( + targetValue = if (expanded) 0.dp else paddingStart, + label = "search_bar_padding_start", + ) + + val searchBarPaddingEnd by animateDpAsState( + targetValue = if (expanded) 0.dp else paddingEnd, + label = "search_bar_padding_end", + ) + + val searchBarInputPaddingStart by animateDpAsState( + targetValue = if (expanded) inputPaddingStart else 0.dp, + label = "search_bar_padding_start", + ) + + val searchBarInputPaddingEnd by animateDpAsState( + targetValue = if (expanded) inputPaddingEnd else 0.dp, + label = "search_bar_padding_end", + ) + + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onSearch = { expanded = true }, + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier + .padding(start = searchBarInputPaddingStart, end = searchBarInputPaddingEnd), + placeholder = { + Text( + text = stringResource(FilmR.string.search_placeholder), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + leadingIcon = { + AnimatedContent( + targetState = expanded, + label = "search_to_back", + ) { targetExpanded -> + if (targetExpanded) { + IconButton( + onClick = { + expanded = false + }, + ) { + Icon( + painter = painterResource(CoreR.drawable.ic_arrow_left), + contentDescription = null, + ) + } + } else { + Icon( + painter = painterResource(CoreR.drawable.ic_search), + contentDescription = null, + ) + } + } + }, + trailingIcon = { + AnimatedContent( + targetState = expanded, + label = "search_to_back", + ) { targetExpanded -> + if (targetExpanded) { + IconButton( + onClick = { + searchQuery = "" + }, + ) { + Icon( + painter = painterResource(CoreR.drawable.ic_x), + contentDescription = null, + ) + } + } else { + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(CoreR.drawable.ic_user), + contentDescription = null, + ) + } + } + } + }, + ) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier + .padding( + start = searchBarPaddingStart, + end = searchBarPaddingEnd, + ), + ) { } +} diff --git a/app/phone/src/main/res/layout/fragment_media.xml b/app/phone/src/main/res/layout/fragment_media.xml deleted file mode 100644 index 22e977213d..0000000000 --- a/app/phone/src/main/res/layout/fragment_media.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - diff --git a/app/phone/src/main/res/navigation/app_navigation.xml b/app/phone/src/main/res/navigation/app_navigation.xml index f1bd0a211b..e607a30e78 100644 --- a/app/phone/src/main/res/navigation/app_navigation.xml +++ b/app/phone/src/main/res/navigation/app_navigation.xml @@ -3,24 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/app_navigation" - app:startDestination="@+id/mediaFragment"> - - - - - + app:startDestination="@+id/twoPaneSettingsFragment"> Unit, isLoading: (Boolean) -> Unit, - mediaViewModel: MediaViewModel = hiltViewModel(), + viewModel: MediaViewModel = hiltViewModel(), ) { - val delegatedUiState by mediaViewModel.uiState.collectAsState() + val state by viewModel.state.collectAsState() + + LaunchedEffect(true) { + viewModel.loadData() + } + + LaunchedEffect(state.isLoading) { + isLoading(state.isLoading) + } LibrariesScreenLayout( - uiState = delegatedUiState, - isLoading = isLoading, - onClick = navigateToLibrary, + state = state, + onAction = { action -> + when (action) { + is MediaAction.OnItemClick -> { + navigateToLibrary(action.item.id, action.item.name, action.item.type) + } + else -> Unit + } + viewModel.onAction(action) + }, ) } @Composable private fun LibrariesScreenLayout( - uiState: MediaViewModel.UiState, - isLoading: (Boolean) -> Unit, - onClick: (UUID, String, CollectionType) -> Unit, + state: MediaState, + onAction: (MediaAction) -> Unit, ) { - var collections: List by remember { - mutableStateOf(emptyList()) - } + val focusRequester = remember { FocusRequester() } - when (uiState) { - is MediaViewModel.UiState.Normal -> { - collections = uiState.collections - isLoading(false) - } - is MediaViewModel.UiState.Loading -> { - isLoading(true) - } - else -> Unit + LaunchedEffect(state.libraries) { + focusRequester.requestFocus() } - val focusRequester = remember { FocusRequester() } - LazyVerticalGrid( columns = GridCells.Fixed(3), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large), @@ -78,19 +80,16 @@ private fun LibrariesScreenLayout( ), modifier = Modifier.focusRequester(focusRequester), ) { - items(collections, key = { it.id }) { collection -> + items(state.libraries, key = { it.id }) { library -> ItemCard( - item = collection, + item = library, direction = Direction.HORIZONTAL, onClick = { - onClick(collection.id, collection.name, collection.type) + onAction(MediaAction.OnItemClick(library)) }, ) } } - LaunchedEffect(collections) { - focusRequester.requestFocus() - } } @Preview(device = "id:tv_1080p") @@ -98,9 +97,8 @@ private fun LibrariesScreenLayout( private fun LibrariesScreenLayoutPreview() { FindroidTheme { LibrariesScreenLayout( - uiState = MediaViewModel.UiState.Normal(dummyCollections), - isLoading = {}, - onClick = { _, _, _ -> }, + state = MediaState(libraries = dummyCollections), + onAction = {}, ) } } diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt index 85c04def2f..60ed3650fb 100644 --- a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt @@ -43,6 +43,7 @@ import dev.jdtech.jellyfin.models.CollectionType import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.User import dev.jdtech.jellyfin.presentation.film.HomeScreen +import dev.jdtech.jellyfin.presentation.film.MediaScreen import dev.jdtech.jellyfin.presentation.theme.FindroidTheme import dev.jdtech.jellyfin.presentation.theme.spacings import dev.jdtech.jellyfin.ui.components.LoadingIndicator @@ -202,7 +203,7 @@ private fun MainScreenLayout( ) } 2 -> { - LibrariesScreen( + MediaScreen( navigateToLibrary = navigateToLibrary, isLoading = { isLoading = it }, ) diff --git a/core/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaViewModel.kt b/core/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaViewModel.kt deleted file mode 100644 index 57c12ac1c9..0000000000 --- a/core/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaViewModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.jdtech.jellyfin.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import dev.jdtech.jellyfin.models.FindroidCollection -import dev.jdtech.jellyfin.repository.JellyfinRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MediaViewModel -@Inject -constructor( - private val jellyfinRepository: JellyfinRepository, -) : ViewModel() { - - private val _uiState = MutableStateFlow(UiState.Loading) - val uiState = _uiState.asStateFlow() - - sealed class UiState { - data class Normal(val collections: List) : UiState() - data object Loading : UiState() - data class Error(val error: Exception) : UiState() - } - - init { - loadData() - } - - fun loadData() { - viewModelScope.launch { - _uiState.emit(UiState.Loading) - try { - val collections = jellyfinRepository.getLibraries() - _uiState.emit(UiState.Normal(collections)) - } catch (e: Exception) { - _uiState.emit( - UiState.Error(e), - ) - } - } - } -} diff --git a/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaAction.kt b/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaAction.kt new file mode 100644 index 0000000000..d52bcc5e0e --- /dev/null +++ b/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaAction.kt @@ -0,0 +1,8 @@ +package dev.jdtech.jellyfin.film.presentation.media + +import dev.jdtech.jellyfin.models.FindroidCollection + +sealed interface MediaAction { + data class OnItemClick(val item: FindroidCollection) : MediaAction + data object OnRetryClick : MediaAction +} diff --git a/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaState.kt b/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaState.kt new file mode 100644 index 0000000000..5f0d9519a5 --- /dev/null +++ b/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaState.kt @@ -0,0 +1,9 @@ +package dev.jdtech.jellyfin.film.presentation.media + +import dev.jdtech.jellyfin.models.FindroidCollection + +data class MediaState( + val libraries: List = emptyList(), + val isLoading: Boolean = false, + val error: Exception? = null, +) diff --git a/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaViewModel.kt b/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaViewModel.kt new file mode 100644 index 0000000000..dde905c3b1 --- /dev/null +++ b/modes/film/src/main/java/dev/jdtech/jellyfin/film/presentation/media/MediaViewModel.kt @@ -0,0 +1,42 @@ +package dev.jdtech.jellyfin.film.presentation.media + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.jdtech.jellyfin.repository.JellyfinRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MediaViewModel +@Inject +constructor( + val repository: JellyfinRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MediaState()) + val state = _state.asStateFlow() + + fun loadData() { + viewModelScope.launch { + _state.emit(_state.value.copy(isLoading = true, error = null)) + try { + val libraries = repository.getLibraries() + _state.emit(_state.value.copy(libraries = libraries)) + } catch (e: Exception) { + _state.emit(_state.value.copy(error = e)) + } + _state.emit(_state.value.copy(isLoading = false)) + } + } + + fun onAction(action: MediaAction) { + when (action) { + is MediaAction.OnRetryClick -> { + loadData() + } + else -> Unit + } + } +}