feat: Implement Library feature with storage, repository, and UI components

This commit is contained in:
tapframe 2026-03-28 18:00:03 +05:30
parent bb00cde10a
commit 7f7f7e2bae
16 changed files with 465 additions and 2 deletions

View file

@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
@ -15,6 +16,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
AddonStorage.initialize(applicationContext)
LibraryStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.library
actual object LibraryClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,25 @@
package com.nuvio.app.features.library
import android.content.Context
import android.content.SharedPreferences
actual object LibraryStorage {
private const val preferencesName = "nuvio_library"
private const val payloadKey = "library_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(payloadKey, null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(payloadKey, payload)
?.apply()
}
}

View file

@ -9,6 +9,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.VideoLibrary
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -40,6 +41,8 @@ import com.nuvio.app.features.details.MetaDetailsScreen
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.HomeScreen
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.search.SearchScreen
@ -86,6 +89,7 @@ data class CatalogRoute(
enum class AppScreenTab {
Home,
Search,
Library,
Settings,
}
@ -95,6 +99,7 @@ fun AppScreen(
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) {
@ -110,6 +115,10 @@ fun AppScreen(
modifier = modifier,
onPosterClick = onPosterClick,
)
AppScreenTab.Library -> LibraryScreen(
modifier = modifier,
onPosterClick = onLibraryPosterClick,
)
AppScreenTab.Settings -> SettingsScreen(
modifier = modifier,
)
@ -227,6 +236,13 @@ fun App() {
label = { Text("Search") },
colors = navigationItemColors,
)
NavigationBarItem(
selected = selectedTab == AppScreenTab.Library,
onClick = { selectedTab = AppScreenTab.Library },
icon = { Icon(Icons.Rounded.VideoLibrary, contentDescription = null) },
label = { Text("Library") },
colors = navigationItemColors,
)
NavigationBarItem(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
@ -246,6 +262,9 @@ fun App() {
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
onLibraryPosterClick = { item ->
navController.navigate(DetailRoute(type = item.type, id = item.id))
},
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
)

View file

@ -1,7 +1,9 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -93,11 +95,18 @@ fun NuvioPosterCard(
shape: NuvioPosterShape = NuvioPosterShape.Poster,
detailLine: String? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier
.width(110.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
.then(
if (onClick != null || onLongClick != null) {
Modifier.posterCardClickable(onClick = onClick, onLongClick = onLongClick)
} else {
Modifier
}
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
@ -235,3 +244,13 @@ private val NuvioPosterShape.aspectRatio: Float
NuvioPosterShape.Square -> 1f
NuvioPosterShape.Landscape -> 1.77f
}
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.posterCardClickable(
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
): Modifier =
combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = onLongClick,
)

View file

@ -43,6 +43,8 @@ import com.nuvio.app.features.details.components.DetailCastSection
import com.nuvio.app.features.details.components.DetailHero
import com.nuvio.app.features.details.components.DetailMetaInfo
import com.nuvio.app.features.details.components.DetailSeriesContent
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
@ -56,6 +58,10 @@ fun MetaDetailsScreen(
modifier: Modifier = Modifier,
) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
@ -114,6 +120,9 @@ fun MetaDetailsScreen(
requestedMeta != null -> {
val meta = requestedMeta
val isSaved = remember(libraryUiState.items, meta.id) {
libraryUiState.items.any { it.id == meta.id }
}
val movieProgress = watchProgressUiState.byVideoId[meta.id]
val seriesResumeEntry = watchProgressUiState.entries
.filter { it.parentMetaId == meta.id }
@ -146,6 +155,7 @@ fun MetaDetailsScreen(
) {
DetailActionButtons(
playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save",
onPlayClick = {
when {
meta.type == "series" && seriesResumeEntry != null -> {
@ -208,6 +218,11 @@ fun MetaDetailsScreen(
}
}
},
onSaveClick = {
LibraryRepository.toggleSaved(
meta.toLibraryItem(savedAtEpochMs = 0L),
)
},
)
DetailMetaInfo(meta = meta)

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
fun DetailActionButtons(
modifier: Modifier = Modifier,
playLabel: String = "Play",
saveLabel: String = "Save",
onPlayClick: () -> Unit = {},
onSaveClick: () -> Unit = {},
) {
@ -73,7 +74,7 @@ fun DetailActionButtons(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Save",
text = saveLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)

View file

@ -12,6 +12,7 @@ fun HomePosterCard(
item: MetaPreview,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
NuvioPosterCard(
title = item.name,
@ -20,6 +21,7 @@ fun HomePosterCard(
shape = item.posterShape.toNuvioPosterShape(),
detailLine = item.releaseInfo,
onClick = onClick,
onLongClick = onLongClick,
)
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.library
internal expect object LibraryClock {
fun nowEpochMs(): Long
}

View file

@ -0,0 +1,65 @@
package com.nuvio.app.features.library
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import kotlinx.serialization.Serializable
@Serializable
data class LibraryItem(
val id: String,
val type: String,
val name: String,
val poster: String? = null,
val banner: String? = null,
val logo: String? = null,
val description: String? = null,
val releaseInfo: String? = null,
val imdbRating: String? = null,
val genres: List<String> = emptyList(),
val posterShape: PosterShape = PosterShape.Poster,
val savedAtEpochMs: Long,
)
data class LibrarySection(
val type: String,
val displayTitle: String,
val items: List<LibraryItem>,
)
data class LibraryUiState(
val items: List<LibraryItem> = emptyList(),
val sections: List<LibrarySection> = emptyList(),
val isLoaded: Boolean = false,
)
fun MetaDetails.toLibraryItem(savedAtEpochMs: Long): LibraryItem =
LibraryItem(
id = id,
type = type,
name = name,
poster = poster,
banner = background,
logo = logo,
description = description,
releaseInfo = releaseInfo,
imdbRating = imdbRating,
genres = genres,
posterShape = PosterShape.Poster,
savedAtEpochMs = savedAtEpochMs,
)
fun LibraryItem.toMetaPreview(): MetaPreview =
MetaPreview(
id = id,
type = type,
name = name,
poster = poster,
banner = banner,
logo = logo,
posterShape = posterShape,
description = description,
releaseInfo = releaseInfo,
imdbRating = imdbRating,
genres = genres,
)

View file

@ -0,0 +1,120 @@
package com.nuvio.app.features.library
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
private data class StoredLibraryPayload(
val items: List<LibraryItem> = emptyList(),
)
object LibraryRepository {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(LibraryUiState())
val uiState: StateFlow<LibraryUiState> = _uiState.asStateFlow()
private var hasLoaded = false
private var itemsById: MutableMap<String, LibraryItem> = mutableMapOf()
fun ensureLoaded() {
if (hasLoaded) return
hasLoaded = true
val payload = LibraryStorage.loadPayload().orEmpty().trim()
if (payload.isNotEmpty()) {
val items = runCatching {
json.decodeFromString<StoredLibraryPayload>(payload).items
}.getOrDefault(emptyList())
itemsById = items.associateBy { it.id }.toMutableMap()
}
publish()
}
fun toggleSaved(item: LibraryItem) {
ensureLoaded()
if (itemsById.containsKey(item.id)) {
remove(item.id)
} else {
save(item)
}
}
fun save(item: LibraryItem) {
ensureLoaded()
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
publish()
persist()
}
fun remove(id: String) {
ensureLoaded()
if (itemsById.remove(id) != null) {
publish()
persist()
}
}
fun isSaved(id: String): Boolean {
ensureLoaded()
return itemsById.containsKey(id)
}
fun savedItem(id: String): LibraryItem? {
ensureLoaded()
return itemsById[id]
}
private fun publish() {
val items = itemsById.values
.sortedByDescending { it.savedAtEpochMs }
val sections = items
.groupBy { it.type }
.map { (type, typeItems) ->
LibrarySection(
type = type,
displayTitle = type.toLibraryDisplayTitle(),
items = typeItems.sortedByDescending { it.savedAtEpochMs },
)
}
.sortedBy { it.displayTitle }
_uiState.value = LibraryUiState(
items = items,
sections = sections,
isLoaded = true,
)
}
private fun persist() {
LibraryStorage.savePayload(
json.encodeToString(
StoredLibraryPayload(
items = itemsById.values.sortedByDescending { it.savedAtEpochMs },
),
),
)
}
}
internal fun String.toLibraryDisplayTitle(): String {
val normalized = trim()
if (normalized.isBlank()) return "Other"
return normalized
.split('-', '_', ' ')
.filter { it.isNotBlank() }
.joinToString(" ") { token ->
token.lowercase().replaceFirstChar { char -> char.uppercase() }
}
.ifBlank { "Other" }
}

View file

@ -0,0 +1,116 @@
package com.nuvio.app.features.library
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard
@Composable
fun LibraryScreen(
modifier: Modifier = Modifier,
onPosterClick: ((LibraryItem) -> Unit)? = null,
) {
val uiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
var pendingRemovalItem by remember { mutableStateOf<LibraryItem?>(null) }
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
) {
stickyHeader {
NuvioScreenHeader(
title = "Library",
modifier = Modifier.padding(horizontal = 16.dp),
)
}
when {
!uiState.isLoaded -> {
item {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
uiState.sections.isEmpty() -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "Your library is empty",
message = "Saved titles will appear here after you tap Save on a details screen.",
)
}
}
else -> {
librarySections(
sections = uiState.sections,
onPosterClick = onPosterClick,
onPosterLongClick = { item -> pendingRemovalItem = item },
)
}
}
}
NuvioStatusModal(
title = "Remove from Library?",
message = pendingRemovalItem?.let { "Remove ${it.name} from your library?" }.orEmpty(),
isVisible = pendingRemovalItem != null,
confirmText = "Remove",
dismissText = "Cancel",
onConfirm = {
pendingRemovalItem?.id?.let(LibraryRepository::remove)
pendingRemovalItem = null
},
onDismiss = { pendingRemovalItem = null },
)
}
private fun LazyListScope.librarySections(
sections: List<LibrarySection>,
onPosterClick: ((LibraryItem) -> Unit)?,
onPosterLongClick: (LibraryItem) -> Unit,
) {
items(
items = sections,
key = { section -> section.type },
) { section ->
NuvioShelfSection(
title = section.displayTitle,
entries = section.items,
headerHorizontalPadding = 16.dp,
rowContentPadding = PaddingValues(horizontal = 16.dp),
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { item -> "${item.type}:${item.id}" },
) { item ->
HomePosterCard(
item = item.toMetaPreview(),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = { onPosterLongClick(item) },
)
}
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.library
internal expect object LibraryStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -0,0 +1,40 @@
package com.nuvio.app.features.library
import com.nuvio.app.features.home.PosterShape
import kotlin.test.Test
import kotlin.test.assertEquals
class LibraryRepositoryTest {
@Test
fun `display title uses exact type formatting`() {
assertEquals("Movie", "movie".toLibraryDisplayTitle())
assertEquals("Anime Series", "anime-series".toLibraryDisplayTitle())
assertEquals("Tv", "tv".toLibraryDisplayTitle())
assertEquals("Other", "".toLibraryDisplayTitle())
}
@Test
fun `meta preview mapping preserves exact type and poster shape`() {
val item = LibraryItem(
id = "tt1",
type = "anime-series",
name = "Title",
poster = "poster",
banner = "banner",
logo = "logo",
description = "desc",
releaseInfo = "2024",
imdbRating = "8.4",
genres = listOf("Drama"),
posterShape = PosterShape.Poster,
savedAtEpochMs = 1L,
)
val preview = item.toMetaPreview()
assertEquals("anime-series", preview.type)
assertEquals(PosterShape.Poster, preview.posterShape)
assertEquals("banner", preview.banner)
}
}

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.library
import kotlinx.cinterop.ExperimentalForeignApi
import platform.posix.time
actual object LibraryClock {
@OptIn(ExperimentalForeignApi::class)
actual fun nowEpochMs(): Long = time(null) * 1000L
}

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.library
import platform.Foundation.NSUserDefaults
actual object LibraryStorage {
private const val payloadKey = "library_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(payloadKey)
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey)
}
}