Basic home page & images

This commit is contained in:
Damontecres 2025-09-21 22:16:42 -04:00
parent 76d78246db
commit 51ad92f539
No known key found for this signature in database
16 changed files with 421 additions and 42 deletions

View file

@ -0,0 +1,56 @@
package com.github.damontecres.dolphin
import androidx.compose.runtime.Composable
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.github.damontecres.dolphin.data.ServerRepository
import okhttp3.Call
import okhttp3.OkHttpClient
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class, ExperimentalCoilApi::class)
@Composable
fun CoilConfig(
serverRepository: ServerRepository,
okHttpClient: OkHttpClient,
debugLogging: Boolean,
) {
setSingletonImageLoaderFactory { ctx ->
ImageLoader
.Builder(ctx)
.diskCache(
DiskCache
.Builder()
.directory(ctx.cacheDir.resolve("coil3_image_cache"))
.maxSizeBytes(100L * 1024 * 1024)
.build(),
).crossfade(true)
.logger(if (debugLogging) DebugLogger() else null)
.components {
add(
OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() },
callFactory = {
Call.Factory { request ->
// Ref: https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f
val token = serverRepository.currentUser?.accessToken
okHttpClient.newCall(
request
.newBuilder()
.addHeader("Authorization", "MediaBrowser Token=\"$token\"")
.build(),
)
}
},
),
)
}.build()
}
}

View file

@ -2,11 +2,15 @@ package com.github.damontecres.dolphin
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
class DolphinApplication : Application() {
init {
instance = this
// TODO only plant in debug builds
Timber.plant(Timber.DebugTree())
}
companion object {

View file

@ -0,0 +1,12 @@
package com.github.damontecres.dolphin
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun CharSequence?.isNotNullOrBlank(): Boolean {
contract {
returns(true) implies (this@isNotNullOrBlank != null)
}
return !this.isNullOrBlank()
}

View file

@ -12,13 +12,14 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.datastore.core.DataStore
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.ServerLoginPage
import com.github.damontecres.dolphin.ui.main.MainPage
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import javax.inject.Inject
@AndroidEntryPoint
@ -29,6 +30,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var userPreferencesDataStore: DataStore<UserPreferences>
@Inject
lateinit var okHttpClient: OkHttpClient
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -39,6 +43,8 @@ class MainActivity : AppCompatActivity() {
modifier = Modifier.fillMaxSize(),
shape = RectangleShape,
) {
CoilConfig(serverRepository, okHttpClient, false)
val preferences by userPreferencesDataStore.data.collectAsState(null)
preferences?.let { preferences ->
if (preferences.currentServerId.isNotBlank() && preferences.currentUserId.isNotBlank()) {
@ -54,7 +60,11 @@ class MainActivity : AppCompatActivity() {
val server = serverRepository.currentServer
val user = serverRepository.currentUser
if (server != null && user != null) {
Text("Logged in as ${user.name} on ${server.url}")
// TODO navigation
MainPage(
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
}
}
}

View file

@ -8,6 +8,7 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userApi
import timber.log.Timber
import javax.inject.Inject
class ServerRepository
@ -51,6 +52,7 @@ class ServerRepository
server: JellyfinServer,
user: JellyfinUser,
) {
Timber.v("Changing user to ${user.name} on ${server.url}")
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
try {
apiClient.userApi
@ -60,6 +62,7 @@ class ServerRepository
_currentUser = user
} catch (e: InvalidStatusException) {
// TODO
Timber.e(e)
if (e.status == 401) {
// Unauthorized
_currentServer = null

View file

@ -0,0 +1,21 @@
package com.github.damontecres.dolphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
sealed interface DolphinModel {
val id: UUID
val name: String?
val imageUrl: String?
}
fun convertModel(
dto: BaseItemDto,
api: ApiClient,
): DolphinModel =
when (dto.type) {
BaseItemKind.VIDEO -> Video.fromDto(dto, api)
else -> throw IllegalArgumentException("Unsupported item type: ${dto.type}")
}

View file

@ -0,0 +1,25 @@
package com.github.damontecres.dolphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
data class Video(
override val id: UUID,
override val name: String?,
override val imageUrl: String?,
) : DolphinModel {
companion object {
fun fromDto(
dto: BaseItemDto,
api: ApiClient,
): Video =
Video(
id = dto.id,
name = dto.name,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
)
}
}

View file

@ -15,11 +15,13 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DolphinModule {
@Provides
@Singleton
fun okHttpClient() =
OkHttpClient
.Builder()
@ -28,9 +30,11 @@ object DolphinModule {
}.build()
@Provides
@Singleton
fun okHttpFactory(okHttpClient: OkHttpClient) = OkHttpFactory(okHttpClient)
@Provides
@Singleton
fun jellyfin(
okHttpFactory: OkHttpFactory,
@ApplicationContext context: Context,
@ -53,5 +57,6 @@ object DolphinModule {
}
@Provides
@Singleton
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
}

View file

@ -1,39 +0,0 @@
package com.github.damontecres.dolphin.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.github.damontecres.dolphin.preferences.UserPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userApi
import javax.inject.Inject
@HiltViewModel
class MainViewModel
@Inject
constructor(
val api: ApiClient,
) : ViewModel() {
init {
viewModelScope.launch {
api.userApi.getCurrentUser().content.configuration?.let {
it.orderedViews
}
}
}
fun todo() {
}
}
@Composable
fun MainPage(
preferences: UserPreferences,
modifier: Modifier,
viewModel: MainViewModel = viewModel(),
) {
}

View file

@ -0,0 +1,23 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.github.damontecres.dolphin.data.model.DolphinModel
import com.github.damontecres.dolphin.data.model.Video
@Composable
fun DolphinCard(
item: DolphinModel?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (item) {
is Video ->
VideoCard(
item = item,
onClick = onClick,
modifier = modifier,
)
null -> TODO()
}
}

View file

@ -0,0 +1,38 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.Video
@Composable
fun VideoCard(
item: Video,
onClick: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 150.dp,
cardHeight: Dp = 200.dp,
) {
Card(
modifier = modifier,
onClick = onClick,
) {
Column(
modifier = Modifier.size(cardWidth, cardHeight),
) {
Text(
text = item.name ?: "",
)
AsyncImage(
model = item.imageUrl,
contentDescription = item.name,
)
}
}
}

View file

@ -0,0 +1,30 @@
package com.github.damontecres.dolphin.ui.main
import androidx.annotation.StringRes
import com.github.damontecres.dolphin.R
/**
* All possible homesections, "synced" with jellyfin-web.
*
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
*/
enum class HomeSection(
val key: String,
@param:StringRes val nameRes: Int,
) {
LATEST_MEDIA("latestmedia", R.string.home_section_latest_media),
LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library),
LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small),
RESUME("resume", R.string.home_section_resume),
RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio),
RESUME_BOOK("resumebook", R.string.home_section_resume_book),
ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings),
NEXT_UP("nextup", R.string.home_section_next_up),
LIVE_TV("livetv", R.string.home_section_livetv),
NONE("none", R.string.home_section_none),
;
companion object {
fun fromKey(key: String): HomeSection = entries.firstOrNull { it.key == key } ?: NONE
}
}

View file

@ -0,0 +1,169 @@
package com.github.damontecres.dolphin.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.DolphinModel
import com.github.damontecres.dolphin.data.model.convertModel
import com.github.damontecres.dolphin.isNotNullOrBlank
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.DolphinCard
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import timber.log.Timber
import javax.inject.Inject
data class HomeRow(
val section: HomeSection,
val items: List<DolphinModel>,
)
@HiltViewModel
class MainViewModel
@Inject
constructor(
val api: ApiClient,
) : ViewModel() {
val homeRows = MutableLiveData<List<HomeRow>>()
init {
viewModelScope.launch(Dispatchers.IO) {
val user = api.userApi.getCurrentUser().content
val displayPrefs =
api.displayPreferencesApi
.getDisplayPreferences(
displayPreferencesId = "usersettings",
client = "emby",
).content
val homeSections =
displayPrefs.customPrefs.entries
.filter { it.key.startsWith("homesection") && it.value.isNotNullOrBlank() }
.sortedBy { it.key }
.map { HomeSection.fromKey(it.value ?: "") }
.filterNot { it == HomeSection.NONE }
val homeRows =
homeSections
.mapNotNull { section ->
Timber.v("Loading section: %s", section.name)
when (section) {
HomeSection.LATEST_MEDIA -> {
user.configuration?.orderedViews?.firstOrNull()?.let { viewId ->
val request =
GetLatestMediaRequest(
fields = listOf(),
imageTypeLimit = 1,
parentId = viewId,
groupItems = true,
limit = 25,
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { convertModel(it, api) }
Timber.v("latest: %s", latest)
// api.itemsApi.getItems(request)
HomeRow(
section = section,
items = latest,
)
}
}
// TODO
HomeSection.LIBRARY_TILES_SMALL -> null
HomeSection.RESUME -> null
HomeSection.ACTIVE_RECORDINGS -> null
HomeSection.NEXT_UP -> null
HomeSection.LIVE_TV -> null
// TODO Not supported?
HomeSection.LIBRARY_BUTTONS -> null
HomeSection.RESUME_AUDIO -> null
HomeSection.RESUME_BOOK -> null
HomeSection.NONE -> null
}
}.filter { it.items.isNotEmpty() }
withContext(Dispatchers.Main) {
this@MainViewModel.homeRows.value = homeRows
}
}
}
}
@Composable
fun MainPage(
preferences: UserPreferences,
modifier: Modifier,
viewModel: MainViewModel = viewModel(),
) {
val homeRows by viewModel.homeRows.observeAsState(listOf())
Column(modifier = modifier) {
// TODO header?
LazyColumn {
homeRows.forEach { row ->
item {
HomePageRow(
row = row,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
@Composable
fun HomePageRow(
row: HomeRow,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = stringResource(row.section.nameRes),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth(),
) {
items(row.items) { item ->
DolphinCard(
item = item,
onClick = {},
modifier = Modifier,
)
}
}
}
}

View file

@ -1,3 +1,14 @@
<resources>
<string name="app_name">Dolphin</string>
<string name="home_section_latest_media">Recently added media</string>
<string name="home_section_library">My media</string>
<string name="home_section_library_small">My media (small)</string>
<string name="home_section_resume">Continue watching</string>
<string name="home_section_resume_audio">Continue listening</string>
<string name="home_section_resume_book">Continue reading</string>
<string name="home_section_active_recordings">Active recordings</string>
<string name="home_section_next_up">Next up</string>
<string name="home_section_livetv">Live TV</string>
<string name="home_section_none">None</string>
</resources>