Compare commits

..

7 Commits

17 changed files with 181 additions and 64 deletions

View File

@@ -17,7 +17,7 @@ android {
applicationId = "hu.bbara.purefin"
minSdk = 29
targetSdk = 36
versionCode = 1000005
versionCode = 1000008
versionName = "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -1,10 +1,12 @@
package hu.bbara.purefin.ui.common.card
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,11 +14,22 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
@@ -26,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaAction
@Composable
fun MediaImageCard(
@@ -34,6 +48,7 @@ fun MediaImageCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
popupActions: List<MediaAction> = emptyList(),
imageModifier: Modifier = Modifier,
shapeSize: Dp = 12.dp,
imageAspectRatio: Float = 16f / 10f,
@@ -45,6 +60,8 @@ fun MediaImageCard(
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(shapeSize)
var expanded by remember { mutableStateOf(false) }
Card(
onClick = onClick,
shape = shape,
@@ -67,26 +84,55 @@ fun MediaImageCard(
)
imageOverlay()
}
Column(modifier = Modifier.padding(textPadding)) {
Text(
text = title,
style = titleStyle,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
subtitle
?.takeIf { it.isNotBlank() }
?.let { text ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
style = subtitleStyle,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.padding(textPadding)) {
Text(
text = title,
style = titleStyle,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
subtitle
?.takeIf { it.isNotBlank() }
?.let { text ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
style = subtitleStyle,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (popupActions.isNotEmpty()) {
Box() {
IconButton(
onClick = { expanded = !expanded },
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "More actions"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
popupActions.forEach { action ->
DropdownMenuItem(
text = { Text(text = action.name) },
onClick = action.onClick
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
package hu.bbara.purefin.ui.model
data class MediaAction(
val name: String,
val onClick: () -> Unit
)

View File

@@ -91,6 +91,7 @@ fun AppScreen(
library.name
)
},
onMarkWatched = viewModel::markAsWatched,
onProfileClick = {},
onCheckForUpdates = { updateViewModel.checkForUpdates() },
isCheckingForUpdates = isCheckingForUpdates,

View File

@@ -26,6 +26,7 @@ fun HomeScreen(
onRefresh: () -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit,
onMarkWatched: (MediaUiModel, Boolean) -> Unit,
onProfileClick: () -> Unit,
onCheckForUpdates: () -> Unit,
isCheckingForUpdates: Boolean,
@@ -71,6 +72,7 @@ fun HomeScreen(
onMediaSelected = onMediaSelected,
onLibrarySelected = onLibrarySelected,
onBrowseLibrariesClick = { onTabSelected(1) },
onMarkAsWatched = onMarkWatched,
modifier = Modifier.padding(innerPadding)
)
}

View File

@@ -20,16 +20,12 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
@@ -71,14 +67,7 @@ fun DefaultTopBar(
}
) {
PurefinLogo(
modifier = Modifier.size(84.dp),
)
Text(
text = "PureFin",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
color = scheme.primary
modifier = Modifier.size(56.dp),
)
}
if (rightActions != null) {

View File

@@ -42,6 +42,7 @@ fun HomeContent(
onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit,
onBrowseLibrariesClick: () -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -105,6 +106,7 @@ fun HomeContent(
item(key = "continue-watching") {
ContinueWatchingSection(
items = continueWatching,
onMarkAsWatched = onMarkAsWatched,
onMediaSelected = onMediaSelected
)
}

View File

@@ -15,6 +15,7 @@ import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
@@ -22,6 +23,7 @@ internal fun ContinueWatchingCard(
item: MediaUiModel,
sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -34,6 +36,16 @@ internal fun ContinueWatchingCard(
title = item.primaryText,
subtitle = item.secondaryText,
onClick = onClick,
popupActions = listOf(
MediaAction(
name = "Mark as watched",
onClick = { onMarkAsWatched(item, true) }
),
MediaAction(
name = "Mark as unwatched",
onClick = { onMarkAsWatched(item, false) }
)
),
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
shapeSize = 26.dp,
imageAspectRatio = 16f / 9f,

View File

@@ -21,6 +21,7 @@ import hu.bbara.purefin.ui.model.MediaUiModel
fun ContinueWatchingSection(
items: List<MediaUiModel>,
onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
@@ -47,7 +48,8 @@ fun ContinueWatchingSection(
ContinueWatchingCard(
item = item,
sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id),
onMediaSelected = onMediaSelected
onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched
)
}
}

View File

@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
@@ -98,7 +97,7 @@ fun SearchScreen(
Modifier
}
SearchFullScreenContent(
SearchScreenContent(
query = query,
searchResults = searchResults,
genres = genres.sortedBy { it.name },
@@ -128,7 +127,7 @@ fun SearchScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SearchFullScreenContent(
private fun SearchScreenContent(
query: String,
searchResults: List<SearchResult>,
genres: List<Genre>,
@@ -149,8 +148,6 @@ private fun SearchFullScreenContent(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.statusBarsPadding()
.padding(top = 24.dp)
) { innerPadding ->
Column(
modifier = Modifier
@@ -219,10 +216,7 @@ private fun SearchResults(
@Composable
private fun SearchHeader(
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
DefaultTopBar(
leftActions = {
DefaultTopBarIconButton(
@@ -477,11 +471,11 @@ private fun GenreChip(
@Preview(showBackground = true)
@Composable
private fun SearchFullScreenPreview() {
private fun SearchScreenPreview() {
var query by rememberSaveable { mutableStateOf("du") }
AppTheme {
SearchFullScreenContent(
SearchScreenContent(
query = query,
searchResults = previewSearchResults,
genres = previewGenres,

View File

@@ -1,14 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="600dp"
android:height="552dp"
android:viewportWidth="600"
android:viewportHeight="552">
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.075"
android:scaleY="-0.075"
android:translateX="75"
android:translateY="483">
android:scaleX="0.01231"
android:scaleY="-0.01231"
android:translateX="15.8"
android:translateY="88.3">
<path
android:fillColor="#7c42f0"
@@ -18,4 +18,4 @@
android:fillColor="#7C42F0"
android:pathData="M3754,3278c14,-45 28,-231 22,-298 -3,-42 -18,-116 -32,-164 -27,-94 -25,-118 11,-142 66,-43 121,70 119,251 -1,87 -30,208 -69,285 -26,51 -60,96 -51,68z" />
</group>
</vector>
</vector>

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media
import hu.bbara.purefin.navigation.EpisodeDto
@@ -34,8 +35,9 @@ import javax.inject.Inject
@HiltViewModel
class AppViewModel @Inject constructor(
private val homeRepository: HomeRepository,
private val mediaCatalogReader: LocalMediaRepository,
private val localMediaRepository: LocalMediaRepository,
private val userSessionRepository: UserSessionRepository,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() {
@@ -59,8 +61,8 @@ class AppViewModel @Inject constructor(
posterUrl = it.posterUrl,
size = it.size,
isEmpty = when (it.type) {
LibraryKind.MOVIES -> mediaCatalogReader.movies.value.isEmpty()
LibraryKind.SERIES -> mediaCatalogReader.series.value.isEmpty()
LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
}
)
}
@@ -68,9 +70,9 @@ class AppViewModel @Inject constructor(
val suggestions = combine(
homeRepository.suggestions,
mediaCatalogReader.movies,
mediaCatalogReader.series,
mediaCatalogReader.episodes
localMediaRepository.movies,
localMediaRepository.series,
localMediaRepository.episodes
) { list, moviesMap, seriesMap, episodesMap ->
list.mapNotNull { media ->
when (media) {
@@ -94,8 +96,8 @@ class AppViewModel @Inject constructor(
val continueWatching = combine(
homeRepository.continueWatching,
mediaCatalogReader.movies,
mediaCatalogReader.episodes
localMediaRepository.movies,
localMediaRepository.episodes
) { list, moviesMap, episodesMap ->
list.mapNotNull { media ->
when (media) {
@@ -116,7 +118,7 @@ class AppViewModel @Inject constructor(
val nextUp = combine(
homeRepository.nextUp,
mediaCatalogReader.episodes
localMediaRepository.episodes
) { list, episodesMap ->
list.mapNotNull { media ->
when (media) {
@@ -134,9 +136,9 @@ class AppViewModel @Inject constructor(
val latestLibraryContent = combine(
homeRepository.latestLibraryContent,
mediaCatalogReader.movies,
mediaCatalogReader.series,
mediaCatalogReader.episodes
localMediaRepository.movies,
localMediaRepository.series,
localMediaRepository.episodes
) { libraryMap, moviesMap, seriesMap, episodesMap ->
libraryMap.mapValues { (_, items) ->
items.mapNotNull { media ->
@@ -162,6 +164,12 @@ class AppViewModel @Inject constructor(
initialValue = emptyMap()
)
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
}
}
fun onLibrarySelected(id: UUID, name: String) {
viewModelScope.launch {
navigationManager.navigate(Route.LibraryRoute(library = LibraryDto(id = id, name = name)))

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.jellyfin
import java.util.UUID
interface JellyfinMediaMetadataUpdater {
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
}

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.data.jellyfin
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.jellyfin.JellyfinMediaMetadataUpdater
import java.util.UUID
import javax.inject.Inject
class JellyfinMediaMetadataUpdaterImpl @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient
) : JellyfinMediaMetadataUpdater {
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
if (watched) {
jellyfinApiClient.markAsWatched(mediaId)
} else {
jellyfinApiClient.markAsUnwatched(mediaId)
}
}
}

View File

@@ -322,6 +322,30 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun markAsWatched(mediaId: UUID) = withContext(Dispatchers.IO) {
logApiFailure("markAsWatched($mediaId)") {
if (!ensureConfigured()) {
return@logApiFailure
}
api.playStateApi.markPlayedItem(
itemId = mediaId,
userId = getUserId(),
)
}
}
suspend fun markAsUnwatched(mediaId: UUID) = withContext(Dispatchers.IO) {
logApiFailure("markAsUnwatched($mediaId)") {
if (!ensureConfigured()) {
return@logApiFailure
}
api.playStateApi.markUnplayedItem(
itemId = mediaId,
userId = getUserId(),
)
}
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
logApiFailure("getMediaSources($mediaId)") {
if (!ensureConfigured()) {

View File

@@ -12,7 +12,9 @@ import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.catalog.InMemoryAppContentRepository
import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
import hu.bbara.purefin.data.jellyfin.JellyfinMediaMetadataUpdaterImpl
import hu.bbara.purefin.data.jellyfin.SearchManagerImpl
import hu.bbara.purefin.jellyfin.JellyfinMediaMetadataUpdater
@Module
@InstallIn(SingletonComponent::class)
@@ -29,6 +31,9 @@ abstract class MediaRepositoryModule {
@Binds
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
@Binds
abstract fun bindJellyfinMediaMetadataUpdater(impl: JellyfinMediaMetadataUpdaterImpl): JellyfinMediaMetadataUpdater
@Binds
abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager
}