diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b092c4c..510cebe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,6 +65,8 @@ dependencies { implementation(libs.okhttp) implementation(libs.logging.interceptor) implementation(libs.androidx.compose.foundation) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt b/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt index 66c85dc..42d074d 100644 --- a/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt +++ b/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt @@ -7,13 +7,23 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.lifecycle.lifecycleScope +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +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.app.HomePage import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.client.JellyfinAuthInterceptor import hu.bbara.purefin.login.ui.LoginScreen import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.ui.theme.PurefinTheme import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okio.Path.Companion.toPath import javax.inject.Inject @AndroidEntryPoint @@ -21,15 +31,48 @@ class PurefinActivity : ComponentActivity() { @Inject lateinit var userSessionRepository: UserSessionRepository + @Inject lateinit var jellyfinApiClient: JellyfinApiClient + @Inject + lateinit var authInterceptor: JellyfinAuthInterceptor + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lifecycleScope.launch { init() } enableEdgeToEdge() setContent { PurefinTheme { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { + add( + OkHttpNetworkFetcherFactory( + callFactory = { + OkHttpClient.Builder() + .addNetworkInterceptor(authInterceptor) + .build() + } + ) + ) + } + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.10) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image_cache").absolutePath.toPath()) + .maxSizeBytes(30000000) + .build() + } + .logger(DebugLogger()) + .crossfade(true) + .build() + } val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false) if (isLoggedIn) { HomePage() diff --git a/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt b/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt index cd74a4e..8dc41cb 100644 --- a/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt +++ b/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt @@ -6,4 +6,6 @@ import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class PurefinApplication : Application() { + + } \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt index aa08ecf..df75268 100644 --- a/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt @@ -46,13 +46,15 @@ class HomePageViewModel @Inject constructor( _continueWatching.value = continueWatching.map { if (it.type == BaseItemKind.EPISODE) { ContinueWatchingItem( + id = it.id, primaryText = it.seriesName!!, secondaryText = it.name!!, progress = it.userData!!.playedPercentage!!.toFloat(), - colors = listOf(Color.Red, Color.Green) + colors = listOf(Color.Red, Color.Green), ) } else { ContinueWatchingItem( + id = it.id, primaryText = it.name!!, secondaryText = it.premiereDate!!.format(DateTimeFormatter.ofLocalizedDate( FormatStyle.MEDIUM)), @@ -86,6 +88,7 @@ class HomePageViewModel @Inject constructor( val libraryItems = jellyfinApiClient.getLibrary(libraryId) val posterItems = libraryItems.map { PosterItem( + id = it.id, title = it.name ?: "Unknown", colors = listOf(Color.Blue, Color.Cyan), isLatest = false diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt index e35c4b4..32fbc98 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import org.jellyfin.sdk.model.UUID data class ContinueWatchingItem( + val id: UUID, val primaryText: String, val secondaryText: String, val progress: Float, @@ -17,6 +18,7 @@ data class LibraryItem( ) data class PosterItem( + val id: UUID, val title: String, val isLatest: Boolean, val colors: List diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt index fb5ebe0..4c97628 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -23,12 +24,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale 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.compose.AsyncImage +import hu.bbara.purefin.image.JellyfinImageHelper +import org.jellyfin.sdk.model.api.ImageType @Composable fun ContinueWatchingSection( @@ -73,10 +77,15 @@ fun ContinueWatchingCard( .clip(RoundedCornerShape(16.dp)) .background(colors.card) ) { - Box( - modifier = Modifier - .matchParentSize() - .background(Brush.linearGradient(item.colors)) + AsyncImage( + model = JellyfinImageHelper.toImageUrl( + url = "https://jellyfin.bbara.hu", + itemId = item.id, + type = ImageType.PRIMARY + ), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) Box( modifier = Modifier @@ -159,36 +168,11 @@ fun PosterCard( .clip(RoundedCornerShape(14.dp)) .background(colors.card) ) { - Box( - modifier = Modifier - .matchParentSize() - .background(Brush.linearGradient(item.colors)) - ) - if (item.isLatest) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - .clip(RoundedCornerShape(6.dp)) - .background(colors.primary) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = "LATEST", - color = colors.onPrimary, - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ) - } - } - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f)) - ) - ) + AsyncImage( + model = JellyfinImageHelper.toImageUrl(url = "https://jellyfin.bbara.hu", itemId = item.id, type = ImageType.PRIMARY), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) Text( text = item.title, diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index 0f81b43..d376ffa 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -64,8 +64,7 @@ class JellyfinApiClient @Inject constructor( userId = userId, startIndex = 0, //TODO remove this limit if needed - limit = 10, - enableImages = false, + limit = 10 ) val response: Response = api.itemsApi.getResumeItems(getResumeItemsRequest) Log.d("getContinueWatching response: {}", response.content.toString()) diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinAuthInterceptor.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinAuthInterceptor.kt new file mode 100644 index 0000000..d68d7b5 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinAuthInterceptor.kt @@ -0,0 +1,22 @@ +package hu.bbara.purefin.client + +import hu.bbara.purefin.session.UserSessionRepository +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class JellyfinAuthInterceptor @Inject constructor ( + private val userSessionRepository: UserSessionRepository +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = runBlocking { userSessionRepository.accessToken.first() } + val request = chain.request().newBuilder() + .addHeader("X-Emby-Token", token) + // Some Jellyfin versions prefer the Authorization header: + // .addHeader("Authorization", "MediaBrowser Client=\"YourAppName\", Device=\"YourDevice\", DeviceId=\"123\", Version=\"1.0.0\", Token=\"$token\"") + .build() + return chain.proceed(request) + } +} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/image/ImageModule.kt b/app/src/main/java/hu/bbara/purefin/image/ImageModule.kt new file mode 100644 index 0000000..40863b3 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/image/ImageModule.kt @@ -0,0 +1,22 @@ +package hu.bbara.purefin.image + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import hu.bbara.purefin.client.JellyfinAuthInterceptor +import okhttp3.OkHttpClient +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ImageModule { + + @Provides + @Singleton + fun provideOkHttpClient(authInterceptor: JellyfinAuthInterceptor): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/image/JellyfinImageHelper.kt b/app/src/main/java/hu/bbara/purefin/image/JellyfinImageHelper.kt new file mode 100644 index 0000000..a238c46 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/image/JellyfinImageHelper.kt @@ -0,0 +1,18 @@ +package hu.bbara.purefin.image + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.ImageType + +class JellyfinImageHelper { + companion object { + fun toImageUrl(url: String, itemId: UUID, type: ImageType): String { + return StringBuilder() + .append(url) + .append("/Items/") + .append(itemId) + .append("/Images/") + .append(type.serialName) + .toString() + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d09f5ab..5c8476b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ datastore = "1.1.1" kotlinxSerializationJson = "1.7.3" okhttp = "4.12.0" foundation = "1.10.1" +coil = "3.3.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -43,6 +44,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + + [plugins]