diff --git a/app-tv/build.gradle.kts b/app-tv/build.gradle.kts new file mode 100644 index 0000000..d346d92 --- /dev/null +++ b/app-tv/build.gradle.kts @@ -0,0 +1,90 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "hu.bbara.purefin.tv" + compileSdk = 36 + + defaultConfig { + applicationId = "hu.bbara.purefin.tv" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:data")) + implementation(project(":core:player")) + implementation(project(":feature:shared")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.jellyfin.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.hilt) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.datastore) + implementation(libs.okhttp) + implementation(libs.logging.interceptor) + implementation(libs.androidx.compose.foundation) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + implementation(libs.medi3.exoplayer) + implementation(libs.medi3.ffmpeg.decoder) + implementation(libs.media3.datasource.okhttp) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + ksp(libs.hilt.compiler) +} diff --git a/app-tv/proguard-rules.pro b/app-tv/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app-tv/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app-tv/src/main/AndroidManifest.xml b/app-tv/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2887bae --- /dev/null +++ b/app-tv/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt new file mode 100644 index 0000000..851172d --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt @@ -0,0 +1,120 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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.sp +import coil3.request.ImageRequest +import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator +import hu.bbara.purefin.common.ui.components.WatchStateIndicator +import hu.bbara.purefin.feature.shared.home.PosterItem +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun PosterCard( + item: PosterItem, + modifier: Modifier = Modifier, + onMovieSelected: (UUID) -> Unit, + onSeriesSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, +) { + val scheme = MaterialTheme.colorScheme + val context = LocalContext.current + val density = LocalDensity.current + + val posterWidth = 144.dp + val posterHeight = posterWidth * 3 / 2 + + fun openItem(posterItem: PosterItem) { + when (posterItem.type) { + BaseItemKind.MOVIE -> onMovieSelected(posterItem.id) + BaseItemKind.SERIES -> onSeriesSelected(posterItem.id) + BaseItemKind.EPISODE -> { + val ep = posterItem.episode!! + onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id) + } + else -> {} + } + } + + val imageRequest = ImageRequest.Builder(context) + .data(item.imageUrl) + .size(with(density) { posterWidth.roundToPx() }, with(density) { posterHeight.roundToPx() }) + .build() + Column( + modifier = Modifier + .width(posterWidth) + ) { + Box() { + PurefinAsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(14.dp)) + .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(14.dp)) + .background(scheme.surfaceVariant) + .clickable(onClick = { openItem(item) }), + contentScale = ContentScale.Crop + ) + when (item.type) { + BaseItemKind.MOVIE -> { + val m = item.movie!! + WatchStateIndicator( + size = 28, + modifier = Modifier.align(Alignment.TopEnd) + .padding(8.dp), + watched = m.watched, + started = (m.progress ?: 0.0) > 0 + ) + } + BaseItemKind.EPISODE -> { + val ep = item.episode!! + WatchStateIndicator( + size = 28, + modifier = Modifier.align(Alignment.TopEnd) + .padding(8.dp), + watched = ep.watched, + started = (ep.progress ?: 0.0) > 0 + ) + } + BaseItemKind.SERIES -> UnwatchedEpisodeIndicator( + size = 28, + modifier = Modifier.align(Alignment.TopEnd) + .padding(8.dp), + unwatchedCount = item.series!!.unwatchedEpisodeCount + ) + else -> {} + } + } + Text( + text = item.title, + color = scheme.onBackground, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinComplexTextField.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinComplexTextField.kt new file mode 100644 index 0000000..33c97d0 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinComplexTextField.kt @@ -0,0 +1,68 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun PurefinComplexTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + val scheme = MaterialTheme.colorScheme + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + color = scheme.onBackground, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) }, + leadingIcon = if (leadingIcon != null) { + { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) } + } else null, + trailingIcon = if (trailingIcon != null) { + { Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) } + } else null, + visualTransformation = visualTransformation, + colors = TextFieldDefaults.colors( + focusedContainerColor = scheme.surfaceVariant, + unfocusedContainerColor = scheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = scheme.primary, + focusedTextColor = scheme.onSurface, + unfocusedTextColor = scheme.onSurface + )) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinPasswordField.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinPasswordField.kt new file mode 100644 index 0000000..2b3a1cf --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinPasswordField.kt @@ -0,0 +1,76 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun PurefinPasswordField( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + leadingIcon: ImageVector, +) { + val scheme = MaterialTheme.colorScheme + + val showField = remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + color = scheme.onBackground, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) }, + leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) }, + trailingIcon = + { + IconButton( + onClick = { showField.value = !showField.value }, + ) { + Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) + } + }, + visualTransformation = if (showField.value) VisualTransformation.None else PasswordVisualTransformation(), + colors = TextFieldDefaults.colors( + focusedContainerColor = scheme.surfaceVariant, + unfocusedContainerColor = scheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = scheme.primary, + focusedTextColor = scheme.onSurface, + unfocusedTextColor = scheme.onSurface + ) + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinTextButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinTextButton.kt new file mode 100644 index 0000000..0e54d08 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinTextButton.kt @@ -0,0 +1,26 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun PurefinTextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + content = content + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/WaitingScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/WaitingScreen.kt new file mode 100644 index 0000000..6ba67bf --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/WaitingScreen.kt @@ -0,0 +1,199 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun PurefinWaitingScreen( + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val accentColor = scheme.primary + val backgroundColor = scheme.background + val surfaceColor = scheme.surface + val textPrimary = scheme.onSurface + val textSecondary = scheme.onSurfaceVariant + + val transition = rememberInfiniteTransition(label = "waiting-pulse") + val pulseScale = transition.animateFloat( + initialValue = 0.9f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulse-scale" + ) + val pulseAlpha = transition.animateFloat( + initialValue = 0.2f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulse-alpha" + ) + + val gradient = Brush.radialGradient( + colors = listOf( + accentColor.copy(alpha = 0.28f), + backgroundColor + ) + ) + + Box( + modifier = modifier + .fillMaxSize() + .background(gradient) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .background(surfaceColor.copy(alpha = 0.92f)) + .padding(horizontal = 28.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(86.dp) + .graphicsLayer { + scaleX = pulseScale.value + scaleY = pulseScale.value + } + .alpha(pulseAlpha.value) + .border( + width = 2.dp, + color = accentColor.copy(alpha = 0.6f), + shape = RoundedCornerShape(26.dp) + ) + ) + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(22.dp)) + .background(accentColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Movie, + contentDescription = null, + tint = scheme.onPrimary, + modifier = Modifier.size(40.dp) + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Just a moment", + color = textPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "I am doing all I can...", + color = textSecondary, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(24.dp)) + + WaitingDots(accentColor = accentColor) + } + } +} + +@Composable +private fun WaitingDots(accentColor: Color, modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "waiting-dots") + val firstAlpha = transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, delayMillis = 0, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "dot-1" + ) + val secondAlpha = transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, delayMillis = 140, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "dot-2" + ) + val thirdAlpha = transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, delayMillis = 280, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "dot-3" + ) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + WaitingDot(alpha = firstAlpha.value, color = accentColor) + WaitingDot(alpha = secondAlpha.value, color = accentColor) + WaitingDot(alpha = thirdAlpha.value, color = accentColor) + } +} + +@Composable +private fun WaitingDot(alpha: Float, color: Color) { + Box( + modifier = Modifier + .size(10.dp) + .graphicsLayer { + val scale = 0.7f + (alpha * 0.3f) + scaleX = scale + scaleY = scale + } + .alpha(alpha) + .background(color, CircleShape) + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt new file mode 100644 index 0000000..2d17543 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt @@ -0,0 +1,48 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +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.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A progress bar component for displaying media playback progress. + * + * @param progress The progress value between 0f and 1f, where 0f is no progress and 1f is complete. + * @param foregroundColor The color of the progress indicator. + * @param backgroundColor The color of the background/unfilled portion of the progress bar. + * @param modifier The modifier to be applied to the Box. Modifier should contain the Alignment. + */ +@Composable +fun MediaProgressBar( + progress: Float, + foregroundColor: Color = MaterialTheme.colorScheme.onSurface, + backgroundColor: Color = MaterialTheme.colorScheme.primary, + modifier: Modifier +) { + if (progress == 0f) return + Box( + modifier = modifier + .padding(bottom = 8.dp, start = 8.dp, end = 8.dp) + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .height(4.dp) + .background(backgroundColor.copy(alpha = 0.2f)) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(progress) + .background(foregroundColor) + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinAsyncImage.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinAsyncImage.kt new file mode 100644 index 0000000..9d510a9 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinAsyncImage.kt @@ -0,0 +1,38 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage + +/** + * Async image that falls back to theme-synced color blocks so loading/error states + * stay aligned with PurefinTheme's colorScheme. + */ +@Composable +fun PurefinAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop +) { + val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant) + + // Convert empty string to null to properly trigger fallback + val effectiveModel = when { + model is String && model.isEmpty() -> null + else -> model + } + + AsyncImage( + model = effectiveModel, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + placeholder = placeholderPainter, + error = placeholderPainter, + fallback = placeholderPainter + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinIconButton.kt new file mode 100644 index 0000000..9a5dc9c --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/PurefinIconButton.kt @@ -0,0 +1,41 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.unit.dp + +@Composable +fun PurefinIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Int = 52 +) { + val scheme = MaterialTheme.colorScheme + + Box( + modifier = modifier + .size(size.dp) + .clip(CircleShape) + .background(scheme.secondary) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = scheme.onSecondary + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/SearchField.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/SearchField.kt new file mode 100644 index 0000000..f606d75 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/SearchField.kt @@ -0,0 +1,48 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun SearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + backgroundColor: Color, + textColor: Color, + cursorColor: Color, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + + TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(32.dp)), + placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) }, + leadingIcon = + { Icon(imageVector = Icons.Outlined.Search, contentDescription = null, tint = scheme.onSurfaceVariant) }, + colors = TextFieldDefaults.colors( + focusedContainerColor = backgroundColor, + unfocusedContainerColor = backgroundColor, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = cursorColor, + focusedTextColor = textColor, + unfocusedTextColor = textColor, + )) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/UnwatchedEpisodeIndicator.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/UnwatchedEpisodeIndicator.kt new file mode 100644 index 0000000..08783fd --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/UnwatchedEpisodeIndicator.kt @@ -0,0 +1,46 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +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.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun UnwatchedEpisodeIndicator( + unwatchedCount: Int, + foregroundColor: Color = MaterialTheme.colorScheme.onPrimary, + backgroundColor: Color = MaterialTheme.colorScheme.primary, + size: Int = 24, + modifier: Modifier = Modifier +) { + if (unwatchedCount == 0) { + return + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .border(1.dp, backgroundColor.copy(alpha = 0.8f), CircleShape) + .background(backgroundColor.copy(alpha = 0.8f), CircleShape) + .size(size.dp) + .clip(CircleShape) + ) { + Text( + text = if (unwatchedCount > 9) "9+" else unwatchedCount.toString(), + color = foregroundColor.copy(alpha = 0.8f), + fontWeight = FontWeight.W900, + fontSize = 15.sp + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/WatchStateIndicator.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/WatchStateIndicator.kt new file mode 100644 index 0000000..20f696a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/WatchStateIndicator.kt @@ -0,0 +1,81 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun WatchStateIndicator( + watched: Boolean, + started: Boolean, + watchedColor: Color = MaterialTheme.colorScheme.onPrimary, + watchedBackgroundColor: Color = MaterialTheme.colorScheme.primary, + startedColor: Color = MaterialTheme.colorScheme.onSecondary, + startedBackgroundColor: Color = MaterialTheme.colorScheme.secondary, + size: Int = 24, + modifier: Modifier = Modifier +) { + + if (watched.not() && started.not()) { + return + } + + val foregroundColor = if (watched) watchedColor.copy(alpha = 0.8f) else startedColor.copy(alpha = 0.3f) + val backgroundColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.3f) + val borderColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.8f) + + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .border(1.dp, borderColor, CircleShape) + .background(backgroundColor, CircleShape) + .size(size.dp) + .clip(CircleShape) + ) { + if (watched) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Check", + tint = foregroundColor, + modifier = Modifier + .padding(1.dp) + .matchParentSize() + ) + } + } +} + +@Preview +@Composable +private fun WatchStateIndicatorPreview() { + Column() { + WatchStateIndicator( + watched = false, + started = false + ) + WatchStateIndicator( + watched = true, + started = false + ) + WatchStateIndicator( + watched = false, + started = true + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt new file mode 100644 index 0000000..45c784a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt @@ -0,0 +1,214 @@ +package hu.bbara.purefin.login.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.PurefinComplexTextField +import hu.bbara.purefin.common.ui.PurefinPasswordField +import hu.bbara.purefin.common.ui.PurefinTextButton +import hu.bbara.purefin.common.ui.PurefinWaitingScreen +import hu.bbara.purefin.feature.shared.login.LoginViewModel +import kotlinx.coroutines.launch + +@Composable +fun LoginScreen( + viewModel: LoginViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + // Observe ViewModel state + val serverUrl by viewModel.url.collectAsState() + val username by viewModel.username.collectAsState() + val password by viewModel.password.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + var isLoggingIn by remember { mutableStateOf(false) } + + val coroutineScope = rememberCoroutineScope() + + if (isLoggingIn) { + PurefinWaitingScreen(modifier = modifier) + } else { + Column( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(0.5f)) + + // Logo Section + Box( + modifier = Modifier + .size(100.dp) + .background(scheme.primary, RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Movie, + contentDescription = "Logo", + tint = scheme.onPrimary, + modifier = Modifier.size(60.dp) + ) + } + + Text( + text = "Jellyfin", + color = scheme.onBackground, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp) + ) + Text( + text = "PERSONAL MEDIA SYSTEM", + color = scheme.onSurfaceVariant, + fontSize = 12.sp, + letterSpacing = 2.sp + ) + + Spacer(modifier = Modifier.height(48.dp)) + + // Form Section + Text( + text = "Connect to Server", + color = scheme.onBackground, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Start) + ) + Text( + text = "Enter your details to access your library", + color = scheme.onSurfaceVariant, + fontSize = 14.sp, + modifier = Modifier + .align(Alignment.Start) + .padding(bottom = 24.dp) + ) + + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = scheme.error, + fontSize = 14.sp, + modifier = Modifier + .fillMaxWidth() + .background( + scheme.errorContainer, + RoundedCornerShape(8.dp) + ) + .padding(12.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + PurefinComplexTextField( + label = "Server URL", + value = serverUrl, + onValueChange = { + viewModel.clearError() + viewModel.setUrl(it) + }, + placeholder = "http://192.168.1.100:8096", + leadingIcon = Icons.Default.Storage + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PurefinComplexTextField( + label = "Username", + value = username, + onValueChange = { + viewModel.clearError() + viewModel.setUsername(it) + }, + placeholder = "Enter your username", + leadingIcon = Icons.Default.Person + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PurefinPasswordField( + label = "Password", + value = password, + onValueChange = { + viewModel.clearError() + viewModel.setPassword(it) + }, + placeholder = "••••••••", + leadingIcon = Icons.Default.Lock, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + PurefinTextButton( + content = { Text("Connect") }, + onClick = { + coroutineScope.launch { + isLoggingIn = true + try { + viewModel.login() + } finally { + isLoggingIn = false + } + } + } + ) + + Spacer(modifier = Modifier.weight(0.5f)) + + // Footer Links + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = {}) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Search, contentDescription = null, tint = scheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Text(" Discover Servers", color = scheme.onSurfaceVariant) + } + } + TextButton(onClick = {}) { + Text("Need Help?", color = scheme.onSurfaceVariant) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt new file mode 100644 index 0000000..840258a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt @@ -0,0 +1,183 @@ +package hu.bbara.purefin.tv + +import android.content.pm.ApplicationInfo +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.crossfade +import coil3.util.DebugLogger +import dagger.hilt.android.AndroidEntryPoint +import hu.bbara.purefin.common.ui.PurefinWaitingScreen +import hu.bbara.purefin.core.data.client.JellyfinApiClient +import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor +import hu.bbara.purefin.core.data.navigation.LocalNavigationManager +import hu.bbara.purefin.core.data.navigation.NavigationCommand +import hu.bbara.purefin.core.data.navigation.NavigationManager +import hu.bbara.purefin.core.data.navigation.Route +import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.login.ui.LoginScreen +import hu.bbara.purefin.ui.theme.AppTheme +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okio.Path.Companion.toPath +import javax.inject.Inject + +@AndroidEntryPoint +class TvActivity : ComponentActivity() { + + @Inject + lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit> + + @Inject + lateinit var userSessionRepository: UserSessionRepository + + @Inject + lateinit var navigationManager: NavigationManager + + @Inject + lateinit var jellyfinApiClient: JellyfinApiClient + + @Inject + lateinit var authInterceptor: JellyfinAuthInterceptor + + private val imageOkHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .addNetworkInterceptor(authInterceptor) + .build() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { init() } + configureImageLoader() + enableEdgeToEdge() + setContent { + AppTheme { + MainApp( + userSessionRepository = userSessionRepository, + entryBuilders = entryBuilders, + navigationManager = navigationManager + ) + } + } + } + + private suspend fun init() { + jellyfinApiClient.updateApiClient() + } + + private fun configureImageLoader() { + SingletonImageLoader.setSafe { context -> + val builder = ImageLoader.Builder(context) + .components { + add( + OkHttpNetworkFetcherFactory( + callFactory = { imageOkHttpClient } + ) + ) + } + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.20) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image_cache").absolutePath.toPath()) + .maxSizeBytes(30_000_000) + .build() + } + .crossfade(true) + + if (isDebuggable()) { + builder.logger(DebugLogger()) + } + + builder.build() + } + } + + private fun isDebuggable(): Boolean = + (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + + @Composable + fun MainApp( + userSessionRepository: UserSessionRepository, + entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit>, + navigationManager: NavigationManager + ) { + var sessionLoaded by remember { mutableStateOf(false) } + val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false) + + LaunchedEffect(Unit) { + userSessionRepository.isLoggedIn.collect { + sessionLoaded = true + } + } + + if (!sessionLoaded) { + PurefinWaitingScreen(modifier = Modifier.fillMaxSize()) + return + } + + if (isLoggedIn) { + @Suppress("UNCHECKED_CAST") + val backStack = rememberNavBackStack(Route.Home) as NavBackStack + val appEntryProvider = entryProvider { + entryBuilders.forEach { builder -> builder() } + } + + LaunchedEffect(navigationManager, backStack) { + navigationManager.commands.collect { command -> + when (command) { + NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull() + is NavigationCommand.Navigate -> backStack.add(command.route) + is NavigationCommand.ReplaceAll -> { + backStack.clear() + backStack.add(command.route) + } + } + } + } + + CompositionLocalProvider(LocalNavigationManager provides navigationManager) { + NavDisplay( + backStack = backStack, + onBack = { navigationManager.pop() }, + modifier = Modifier.fillMaxSize(), + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = appEntryProvider + ) + } + } else { + LoginScreen() + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt new file mode 100644 index 0000000..2d94057 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt @@ -0,0 +1,7 @@ +package hu.bbara.purefin.tv + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class TvApplication : Application() diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt new file mode 100644 index 0000000..95a9ab1 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt @@ -0,0 +1,108 @@ +package hu.bbara.purefin.tv.home + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import hu.bbara.purefin.feature.shared.home.HomePageViewModel +import hu.bbara.purefin.tv.home.ui.TvHomeContent +import hu.bbara.purefin.tv.home.ui.TvHomeDrawerContent +import hu.bbara.purefin.tv.home.ui.TvHomeMockData +import hu.bbara.purefin.tv.home.ui.TvHomeNavItem +import hu.bbara.purefin.tv.home.ui.TvHomeTopBar +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.CollectionType + +@Composable +fun TvHomePage( + viewModel: HomePageViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val coroutineScope = rememberCoroutineScope() + + val libraries = viewModel.libraries.collectAsState().value + val isOfflineMode = viewModel.isOfflineMode.collectAsState().value + val libraryNavItems = libraries.map { + TvHomeNavItem( + id = it.id, + label = it.name, + icon = when (it.type) { + CollectionType.MOVIES -> Icons.Outlined.Movie + CollectionType.TVSHOWS -> Icons.Outlined.Tv + else -> Icons.Outlined.Collections + }, + ) + } + val continueWatching = viewModel.continueWatching.collectAsState() + val nextUp = viewModel.nextUp.collectAsState() + val latestLibraryContent = viewModel.latestLibraryContent.collectAsState() + + LifecycleResumeEffect(Unit) { + viewModel.onResumed() + onPauseOrDispose { } + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + modifier = Modifier + .width(280.dp) + .fillMaxSize(), + drawerContainerColor = MaterialTheme.colorScheme.surface, + drawerContentColor = MaterialTheme.colorScheme.onBackground + ) { + TvHomeDrawerContent( + title = "Jellyfin", + subtitle = "Library Dashboard", + primaryNavItems = libraryNavItems, + secondaryNavItems = TvHomeMockData.secondaryNavItems, + user = TvHomeMockData.user, + onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) }, + onLogout = viewModel::logout + ) + } + } + ) { + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + topBar = { + TvHomeTopBar( + onMenuClick = { coroutineScope.launch { drawerState.open() } }, + isOfflineMode = isOfflineMode, + onToggleOfflineMode = viewModel::toggleOfflineMode + ) + } + ) { innerPadding -> + TvHomeContent( + libraries = libraries, + libraryContent = latestLibraryContent.value, + continueWatching = continueWatching.value, + nextUp = nextUp.value, + onMovieSelected = viewModel::onMovieSelected, + onSeriesSelected = viewModel::onSeriesSelected, + onEpisodeSelected = viewModel::onEpisodeSelected, + modifier = Modifier.padding(innerPadding) + ) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeAvatar.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeAvatar.kt new file mode 100644 index 0000000..6a49b9a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeAvatar.kt @@ -0,0 +1,41 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp + +@Composable +fun TvHomeAvatar( + size: Dp, + borderWidth: Dp, + borderColor: Color, + backgroundColor: Color, + icon: ImageVector, + iconTint: Color, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .border(borderWidth, borderColor, CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt new file mode 100644 index 0000000..33c608e --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt @@ -0,0 +1,72 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem +import hu.bbara.purefin.feature.shared.home.LibraryItem +import hu.bbara.purefin.feature.shared.home.NextUpItem +import hu.bbara.purefin.feature.shared.home.PosterItem +import org.jellyfin.sdk.model.UUID + +@Composable +fun TvHomeContent( + libraries: List, + libraryContent: Map>, + continueWatching: List, + nextUp: List, + onMovieSelected: (UUID) -> Unit, + onSeriesSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + } + item { + TvContinueWatchingSection( + items = continueWatching, + onMovieSelected = onMovieSelected, + onEpisodeSelected = onEpisodeSelected + ) + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } + item { + TvNextUpSection( + items = nextUp, + onEpisodeSelected = onEpisodeSelected + ) + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } + items( + items = libraries.filter { libraryContent[it.id]?.isEmpty() != true }, + key = { it.id } + ) { item -> + TvLibraryPosterSection( + title = item.name, + items = libraryContent[item.id] ?: emptyList(), + action = "See All", + onMovieSelected = onMovieSelected, + onSeriesSelected = onSeriesSelected, + onEpisodeSelected = onEpisodeSelected + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt new file mode 100644 index 0000000..312e645 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt @@ -0,0 +1,204 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.sp + +@Composable +fun TvHomeDrawerContent( + title: String, + subtitle: String, + primaryNavItems: List, + secondaryNavItems: List, + user: TvHomeUser, + onLibrarySelected: (TvHomeNavItem) -> Unit, + onLogout: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize()) { + TvHomeDrawerHeader( + title = title, + subtitle = subtitle + ) + TvHomeDrawerNav( + primaryItems = primaryNavItems, + secondaryItems = secondaryNavItems, + onLibrarySelected = onLibrarySelected + ) + Spacer(modifier = Modifier.weight(1f)) + TvHomeDrawerFooter(user = user, onLogout = onLogout) + } +} + +@Composable +fun TvHomeDrawerHeader( + title: String, + subtitle: String, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 16.dp, top = 24.dp, bottom = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .size(40.dp) + .background(scheme.primary, RoundedCornerShape(12.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play", + tint = scheme.onPrimary + ) + } + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + text = title, + color = scheme.onBackground, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = subtitle, + color = scheme.onSurfaceVariant, + fontSize = 12.sp + ) + } + } + HorizontalDivider(color = scheme.onSurfaceVariant.copy(alpha = 0.2f)) +} + +@Composable +fun TvHomeDrawerNav( + primaryItems: List, + secondaryItems: List, + onLibrarySelected: (TvHomeNavItem) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + primaryItems.forEach { item -> + TvHomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected) + } + if (secondaryItems.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + secondaryItems.forEach { item -> + TvHomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected) + } + } + } +} + +@Composable +fun TvHomeDrawerNavItem( + item: TvHomeNavItem, + modifier: Modifier = Modifier, + onLibrarySelected: (TvHomeNavItem) -> Unit +) { + val scheme = MaterialTheme.colorScheme + val background = if (item.selected) scheme.primary.copy(alpha = 0.12f) else Color.Transparent + val tint = if (item.selected) scheme.primary else scheme.onSurfaceVariant + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .background(background, RoundedCornerShape(12.dp)) + .clickable { onLibrarySelected(item) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = item.icon, + contentDescription = item.label, + tint = tint + ) + Text( + text = item.label, + color = if (item.selected) scheme.primary else scheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 12.dp) + ) + } +} + +@Composable +fun TvHomeDrawerFooter( + user: TvHomeUser, + onLogout: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .background(scheme.surfaceVariant, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TvHomeAvatar( + size = 32.dp, + borderWidth = 1.dp, + borderColor = scheme.outlineVariant, + backgroundColor = scheme.primaryContainer, + icon = Icons.Outlined.Person, + iconTint = scheme.onBackground + ) + Column(modifier = Modifier.padding(start = 12.dp) + .clickable { onLogout() }) { + Text( + text = user.name, + color = scheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = user.plan, + color = scheme.onSurfaceVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMockData.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMockData.kt new file mode 100644 index 0000000..4ab9432 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMockData.kt @@ -0,0 +1,24 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Tv +import org.jellyfin.sdk.model.UUID + +object TvHomeMockData { + val user = TvHomeUser(name = "Alex User", plan = "Premium Account") + + val primaryNavItems = listOf( + TvHomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true), + TvHomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie), + TvHomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv), + TvHomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search) + ) + + val secondaryNavItems = listOf( + TvHomeNavItem(id = UUID.randomUUID(), label = "Settings", icon = Icons.Outlined.Settings) + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeModels.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeModels.kt new file mode 100644 index 0000000..585817d --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeModels.kt @@ -0,0 +1,16 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.ui.graphics.vector.ImageVector +import org.jellyfin.sdk.model.UUID + +data class TvHomeNavItem( + val id: UUID, + val label: String, + val icon: ImageVector, + val selected: Boolean = false +) + +data class TvHomeUser( + val name: String, + val plan: String +) diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt new file mode 100644 index 0000000..05c13a9 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt @@ -0,0 +1,347 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.MaterialTheme +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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.sp +import coil3.request.ImageRequest +import hu.bbara.purefin.common.ui.PosterCard +import hu.bbara.purefin.common.ui.components.MediaProgressBar +import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem +import androidx.compose.material3.IconButtonDefaults +import hu.bbara.purefin.feature.shared.home.NextUpItem +import hu.bbara.purefin.feature.shared.home.PosterItem +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import kotlin.math.nextUp + +@Composable +fun TvContinueWatchingSection( + items: List, + onMovieSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) return + TvSectionHeader( + title = "Continue Watching", + action = null + ) + LazyRow( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(items = items) { item -> + TvContinueWatchingCard( + item = item, + onMovieSelected = onMovieSelected, + onEpisodeSelected = onEpisodeSelected + ) + } + } +} + +@Composable +fun TvContinueWatchingCard( + item: ContinueWatchingItem, + modifier: Modifier = Modifier, + onMovieSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, +) { + val scheme = MaterialTheme.colorScheme + + val context = LocalContext.current + val density = LocalDensity.current + + val imageUrl = when (item.type) { + BaseItemKind.MOVIE -> item.movie?.heroImageUrl + BaseItemKind.EPISODE -> item.episode?.heroImageUrl + else -> null + } + + val cardWidth = 280.dp + val cardHeight = cardWidth * 9 / 16 + + fun openItem(item: ContinueWatchingItem) { + when (item.type) { + BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id) + BaseItemKind.EPISODE -> { + val episode = item.episode!! + onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) + } + + else -> {} + } + } + + val imageRequest = ImageRequest.Builder(context) + .data(imageUrl) + .size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() }) + .build() + + Column( + modifier = modifier + .width(cardWidth) + .wrapContentHeight() + ) { + Box( + modifier = Modifier + .width(cardWidth) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(16.dp)) + .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) + .background(scheme.surfaceVariant) + ) { + PurefinAsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clickable { + openItem(item) + }, + contentScale = ContentScale.Crop, + ) + MediaProgressBar( + progress = item.progress.toFloat().nextUp().div(100), + foregroundColor = scheme.onSurface, + backgroundColor = scheme.primary, + modifier = Modifier + .align(Alignment.BottomStart) + ) + } + Column(modifier = Modifier.padding(top = 12.dp)) { + Text( + text = item.primaryText, + color = scheme.onBackground, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = item.secondaryText, + color = scheme.onSurfaceVariant, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun TvNextUpSection( + items: List, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) return + TvSectionHeader( + title = "Next Up", + action = null + ) + LazyRow( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items( + items = items, key = { it.id }) { item -> + TvNextUpCard( + item = item, + onEpisodeSelected = onEpisodeSelected + ) + } + } +} + +@Composable +fun TvNextUpCard( + item: NextUpItem, + modifier: Modifier = Modifier, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, +) { + val scheme = MaterialTheme.colorScheme + + val context = LocalContext.current + val density = LocalDensity.current + + val imageUrl = item.episode.heroImageUrl + + val cardWidth = 280.dp + val cardHeight = cardWidth * 9 / 16 + + fun openItem(item: NextUpItem) { + val episode = item.episode + onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) + } + + val imageRequest = ImageRequest.Builder(context) + .data(imageUrl) + .size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() }) + .build() + + Column( + modifier = modifier + .width(cardWidth) + .wrapContentHeight() + ) { + Box( + modifier = Modifier + .width(cardWidth) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(16.dp)) + .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) + .background(scheme.surfaceVariant) + ) { + PurefinAsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clickable { + openItem(item) + }, + contentScale = ContentScale.Crop, + ) + IconButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 8.dp, bottom = 16.dp) + .clip(CircleShape) + .background(scheme.secondary) + .size(36.dp), + onClick = { + openItem(item) + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = scheme.secondary, + contentColor = scheme.onSecondary + ) + ) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = "Play", + modifier = Modifier.size(28.dp), + ) + } + } + Column(modifier = Modifier.padding(top = 12.dp)) { + Text( + text = item.primaryText, + color = scheme.onBackground, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = item.secondaryText, + color = scheme.onSurfaceVariant, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun TvLibraryPosterSection( + title: String, + items: List, + action: String?, + modifier: Modifier = Modifier, + onMovieSelected: (UUID) -> Unit, + onSeriesSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, +) { + TvSectionHeader( + title = title, + action = action + ) + LazyRow( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items( + items = items, key = { it.id }) { item -> + PosterCard( + item = item, + onMovieSelected = onMovieSelected, + onSeriesSelected = onSeriesSelected, + onEpisodeSelected = onEpisodeSelected + ) + } + } +} + +@Composable +fun TvSectionHeader( + title: String, + action: String?, + modifier: Modifier = Modifier, + onActionClick: () -> Unit = {} +) { + val scheme = MaterialTheme.colorScheme + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = title, + color = scheme.onBackground, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + if (action != null) { + Text( + text = action, + color = scheme.primary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable { onActionClick() }) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt new file mode 100644 index 0000000..f2e146d --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt @@ -0,0 +1,67 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.CloudOff +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import hu.bbara.purefin.common.ui.components.PurefinIconButton +import hu.bbara.purefin.common.ui.components.SearchField + +@Composable +fun TvHomeTopBar( + onMenuClick: () -> Unit, + isOfflineMode: Boolean, + onToggleOfflineMode: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + + Box( + modifier = modifier + .fillMaxWidth() + .background(scheme.background.copy(alpha = 0.95f)) + .zIndex(1f) + ) { + Row( + modifier = Modifier + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + ) { + PurefinIconButton( + icon = Icons.Outlined.Menu, + contentDescription = "Menu", + onClick = onMenuClick, + ) + SearchField( + value = "", + onValueChange = {}, + placeholder = "Search", + backgroundColor = scheme.secondaryContainer, + textColor = scheme.onSecondaryContainer, + cursorColor = scheme.onSecondaryContainer, + modifier = Modifier.weight(1.0f, true), + ) + PurefinIconButton( + icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud, + contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline", + onClick = onToggleOfflineMode + ) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt new file mode 100644 index 0000000..0d81090 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt @@ -0,0 +1,26 @@ +package hu.bbara.purefin.tv.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet +import hu.bbara.purefin.core.data.navigation.Route + +@Module +@InstallIn(ActivityRetainedComponent::class) +object TvNavigationModule { + + @IntoSet + @Provides + fun provideTvHomeEntryBuilder(): EntryProviderScope.() -> Unit = { + tvHomeSection() + } + + @IntoSet + @Provides + fun provideTvLoginEntryBuilder(): EntryProviderScope.() -> Unit = { + tvLoginSection() + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt new file mode 100644 index 0000000..00e494a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt @@ -0,0 +1,18 @@ +package hu.bbara.purefin.tv.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import hu.bbara.purefin.core.data.navigation.Route +import hu.bbara.purefin.login.ui.LoginScreen +import hu.bbara.purefin.tv.home.TvHomePage + +fun EntryProviderScope.tvHomeSection() { + entry { + TvHomePage() + } +} + +fun EntryProviderScope.tvLoginSection() { + entry { + LoginScreen() + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Color.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Color.kt new file mode 100644 index 0000000..8223689 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Color.kt @@ -0,0 +1,218 @@ +package hu.bbara.purefin.ui.theme +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF8A5021) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFFFDCC5) +val onPrimaryContainerLight = Color(0xFF6D390B) +val secondaryLight = Color(0xFF755945) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFFFDCC5) +val onSecondaryContainerLight = Color(0xFF5B412F) +val tertiaryLight = Color(0xFF5E6135) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFE4E6AE) +val onTertiaryContainerLight = Color(0xFF464920) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFFFF8F5) +val onBackgroundLight = Color(0xFF221A15) +val surfaceLight = Color(0xFFFFF8F5) +val onSurfaceLight = Color(0xFF221A15) +val surfaceVariantLight = Color(0xFFF3DFD2) +val onSurfaceVariantLight = Color(0xFF52443B) +val outlineLight = Color(0xFF84746A) +val outlineVariantLight = Color(0xFFD6C3B7) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF382F29) +val inverseOnSurfaceLight = Color(0xFFFEEEE4) +val inversePrimaryLight = Color(0xFFFFB783) +val surfaceDimLight = Color(0xFFE7D7CE) +val surfaceBrightLight = Color(0xFFFFF8F5) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFFFF1E9) +val surfaceContainerLight = Color(0xFFFBEBE1) +val surfaceContainerHighLight = Color(0xFFF5E5DC) +val surfaceContainerHighestLight = Color(0xFFF0DFD6) + +val primaryLightMediumContrast = Color(0xFF572A00) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF9B5E2E) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF493120) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF856753) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF363911) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF6D7042) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF740006) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFCF2C27) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFFF8F5) +val onBackgroundLightMediumContrast = Color(0xFF221A15) +val surfaceLightMediumContrast = Color(0xFFFFF8F5) +val onSurfaceLightMediumContrast = Color(0xFF17100B) +val surfaceVariantLightMediumContrast = Color(0xFFF3DFD2) +val onSurfaceVariantLightMediumContrast = Color(0xFF40342B) +val outlineLightMediumContrast = Color(0xFF5E5046) +val outlineVariantLightMediumContrast = Color(0xFF7A6A60) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF382F29) +val inverseOnSurfaceLightMediumContrast = Color(0xFFFEEEE4) +val inversePrimaryLightMediumContrast = Color(0xFFFFB783) +val surfaceDimLightMediumContrast = Color(0xFFD3C4BB) +val surfaceBrightLightMediumContrast = Color(0xFFFFF8F5) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFFFF1E9) +val surfaceContainerLightMediumContrast = Color(0xFFF5E5DC) +val surfaceContainerHighLightMediumContrast = Color(0xFFEADAD1) +val surfaceContainerHighestLightMediumContrast = Color(0xFFDECFC6) + +val primaryLightHighContrast = Color(0xFF492200) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF703C0D) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF3D2717) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF5D4431) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF2C2E07) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF494C22) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF600004) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF98000A) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFFF8F5) +val onBackgroundLightHighContrast = Color(0xFF221A15) +val surfaceLightHighContrast = Color(0xFFFFF8F5) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFF3DFD2) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF362A22) +val outlineVariantLightHighContrast = Color(0xFF54463E) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF382F29) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFFFB783) +val surfaceDimLightHighContrast = Color(0xFFC5B6AD) +val surfaceBrightLightHighContrast = Color(0xFFFFF8F5) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFFEEEE4) +val surfaceContainerLightHighContrast = Color(0xFFF0DFD6) +val surfaceContainerHighLightHighContrast = Color(0xFFE1D1C8) +val surfaceContainerHighestLightHighContrast = Color(0xFFD3C4BB) + +val primaryDark = Color(0xFFFFB783) +val onPrimaryDark = Color(0xFF4F2500) +val primaryContainerDark = Color(0xFF6D390B) +val onPrimaryContainerDark = Color(0xFFFFDCC5) +val secondaryDark = Color(0xFFE4BFA7) +val onSecondaryDark = Color(0xFF422B1B) +val secondaryContainerDark = Color(0xFF5B412F) +val onSecondaryContainerDark = Color(0xFFFFDCC5) +val tertiaryDark = Color(0xFFC7CA94) +val onTertiaryDark = Color(0xFF30330B) +val tertiaryContainerDark = Color(0xFF464920) +val onTertiaryContainerDark = Color(0xFFE4E6AE) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF19120D) +val onBackgroundDark = Color(0xFFF0DFD6) +val surfaceDark = Color(0xFF19120D) +val onSurfaceDark = Color(0xFFF0DFD6) +val surfaceVariantDark = Color(0xFF52443B) +val onSurfaceVariantDark = Color(0xFFD6C3B7) +val outlineDark = Color(0xFF9F8D83) +val outlineVariantDark = Color(0xFF52443B) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFF0DFD6) +val inverseOnSurfaceDark = Color(0xFF382F29) +val inversePrimaryDark = Color(0xFF8A5021) +val surfaceDimDark = Color(0xFF19120D) +val surfaceBrightDark = Color(0xFF413731) +val surfaceContainerLowestDark = Color(0xFF140D08) +val surfaceContainerLowDark = Color(0xFF221A15) +val surfaceContainerDark = Color(0xFF261E18) +val surfaceContainerHighDark = Color(0xFF312822) +val surfaceContainerHighestDark = Color(0xFF3C332D) + +val primaryDarkMediumContrast = Color(0xFFFFD4B7) +val onPrimaryDarkMediumContrast = Color(0xFF3F1C00) +val primaryContainerDarkMediumContrast = Color(0xFFC5814E) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFFBD5BC) +val onSecondaryDarkMediumContrast = Color(0xFF362111) +val secondaryContainerDarkMediumContrast = Color(0xFFAB8A74) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFDDE0A9) +val onTertiaryDarkMediumContrast = Color(0xFF252803) +val tertiaryContainerDarkMediumContrast = Color(0xFF919463) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF19120D) +val onBackgroundDarkMediumContrast = Color(0xFFF0DFD6) +val surfaceDarkMediumContrast = Color(0xFF19120D) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF52443B) +val onSurfaceVariantDarkMediumContrast = Color(0xFFEDD8CC) +val outlineDarkMediumContrast = Color(0xFFC1AEA3) +val outlineVariantDarkMediumContrast = Color(0xFF9E8D82) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFF0DFD6) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF312822) +val inversePrimaryDarkMediumContrast = Color(0xFF6F3A0C) +val surfaceDimDarkMediumContrast = Color(0xFF19120D) +val surfaceBrightDarkMediumContrast = Color(0xFF4D423C) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0603) +val surfaceContainerLowDarkMediumContrast = Color(0xFF241C16) +val surfaceContainerDarkMediumContrast = Color(0xFF2F2620) +val surfaceContainerHighDarkMediumContrast = Color(0xFF3A312B) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF463C35) + +val primaryDarkHighContrast = Color(0xFFFFECE2) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFFEB27A) +val onPrimaryContainerDarkHighContrast = Color(0xFF170700) +val secondaryDarkHighContrast = Color(0xFFFFECE2) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFE0BBA3) +val onSecondaryContainerDarkHighContrast = Color(0xFF170700) +val tertiaryDarkHighContrast = Color(0xFFF1F4BB) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFC3C691) +val onTertiaryContainerDarkHighContrast = Color(0xFF0B0C00) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF19120D) +val onBackgroundDarkHighContrast = Color(0xFFF0DFD6) +val surfaceDarkHighContrast = Color(0xFF19120D) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF52443B) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFFFECE2) +val outlineVariantDarkHighContrast = Color(0xFFD2BFB3) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFF0DFD6) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF6F3A0C) +val surfaceDimDarkHighContrast = Color(0xFF19120D) +val surfaceBrightDarkHighContrast = Color(0xFF594E47) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF261E18) +val surfaceContainerDarkHighContrast = Color(0xFF382F29) +val surfaceContainerHighDarkHighContrast = Color(0xFF433933) +val surfaceContainerHighestDarkHighContrast = Color(0xFF4F453E) diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Theme.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Theme.kt new file mode 100644 index 0000000..d99c74d --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Theme.kt @@ -0,0 +1,275 @@ +package hu.bbara.purefin.ui.theme +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) + +val unspecified_scheme = ColorFamily( + Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified +) + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable() () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Type.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Type.kt new file mode 100644 index 0000000..d7a6cb2 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package hu.bbara.purefin.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/app-tv/src/main/res/drawable/purefin_logo_background.xml b/app-tv/src/main/res/drawable/purefin_logo_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app-tv/src/main/res/drawable/purefin_logo_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-tv/src/main/res/mipmap-anydpi-v26/purefin_logo.xml b/app-tv/src/main/res/mipmap-anydpi-v26/purefin_logo.xml new file mode 100644 index 0000000..fe7f035 --- /dev/null +++ b/app-tv/src/main/res/mipmap-anydpi-v26/purefin_logo.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-tv/src/main/res/mipmap-anydpi-v26/purefin_logo_round.xml b/app-tv/src/main/res/mipmap-anydpi-v26/purefin_logo_round.xml new file mode 100644 index 0000000..fe7f035 --- /dev/null +++ b/app-tv/src/main/res/mipmap-anydpi-v26/purefin_logo_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-tv/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app-tv/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app-tv/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-tv/src/main/res/mipmap-hdpi/purefin_logo.webp b/app-tv/src/main/res/mipmap-hdpi/purefin_logo.webp new file mode 100644 index 0000000..864416e Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/purefin_logo.webp differ diff --git a/app-tv/src/main/res/mipmap-hdpi/purefin_logo_foreground.webp b/app-tv/src/main/res/mipmap-hdpi/purefin_logo_foreground.webp new file mode 100644 index 0000000..a41f551 Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/purefin_logo_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-hdpi/purefin_logo_round.webp b/app-tv/src/main/res/mipmap-hdpi/purefin_logo_round.webp new file mode 100644 index 0000000..0783cc6 Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/purefin_logo_round.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/purefin_logo.webp b/app-tv/src/main/res/mipmap-mdpi/purefin_logo.webp new file mode 100644 index 0000000..a67ca08 Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/purefin_logo.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/purefin_logo_foreground.webp b/app-tv/src/main/res/mipmap-mdpi/purefin_logo_foreground.webp new file mode 100644 index 0000000..327f617 Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/purefin_logo_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/purefin_logo_round.webp b/app-tv/src/main/res/mipmap-mdpi/purefin_logo_round.webp new file mode 100644 index 0000000..e9662eb Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/purefin_logo_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/purefin_logo.webp b/app-tv/src/main/res/mipmap-xhdpi/purefin_logo.webp new file mode 100644 index 0000000..b213faf Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/purefin_logo.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/purefin_logo_foreground.webp b/app-tv/src/main/res/mipmap-xhdpi/purefin_logo_foreground.webp new file mode 100644 index 0000000..9633959 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/purefin_logo_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/purefin_logo_round.webp b/app-tv/src/main/res/mipmap-xhdpi/purefin_logo_round.webp new file mode 100644 index 0000000..014ecd5 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/purefin_logo_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo.webp b/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo.webp new file mode 100644 index 0000000..3f8d244 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_foreground.webp b/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_foreground.webp new file mode 100644 index 0000000..1858f16 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_round.webp b/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_round.webp new file mode 100644 index 0000000..a4eea09 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo.webp b/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo.webp new file mode 100644 index 0000000..eb1be3d Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_foreground.webp b/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_foreground.webp new file mode 100644 index 0000000..c83d402 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_round.webp b/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_round.webp new file mode 100644 index 0000000..4afc8f8 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_round.webp differ diff --git a/app-tv/src/main/res/values/strings.xml b/app-tv/src/main/res/values/strings.xml new file mode 100644 index 0000000..b6076e1 --- /dev/null +++ b/app-tv/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Purefin + \ No newline at end of file diff --git a/app-tv/src/main/res/values/themes.xml b/app-tv/src/main/res/values/themes.xml new file mode 100644 index 0000000..55f99d0 --- /dev/null +++ b/app-tv/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +