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 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app-tv/src/main/res/xml/backup_rules.xml b/app-tv/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app-tv/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app-tv/src/main/res/xml/data_extraction_rules.xml b/app-tv/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app-tv/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 4658f4b..4911f7b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,6 +21,7 @@ dependencyResolutionManagement {
rootProject.name = "Purefin"
include(":app")
+include(":app-tv")
include(":core:model")
include(":core:data")
include(":feature:download")