diff --git a/app/src/debug/res/values/strings_donottranslate.xml b/app/src/debug/res/values/strings_donottranslate.xml index 5f82fbeb..789da187 100644 --- a/app/src/debug/res/values/strings_donottranslate.xml +++ b/app/src/debug/res/values/strings_donottranslate.xml @@ -1,4 +1,5 @@ Jellyfin Debug + Jellyfin diff --git a/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt b/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt index 89ef6603..5b06cfe1 100644 --- a/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt @@ -19,6 +19,7 @@ import org.jellyfin.mobile.player.PlayerEvent import org.jellyfin.mobile.player.PlayerFragment import org.jellyfin.mobile.player.source.MediaSourceResolver import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.ImageResolver import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.viewmodel.MainViewModel import org.jellyfin.mobile.webapp.RemoteVolumeProvider @@ -35,6 +36,7 @@ val applicationModule = module { single { AppPreferences(androidApplication()) } single { OkHttpClient() } single { ImageLoader(androidApplication()) } + single { ImageResolver(androidApplication(), get(), get()) } single { PermissionRequestHelper() } single { WebappFunctionChannel() } single { RemoteVolumeProvider(get()) } diff --git a/app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt b/app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt index 29d07c15..fff2dccb 100644 --- a/app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt +++ b/app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt @@ -2,8 +2,10 @@ package org.jellyfin.mobile import android.app.Application import android.webkit.WebView +import coil.Coil import com.melegy.redscreenofdeath.RedScreenOfDeath import org.jellyfin.mobile.api.apiModule +import org.jellyfin.mobile.controller.controllerModule import org.jellyfin.mobile.model.databaseModule import org.jellyfin.mobile.utils.JellyTree import org.jellyfin.mobile.utils.isWebViewSupported @@ -37,8 +39,14 @@ class JellyfinApplication : Application() { modules( applicationModule, apiModule, + controllerModule, databaseModule, ) + + // Set Coil ImageLoader factory + Coil.setImageLoader { + koin.get() + } } } } diff --git a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt index f54ab42f..499f3b38 100644 --- a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt +++ b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt @@ -14,12 +14,12 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch import org.jellyfin.mobile.cast.Chromecast import org.jellyfin.mobile.cast.IChromecast import org.jellyfin.mobile.fragment.ConnectFragment import org.jellyfin.mobile.fragment.WebViewFragment import org.jellyfin.mobile.player.PlayerFragment +import org.jellyfin.mobile.ui.MainFragment import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.utils.SmartOrientationListener @@ -82,20 +82,26 @@ class MainActivity : AppCompatActivity() { } // Load UI - lifecycleScope.launchWhenStarted { - mainViewModel.serverState.collect { state -> - with(supportFragmentManager) { - when (state) { - ServerState.Pending -> { - // TODO add loading indicator - } - is ServerState.Unset -> replaceFragment() - is ServerState.Available -> { - val currentFragment = findFragmentById(R.id.fragment_container) - if (currentFragment !is WebViewFragment || currentFragment.server != state.server) { - replaceFragment(Bundle().apply { - putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server) - }) + if (true) { + with(supportFragmentManager) { + replaceFragment() + } + } else { + lifecycleScope.launchWhenStarted { + mainViewModel.serverState.collect { state -> + with(supportFragmentManager) { + when (state) { + ServerState.Pending -> { + // TODO add loading indicator + } + is ServerState.Unset -> replaceFragment() + is ServerState.Available -> { + val currentFragment = findFragmentById(R.id.fragment_container) + if (currentFragment !is WebViewFragment || currentFragment.server != state.server) { + replaceFragment(Bundle().apply { + putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server) + }) + } } } } diff --git a/app/src/main/java/org/jellyfin/mobile/api/ApiModule.kt b/app/src/main/java/org/jellyfin/mobile/api/ApiModule.kt index d802d2f1..2c5d5e38 100644 --- a/app/src/main/java/org/jellyfin/mobile/api/ApiModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/api/ApiModule.kt @@ -13,6 +13,7 @@ import org.jellyfin.sdk.api.operations.PlayStateApi import org.jellyfin.sdk.api.operations.PlaylistsApi import org.jellyfin.sdk.api.operations.SystemApi import org.jellyfin.sdk.api.operations.UniversalAudioApi +import org.jellyfin.sdk.api.operations.UserApi import org.jellyfin.sdk.api.operations.UserViewsApi import org.jellyfin.sdk.api.operations.VideosApi import org.jellyfin.sdk.discovery.AndroidBroadcastAddressesProvider @@ -41,6 +42,7 @@ val apiModule = module { single { SystemApi(get()) } single { ItemsApi(get()) } single { ImageApi(get()) } + single { UserApi(get()) } single { UserViewsApi(get()) } single { ArtistsApi(get()) } single { GenresApi(get()) } diff --git a/app/src/main/java/org/jellyfin/mobile/controller/ApiController.kt b/app/src/main/java/org/jellyfin/mobile/controller/ApiController.kt index 730b7ab3..e638c861 100644 --- a/app/src/main/java/org/jellyfin/mobile/controller/ApiController.kt +++ b/app/src/main/java/org/jellyfin/mobile/controller/ApiController.kt @@ -79,7 +79,7 @@ class ApiController( apiClient.deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId) } - private fun resetApiClientUser() { + fun resetApiClientUser() { apiClient.userId = null apiClient.accessToken = null apiClient.deviceInfo = baseDeviceInfo diff --git a/app/src/main/java/org/jellyfin/mobile/controller/ControllerModule.kt b/app/src/main/java/org/jellyfin/mobile/controller/ControllerModule.kt new file mode 100644 index 00000000..03723900 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/controller/ControllerModule.kt @@ -0,0 +1,9 @@ +package org.jellyfin.mobile.controller + +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val controllerModule = module { + single { LoginController(androidContext(), get(), get(), get(), get(), get()) } + single { LibraryController(get(), get()) } +} diff --git a/app/src/main/java/org/jellyfin/mobile/controller/LibraryController.kt b/app/src/main/java/org/jellyfin/mobile/controller/LibraryController.kt new file mode 100644 index 00000000..53011219 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/controller/LibraryController.kt @@ -0,0 +1,43 @@ +package org.jellyfin.mobile.controller + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jellyfin.mobile.model.dto.UserViewInfo +import org.jellyfin.mobile.model.dto.toUserViewInfo +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import java.util.UUID + +class LibraryController( + private val apiClient: ApiClient, + private val userViewsApi: UserViewsApi, +) { + private val scope = CoroutineScope(Dispatchers.Default) + + var userViews: List by mutableStateOf(emptyList()) + + init { + scope.launch { + val response by userViewsApi.getUserViews(requireNotNull(apiClient.userId)) + userViews = response.items?.run { + map(BaseItemDto::toUserViewInfo).filter { item -> item.collectionType in SUPPORTED_COLLECTION_TYPES } + } ?: emptyList() + } + } + + fun getCollection(id: UUID): UserViewInfo? = userViews.find { collection -> collection.id == id } + + companion object { + val SUPPORTED_COLLECTION_TYPES = setOf( + "movies", + "tvshows", + "music", + "musicvideos", + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/controller/LoginController.kt b/app/src/main/java/org/jellyfin/mobile/controller/LoginController.kt new file mode 100644 index 00000000..56587f82 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/controller/LoginController.kt @@ -0,0 +1,142 @@ +package org.jellyfin.mobile.controller + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.mobile.R +import org.jellyfin.mobile.model.dto.UserInfo +import org.jellyfin.mobile.model.sql.dao.UserDao +import org.jellyfin.mobile.model.state.CheckUrlState +import org.jellyfin.mobile.model.state.LoginState +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.authenticateUserByName +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import timber.log.Timber + +class LoginController( + private val context: Context, + private val jellyfin: Jellyfin, + private val apiClient: ApiClient, + private val userDao: UserDao, + private val userApi: UserApi, + private val apiController: ApiController, +) { + private val scope = CoroutineScope(Dispatchers.Main) + + var loginState by mutableStateOf(LoginState.PENDING) + var userInfo by mutableStateOf(null) + + init { + scope.launch { + apiController.loadSavedServerUser() + val userId = apiClient.userId + loginState = if (userId != null) { + val userDto by userApi.getUserById(userId) + userInfo = UserInfo(0, userDto) + LoginState.LOGGED_IN + } else { + LoginState.NOT_LOGGED_IN + } + } + } + + suspend fun checkServerUrl(enteredUrl: String): CheckUrlState { + Timber.i("checkServerUrlAndConnection $enteredUrl") + + val candidates = jellyfin.discovery.getAddressCandidates(enteredUrl) + Timber.i("Address candidates are $candidates") + + // Find servers and classify them into groups. + // BAD servers are collected in case we need an error message, + // GOOD are kept if there's no GREAT one. + val badServers = mutableListOf() + val goodServers = mutableListOf() + val greatServer = jellyfin.discovery.getRecommendedServers(candidates).firstOrNull { recommendedServer -> + when (recommendedServer.score) { + RecommendedServerInfoScore.GREAT -> true + RecommendedServerInfoScore.GOOD -> { + goodServers += recommendedServer + false + } + RecommendedServerInfoScore.OK, + RecommendedServerInfoScore.BAD -> { + badServers += recommendedServer + false + } + } + } + + val server = greatServer ?: goodServers.firstOrNull() + if (server != null) { + val systemInfo = requireNotNull(server.systemInfo) + Timber.i("Found valid server at ${server.address} with rating ${server.score} and version ${systemInfo.version}") + // TODO: Set server + server.address + return CheckUrlState.Success + } + + // No valid server found, log and show error message + val loggedServers = badServers.joinToString { "${it.address}/${it.systemInfo}" } + Timber.i("No valid servers found, invalid candidates were: $loggedServers") + + val error = if (badServers.isNotEmpty()) { + val count = badServers.size + val (unreachableServers, incompatibleServers) = badServers.partition { result -> result.systemInfo == null } + + StringBuilder(context.resources.getQuantityString(R.plurals.connection_error_prefix, count, count)).apply { + if (unreachableServers.isNotEmpty()) { + append("\n\n") + append(context.getString(R.string.connection_error_unable_to_reach_sever)) + append(":\n") + append(unreachableServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" }) + } + if (incompatibleServers.isNotEmpty()) { + append("\n\n") + append(context.getString(R.string.connection_error_unsupported_version_or_product)) + append(":\n") + append(incompatibleServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" }) + } + }.toString() + } else null + + return CheckUrlState.Error(error) + } + + suspend fun authenticate(username: String, password: String): Boolean { + requireNotNull(apiClient.baseUrl) { "Server address not set" } + val authResult by userApi.authenticateUserByName(username, password) + val user = authResult.user + val accessToken = authResult.accessToken + if (user != null && accessToken != null) { + apiController.setupUser(0, user.id.toString(), accessToken) + userInfo = UserInfo(0, user) + loginState = LoginState.LOGGED_IN + return true + } + return false + } + + fun tryLogout() { + scope.launch { logout() } + } + + suspend fun logout() { + userInfo?.let { user -> + withContext(Dispatchers.IO) { + userDao.logout(user.id) + } + } + apiController.resetApiClientUser() + loginState = LoginState.NOT_LOGGED_IN + userInfo = null + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/model/BaseItemKind.kt b/app/src/main/java/org/jellyfin/mobile/model/BaseItemKind.kt new file mode 100644 index 00000000..c30160ef --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/BaseItemKind.kt @@ -0,0 +1,38 @@ +package org.jellyfin.mobile.model + +enum class BaseItemKind(val serialName: String) { + AggregateFolder("AggregateFolder"), + Audio("Audio"), + AudioBook("AudioBook"), + BasePluginFolder("BasePluginFolder"), + Book("Book"), + BoxSet("BoxSet"), + Channel("Channel"), + ChannelFolderItem("ChannelFolderItem"), + CollectionFolder("CollectionFolder"), + Episode("Episode"), + Folder("Folder"), + Genre("Genre"), + ManualPlaylistsFolder("ManualPlaylistsFolder"), + Movie("Movie"), + MusicAlbum("MusicAlbum"), + MusicArtist("MusicArtist"), + MusicGenre("MusicGenre"), + MusicVideo("MusicVideo"), + Person("Person"), + Photo("Photo"), + PhotoAlbum("PhotoAlbum"), + Playlist("Playlist"), + Program("Program"), + Recording("Recording"), + Season("Season"), + Series("Series"), + Studio("Studio"), + Trailer("Trailer"), + TvChannel("TvChannel"), + TvProgram("TvProgram"), + UserRootFolder("UserRootFolder"), + UserView("UserView"), + Video("Video"), + Year("Year"), +} diff --git a/app/src/main/java/org/jellyfin/mobile/model/CollectionType.kt b/app/src/main/java/org/jellyfin/mobile/model/CollectionType.kt new file mode 100644 index 00000000..b6222997 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/CollectionType.kt @@ -0,0 +1,16 @@ +package org.jellyfin.mobile.model + +object CollectionType { + const val Movies = "movies" + const val TvShows = "tvshows" + const val Music = "music" + const val MusicVideos = "musicvideos" + const val Trailers = "trailers" + const val HomeVideos = "homevideos" + const val BoxSets = "boxsets" + const val Books = "books" + const val Photos = "photos" + const val LiveTv = "livetv" + const val Playlists = "playlists" + const val Folders = "folders" +} diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/Album.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/Album.kt new file mode 100644 index 00000000..b0813f32 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/Album.kt @@ -0,0 +1,13 @@ +package org.jellyfin.mobile.model.dto + +import androidx.compose.runtime.Immutable +import java.util.UUID + +@Immutable +data class Album( + val id: UUID, + val name: String, + val albumArtist: String, + val artists: List, + val primaryImageTag: String?, +) diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/Artist.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/Artist.kt new file mode 100644 index 00000000..9fe3d1c7 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/Artist.kt @@ -0,0 +1,11 @@ +package org.jellyfin.mobile.model.dto + +import androidx.compose.runtime.Immutable +import java.util.UUID + +@Immutable +data class Artist( + val id: UUID, + val name: String, + val primaryImageTag: String?, +) diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/FolderInfo.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/FolderInfo.kt new file mode 100644 index 00000000..5ae23cc3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/FolderInfo.kt @@ -0,0 +1,11 @@ +package org.jellyfin.mobile.model.dto + +import androidx.compose.runtime.Immutable +import java.util.UUID + +@Immutable +data class FolderInfo( + val id: UUID, + val name: String, + val primaryImageTag: String?, +) diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/ModelExtensions.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/ModelExtensions.kt new file mode 100644 index 00000000..3b4948d9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/ModelExtensions.kt @@ -0,0 +1,13 @@ +package org.jellyfin.mobile.model.dto + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.ImageType +import java.util.Locale + +fun BaseItemDto.toUserViewInfo() = UserViewInfo(id, name.orEmpty(), collectionType.orEmpty().lowercase(Locale.ROOT), imageTags?.get(ImageType.PRIMARY)) + +fun BaseItemDto.toFolderInfo() = FolderInfo(id, name.orEmpty(), imageTags?.get(ImageType.PRIMARY)) +fun BaseItemDto.toAlbum() = Album(id, name.orEmpty(), albumArtist.orEmpty(), artists.orEmpty(), imageTags?.get(ImageType.PRIMARY)) +fun BaseItemDto.toArtist() = Artist(id, name.orEmpty(), imageTags?.get(ImageType.PRIMARY)) +fun BaseItemDto.toSong() = Song(id, name.orEmpty(), artists.orEmpty(), albumId, albumPrimaryImageTag ?: imageTags?.get(ImageType.PRIMARY)) +fun BaseItemDto.toMusicVideo() = MusicVideo(id, name.orEmpty(), artists.orEmpty(), album, imageTags?.get(ImageType.PRIMARY)) diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/MusicVideo.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/MusicVideo.kt new file mode 100644 index 00000000..d9bd775d --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/MusicVideo.kt @@ -0,0 +1,11 @@ +package org.jellyfin.mobile.model.dto + +import java.util.UUID + +data class MusicVideo( + val id: UUID, + val title: String, + val artists: List, + val album: String?, + val primaryImageTag: String?, +) diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/Song.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/Song.kt new file mode 100644 index 00000000..d8a0f734 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/Song.kt @@ -0,0 +1,13 @@ +package org.jellyfin.mobile.model.dto + +import androidx.compose.runtime.Immutable +import java.util.UUID + +@Immutable +data class Song( + val id: UUID, + val title: String, + val artists: List, + val album: UUID?, + val primaryImageTag: String?, +) diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/UserInfo.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/UserInfo.kt new file mode 100644 index 00000000..f7526964 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/UserInfo.kt @@ -0,0 +1,15 @@ +package org.jellyfin.mobile.model.dto + +import androidx.compose.runtime.Immutable +import org.jellyfin.sdk.model.api.UserDto +import java.util.UUID + +@Immutable +data class UserInfo( + val id: Long, + val userId: UUID, + val name: String, + val primaryImageTag: String?, +) { + constructor(id: Long, dto: UserDto) : this(id, dto.id, dto.name.orEmpty(), dto.primaryImageTag) +} diff --git a/app/src/main/java/org/jellyfin/mobile/model/dto/UserViewInfo.kt b/app/src/main/java/org/jellyfin/mobile/model/dto/UserViewInfo.kt new file mode 100644 index 00000000..e58c5d49 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/dto/UserViewInfo.kt @@ -0,0 +1,12 @@ +package org.jellyfin.mobile.model.dto + +import androidx.compose.runtime.Immutable +import java.util.UUID + +@Immutable +data class UserViewInfo( + val id: UUID, + val name: String, + val collectionType: String, + val primaryImageTag: String?, +) diff --git a/app/src/main/java/org/jellyfin/mobile/model/state/AuthState.kt b/app/src/main/java/org/jellyfin/mobile/model/state/AuthState.kt new file mode 100644 index 00000000..d53a51e5 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/state/AuthState.kt @@ -0,0 +1,8 @@ +package org.jellyfin.mobile.model.state + +enum class AuthState { + UNSET, + PENDING, + SUCCESS, + FAILURE, +} diff --git a/app/src/main/java/org/jellyfin/mobile/model/state/CheckUrlState.kt b/app/src/main/java/org/jellyfin/mobile/model/state/CheckUrlState.kt new file mode 100644 index 00000000..01b3cd34 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/state/CheckUrlState.kt @@ -0,0 +1,8 @@ +package org.jellyfin.mobile.model.state + +sealed class CheckUrlState { + object Unchecked : CheckUrlState() + object Pending : CheckUrlState() + object Success : CheckUrlState() + class Error(val message: String?) : CheckUrlState() +} diff --git a/app/src/main/java/org/jellyfin/mobile/model/state/LoginState.kt b/app/src/main/java/org/jellyfin/mobile/model/state/LoginState.kt new file mode 100644 index 00000000..a74007ab --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/model/state/LoginState.kt @@ -0,0 +1,7 @@ +package org.jellyfin.mobile.model.state + +enum class LoginState { + PENDING, + NOT_LOGGED_IN, + LOGGED_IN, +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/App.kt b/app/src/main/java/org/jellyfin/mobile/ui/App.kt new file mode 100644 index 00000000..9a2457f1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/App.kt @@ -0,0 +1,138 @@ +package org.jellyfin.mobile.ui + +import android.os.Bundle +import androidx.compose.animation.Crossfade +import androidx.compose.runtime.Composable +import androidx.fragment.app.add +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navArgument +import androidx.navigation.compose.rememberNavController +import org.jellyfin.mobile.R +import org.jellyfin.mobile.bridge.PlayOptions +import org.jellyfin.mobile.controller.LoginController +import org.jellyfin.mobile.model.CollectionType +import org.jellyfin.mobile.model.state.LoginState +import org.jellyfin.mobile.player.PlayerFragment +import org.jellyfin.mobile.ui.AppDestinations.ROUTE_ALBUM +import org.jellyfin.mobile.ui.AppDestinations.ROUTE_ARTIST +import org.jellyfin.mobile.ui.AppDestinations.ROUTE_HOME +import org.jellyfin.mobile.ui.AppDestinations.ROUTE_MUSIC_COLLECTION +import org.jellyfin.mobile.ui.AppDestinations.ROUTE_MUSIC_VIDEO_COLLECTION +import org.jellyfin.mobile.ui.AppDestinations.ROUTE_UUID_KEY +import org.jellyfin.mobile.ui.screen.SetupScreen +import org.jellyfin.mobile.ui.screen.home.HomeScreen +import org.jellyfin.mobile.ui.screen.library.music.MusicCollectionScreen +import org.jellyfin.mobile.ui.screen.library.musicvideo.MusicVideoCollectionScreen +import org.jellyfin.mobile.ui.utils.LocalFragmentManager +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID + +@Composable +fun AppContent() { + val loginController: LoginController by inject() + Crossfade(targetState = loginController.loginState) { loginState -> + when (loginState) { + LoginState.PENDING -> Unit // do nothing + LoginState.NOT_LOGGED_IN -> SetupScreen() + LoginState.LOGGED_IN -> AppRouter() + } + } +} + +@Composable +fun AppRouter() { + val navController = rememberNavController() + val fragmentManager = LocalFragmentManager.current + + + NavHost(navController = navController, startDestination = ROUTE_HOME) { + composable(ROUTE_HOME) { + HomeScreen( + onClickUserView = { userViewInfo -> + navController.navigate("${userViewInfo.collectionType}/${userViewInfo.id}") + }, + ) + } + composableWithUuidArgument(ROUTE_MUSIC_COLLECTION) { _, collectionId -> + MusicCollectionScreen( + collectionId = collectionId, + onGoBack = { + navController.popBackStack(ROUTE_HOME, inclusive = false) + }, + onClickAlbum = { + + }, + onClickArtist = { + + }, + ) + } + composableWithUuidArgument(ROUTE_MUSIC_VIDEO_COLLECTION) { _, collectionId -> + MusicVideoCollectionScreen( + collectionId = collectionId, + onGoBack = { + navController.popBackStack() + }, + onClickFolder = { folderId -> + navController.navigate("$ROUTE_MUSIC_VIDEO_COLLECTION/$folderId") + }, + onClickMusicVideo = { musicVideoId -> + fragmentManager.beginTransaction().apply { + val playOptions = PlayOptions( + mediaSourceId = musicVideoId, + ids = listOf(musicVideoId), + startIndex = 0, + startPositionTicks = null, + audioStreamIndex = null, + subtitleStreamIndex = null, + ) + val args = Bundle().apply { + putParcelable(Constants.EXTRA_MEDIA_PLAY_OPTIONS, playOptions) + } + add(R.id.fragment_container, args = args) + addToBackStack(null) + }.commit() + }, + ) + } + composableWithUuidArgument(ROUTE_ALBUM) { _, _ -> + //remember(route.info) { AlbumScreen(route.info) }.Content() + } + composableWithUuidArgument(ROUTE_ARTIST) { _, _ -> + //remember(route.info) { ArtistScreen(route.info) }.Content() + } + } +} + +fun NavGraphBuilder.composableWithUuidArgument( + route: String, + content: @Composable (backStackEntry: NavBackStackEntry, UUID) -> Unit, +) { + composable( + route = "$route/{$ROUTE_UUID_KEY}", + arguments = listOf( + navArgument(ROUTE_UUID_KEY) { type = NavType.StringType }, + ), + ) { backStackEntry -> + val arguments = requireNotNull(backStackEntry.arguments) + val uuid = requireNotNull(arguments.getString(ROUTE_UUID_KEY)?.toUUIDOrNull()) + content(backStackEntry, uuid) + } +} + +object AppDestinations { + const val ROUTE_HOME = "home" + const val ROUTE_MOVIES_COLLECTION = CollectionType.Movies + const val ROUTE_TV_SHOWS_COLLECTION = CollectionType.TvShows + const val ROUTE_MUSIC_COLLECTION = CollectionType.Music + const val ROUTE_MUSIC_VIDEO_COLLECTION = CollectionType.MusicVideos + const val ROUTE_ALBUM = "album" + const val ROUTE_ARTIST = "artist" + + const val ROUTE_UUID_KEY = "uuid" +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/Common.kt b/app/src/main/java/org/jellyfin/mobile/ui/Common.kt new file mode 100644 index 00000000..38c5d15a --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/Common.kt @@ -0,0 +1,104 @@ +package org.jellyfin.mobile.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.R + +val TopBarElevation = 4.dp +val DefaultCornerRounding = RoundedCornerShape(8.dp) + +@Composable +fun ScreenScaffold( + modifier: Modifier = Modifier, + title: String, + titleFont: FontFamily? = null, + canGoBack: Boolean = false, + onGoBack: () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + hasElevation: Boolean = true, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + modifier = modifier, + topBar = { + if (canGoBack) { + TopAppBar( + title = { + Text(text = title, fontFamily = titleFont) + }, + navigationIcon = { + ToolbarUpButton(onClick = onGoBack) + }, + actions = actions, + backgroundColor = MaterialTheme.colors.primary, + elevation = if (hasElevation) TopBarElevation else 0.dp, + ) + } else { + TopAppBar( + title = { + Text(text = title, fontFamily = titleFont) + }, + actions = actions, + backgroundColor = MaterialTheme.colors.primary, + elevation = if (hasElevation) TopBarElevation else 0.dp, + ) + } + }, + content = content, + ) +} + +@Composable +fun ToolbarUpButton( + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back_white_24dp), + contentDescription = null, + ) + } +} + +@Composable +inline fun CenterRow( + content: @Composable RowScope.() -> Unit +) = Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, +) + +@Composable +fun ChipletButton( + text: String, + onClick: () -> Unit +) = OutlinedButton( + onClick = onClick, + modifier = Modifier.padding(8.dp), + shape = CircleShape, +) { + Text(text = text, color = MaterialTheme.colors.onSurface) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/KoinHelpers.kt b/app/src/main/java/org/jellyfin/mobile/ui/KoinHelpers.kt new file mode 100644 index 00000000..4071a457 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/KoinHelpers.kt @@ -0,0 +1,32 @@ +package org.jellyfin.mobile.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.koin.core.Koin +import org.koin.core.parameter.ParametersDefinition +import org.koin.core.qualifier.Qualifier +import org.koin.mp.KoinPlatformTools + +@Composable +inline fun inject( + qualifier: Qualifier? = null, + noinline parameters: ParametersDefinition? = null +): Lazy = remember { + val context = KoinPlatformTools.defaultContext().get() + context.inject(qualifier, parameters = parameters) +} + +@Composable +inline fun get( + qualifier: Qualifier? = null, + noinline parameters: ParametersDefinition? = null, +): T = remember { + val context = KoinPlatformTools.defaultContext().get() + context.get(qualifier, parameters) +} + +@Composable +fun getKoin(): Koin = remember { + val context = KoinPlatformTools.defaultContext().get() + context.get() +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/MainFragment.kt b/app/src/main/java/org/jellyfin/mobile/ui/MainFragment.kt new file mode 100644 index 00000000..7b64d3b4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/MainFragment.kt @@ -0,0 +1,42 @@ +package org.jellyfin.mobile.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.core.view.ViewCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewTreeLifecycleOwner +import org.jellyfin.mobile.databinding.FragmentMainBinding +import org.jellyfin.mobile.ui.utils.AppTheme +import org.jellyfin.mobile.ui.utils.LocalFragmentManager +import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins + +class MainFragment : Fragment() { + private var _viewBinding: FragmentMainBinding? = null + private val viewBinding get() = _viewBinding!! + private val composeView: ComposeView get() = viewBinding.composeView + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _viewBinding = FragmentMainBinding.inflate(inflater, container, false) + return composeView.apply { applyWindowInsetsAsMargins() } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ViewTreeLifecycleOwner.set(composeView, this) + + // Apply window insets + ViewCompat.requestApplyInsets(composeView) + + composeView.setContent { + AppTheme { + CompositionLocalProvider(values = arrayOf(LocalFragmentManager provides parentFragmentManager)) { + AppContent() + } + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/SetupScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/SetupScreen.kt new file mode 100644 index 00000000..981144e2 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/SetupScreen.kt @@ -0,0 +1,298 @@ +package org.jellyfin.mobile.ui.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.FixedScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.jellyfin.mobile.R +import org.jellyfin.mobile.controller.LoginController +import org.jellyfin.mobile.model.state.AuthState +import org.jellyfin.mobile.model.state.CheckUrlState +import org.jellyfin.mobile.ui.CenterRow +import org.jellyfin.mobile.ui.get + +@Composable +fun SetupScreen() { + val loginController: LoginController = get() + val serverSelectionState = remember { mutableStateOf(false) } + val serverSelected by serverSelectionState + Surface(color = MaterialTheme.colors.background) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + LogoHeader() + Crossfade(targetState = serverSelected) { serverSelected -> + if (!serverSelected) { + ServerSelection( + loginController = loginController, + serverSelectionState = serverSelectionState, + ) + } else { + AuthInput(loginController = loginController) + } + } + } + } +} + +@Stable +@Composable +fun LogoHeader() { + CenterRow { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + modifier = Modifier + .width(72.dp) + .height(72.dp) + .padding(top = 8.dp), + contentScale = FixedScale(1.2f), + contentDescription = null, + ) + Text( + text = stringResource(R.string.app_name_short), + modifier = Modifier + .padding(vertical = 56.dp) + .padding(start = 12.dp, end = 24.dp), + fontFamily = FontFamily(Font(R.font.quicksand)), + maxLines = 1, + style = MaterialTheme.typography.h3, + ) + } +} + +@Composable +fun ServerSelection( + loginController: LoginController, + serverSelectionState: MutableState +) { + val coroutineScope = rememberCoroutineScope() + val hostnameInputState = remember { mutableStateOf("") } + val checkUrlState = remember { mutableStateOf(CheckUrlState.Unchecked) } + var hostname by hostnameInputState + var urlStateDelegate by checkUrlState + val error = urlStateDelegate as? CheckUrlState.Error + + ServerSelectionStateless( + text = hostname, + errorText = error?.message, + onTextChange = { value -> + urlStateDelegate = CheckUrlState.Unchecked + hostname = value + }, + loading = urlStateDelegate is CheckUrlState.Pending, + submit = { + coroutineScope.launch { + checkUrlState.value = CheckUrlState.Pending + with(loginController) { + checkServerUrl(hostnameInputState.value).let { state -> + checkUrlState.value = state + if (state is CheckUrlState.Success) { + serverSelectionState.value = true + } + } + } + } + }, + ) +} + +@Stable +@Composable +@OptIn(ExperimentalAnimationApi::class) +private fun ServerSelectionStateless( + text: String, + errorText: String?, + onTextChange: (String) -> Unit, + loading: Boolean, + submit: () -> Unit +) { + Column { + Text( + text = stringResource(R.string.connect_to_server_title), + modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.h5, + ) + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { + Text(text = stringResource(R.string.host_input_hint)) + }, + isError = errorText != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions { + submit() + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedLabelColor = MaterialTheme.colors.secondary, + focusedBorderColor = MaterialTheme.colors.secondary, + ), + ) + AnimatedVisibility(visible = errorText != null) { + Text( + text = errorText.orEmpty(), + modifier = Modifier.padding(bottom = 8.dp), + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + ) + } + CenterRow { + TextButton( + onClick = submit, + modifier = Modifier.padding(4.dp), + enabled = !loading, + colors = ButtonDefaults.textButtonColors(MaterialTheme.colors.secondary), + ) { + Text(text = stringResource(R.string.connect_button_text)) + } + } + } +} + +@Composable +fun AuthInput( + loginController: LoginController +) { + val coroutineScope = rememberCoroutineScope() + val authState = remember { mutableStateOf(AuthState.UNSET) } + val usernameInputState = remember { mutableStateOf("") } + val passwordInputState = remember { mutableStateOf("") } + var authStateDelegate by authState + var username by usernameInputState + var password by passwordInputState + + AuthInputStateless( + username = username, + password = password, + onUsernameChange = { user -> + authStateDelegate = AuthState.UNSET + username = user + }, + onPasswordChange = { pw -> + authStateDelegate = AuthState.UNSET + password = pw + }, + loading = authStateDelegate == AuthState.PENDING, + error = authStateDelegate == AuthState.FAILURE, + submit = submit@{ + if (username.isEmpty() || password.isEmpty()) + return@submit + + coroutineScope.launch { + authStateDelegate = AuthState.PENDING + val authSuccess = loginController.authenticate(username, password) + authStateDelegate = if (authSuccess) AuthState.SUCCESS else AuthState.FAILURE + } + }, + ) +} + +@Stable +@Composable +@OptIn(ExperimentalAnimationApi::class) +fun AuthInputStateless( + username: String, + password: String, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + loading: Boolean, + error: Boolean, + submit: () -> Unit +) { + Column { + val passwordFocusRequester = remember { FocusRequester() } + Text( + text = stringResource(R.string.connect_to_server_title), + modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.h5, + ) + OutlinedTextField( + value = username, + onValueChange = onUsernameChange, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { + Text(text = stringResource(R.string.username_input_hint)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions { + passwordFocusRequester.requestFocus() + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedLabelColor = MaterialTheme.colors.secondary, + focusedBorderColor = MaterialTheme.colors.secondary, + ), + ) + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + modifier = Modifier + .focusRequester(passwordFocusRequester) + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { + Text(text = stringResource(R.string.password_input_hint)) + }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions { + submit() + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedLabelColor = MaterialTheme.colors.secondary, + focusedBorderColor = MaterialTheme.colors.secondary, + ), + ) + AnimatedVisibility(visible = error) { + Text( + text = stringResource(R.string.error_text_wrong_login), + modifier = Modifier.padding(bottom = 8.dp), + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + ) + } + CenterRow { + TextButton( + onClick = submit, + modifier = Modifier.padding(4.dp), + enabled = !loading && username.isNotEmpty() && password.isNotEmpty(), + colors = ButtonDefaults.textButtonColors(MaterialTheme.colors.secondary), + ) { + Text(text = stringResource(R.string.login_button_text)) + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/home/HomeScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/home/HomeScreen.kt new file mode 100644 index 00000000..de736a17 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/home/HomeScreen.kt @@ -0,0 +1,62 @@ +package org.jellyfin.mobile.ui.screen.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.R +import org.jellyfin.mobile.controller.LibraryController +import org.jellyfin.mobile.controller.LoginController +import org.jellyfin.mobile.model.dto.UserViewInfo +import org.jellyfin.mobile.ui.ScreenScaffold +import org.jellyfin.mobile.ui.get + +@Composable +fun HomeScreen( + onClickUserView: (UserViewInfo) -> Unit, +) { + val loginController: LoginController = get() + val libraryController: LibraryController = get() + val currentUser = loginController.userInfo ?: return + val userDetailsState = remember { mutableStateOf(false) } + val (userDetailsShown, showUserDetails) = userDetailsState + ScreenScaffold( + title = stringResource(R.string.app_name_short), + titleFont = FontFamily(Font(R.font.quicksand)), + actions = { + UserDetailsButton( + user = currentUser, + showUserDetails = showUserDetails, + ) + }, + ) { + Column { + Text( + text = "Welcome, ${currentUser.name}", + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.h5, + ) + UserViews( + views = libraryController.userViews, + onClickView = onClickUserView, + ) + } + if (userDetailsShown) { + UserDetails( + loginController = loginController, + user = currentUser, + showUserDetails = showUserDetails, + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/home/UserDetails.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/home/UserDetails.kt new file mode 100644 index 00000000..cf67c079 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/home/UserDetails.kt @@ -0,0 +1,110 @@ +package org.jellyfin.mobile.ui.screen.home + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import org.jellyfin.mobile.R +import org.jellyfin.mobile.controller.LoginController +import org.jellyfin.mobile.model.dto.UserInfo +import org.jellyfin.mobile.ui.ChipletButton +import org.jellyfin.mobile.ui.DefaultCornerRounding +import org.jellyfin.mobile.ui.utils.ApiUserImage +import org.jellyfin.mobile.utils.toast + +@Composable +fun UserImage(modifier: Modifier = Modifier, user: UserInfo) { + Surface( + modifier = Modifier + .size(56.dp) + .padding(8.dp) + .clip(CircleShape) + .then(modifier), + color = MaterialTheme.colors.primaryVariant, + ) { + ApiUserImage( + userInfo = user, + modifier = Modifier.size(40.dp), + ) + } +} + +@Composable +fun UserDetailsButton( + modifier: Modifier = Modifier, + user: UserInfo, + showUserDetails: (Boolean) -> Unit +) { + UserImage( + modifier = modifier.clickable { + showUserDetails(true) + }, + user = user, + ) +} + +@Composable +fun UserDetails( + loginController: LoginController, + user: UserInfo, + showUserDetails: (Boolean) -> Unit +) { + Popup( + alignment = Alignment.TopCenter, + properties = PopupProperties(focusable = true), + onDismissRequest = { + showUserDetails(false) + }, + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + shape = DefaultCornerRounding, + ) { + Column(modifier = Modifier + .padding(8.dp) + .fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserImage(user = user) + Text( + text = user.name, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + val context = LocalContext.current + ChipletButton( + text = stringResource(R.string.profile_button_text), + onClick = { + context.toast("Not implemented") + }, + ) + ChipletButton( + text = stringResource(R.string.logout_button_text), + onClick = loginController::tryLogout, + ) + } + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/home/UserViews.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/home/UserViews.kt new file mode 100644 index 00000000..4f49dd98 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/home/UserViews.kt @@ -0,0 +1,63 @@ +package org.jellyfin.mobile.ui.screen.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.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.unit.dp +import org.jellyfin.mobile.model.dto.UserViewInfo +import org.jellyfin.mobile.ui.DefaultCornerRounding +import org.jellyfin.mobile.ui.utils.ApiImage +import org.jellyfin.sdk.model.api.ImageType + +@Composable +fun UserViews( + views: List, + onClickView: (UserViewInfo) -> Unit, +) { + LazyRow( + modifier = Modifier.fillMaxWidth(1f), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp), + ) { + items(views) { item -> + UserView(view = item, onClick = onClickView) + } + } +} + +@Composable +fun UserView( + view: UserViewInfo, + onClick: (UserViewInfo) -> Unit, +) { + Column(modifier = Modifier.padding(4.dp)) { + val width = 256.dp + val height = 144.dp + ApiImage( + id = view.id, + modifier = Modifier + .width(width) + .height(height) + .clip(DefaultCornerRounding) + .clickable(onClick = { onClick(view) }), + imageType = ImageType.PRIMARY, + imageTag = view.primaryImageTag, + ) + Text( + text = view.name, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp), + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/BaseMediaItem.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/BaseMediaItem.kt new file mode 100644 index 00000000..6775d301 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/BaseMediaItem.kt @@ -0,0 +1,76 @@ +package org.jellyfin.mobile.ui.screen.library + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.ui.DefaultCornerRounding +import org.jellyfin.mobile.ui.utils.ApiImage +import org.jellyfin.sdk.model.api.ImageType +import java.util.UUID + +@Stable +@Composable +fun BaseMediaItem( + modifier: Modifier = Modifier, + id: UUID, + title: String, + subtitle: String? = null, + primaryImageTag: String? = null, + @DrawableRes fallbackResource: Int = 0, + imageDecorator: @Composable () -> Unit = {}, + onClick: () -> Unit = {}, +) { + Column( + modifier = modifier + .clip(DefaultCornerRounding) + .clickable(onClick = onClick) + .padding(8.dp), + ) { + BoxWithConstraints { + val imageSize = with(LocalDensity.current) { constraints.maxWidth.toDp() } + ApiImage( + id = id, + modifier = Modifier + .size(imageSize) + .clip(DefaultCornerRounding), + imageType = ImageType.PRIMARY, + imageTag = primaryImageTag, + fallback = { + Image( + painter = painterResource(fallbackResource), + contentDescription = null, + ) + }, + ) + imageDecorator() + } + Text( + text = title, + modifier = Modifier.padding(top = 6.dp, bottom = if (subtitle != null) 2.dp else 0.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + subtitle?.let { subtitle -> + Text( + text = subtitle, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.caption, + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/LibraryViewModel.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/LibraryViewModel.kt new file mode 100644 index 00000000..a51e2957 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/LibraryViewModel.kt @@ -0,0 +1,13 @@ +package org.jellyfin.mobile.ui.screen.library + +import androidx.lifecycle.ViewModel +import org.jellyfin.mobile.model.dto.UserViewInfo +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.operations.ItemsApi +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +abstract class LibraryViewModel(protected val viewInfo: UserViewInfo) : ViewModel(), KoinComponent { + protected val apiClient: ApiClient by inject() + protected val itemsApi: ItemsApi by inject() +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/TabbedContent.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/TabbedContent.kt new file mode 100644 index 00000000..52b19c04 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/TabbedContent.kt @@ -0,0 +1,61 @@ +package org.jellyfin.mobile.ui.screen.library + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.Tab +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.zIndex +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.rememberPagerState +import kotlinx.coroutines.launch +import org.jellyfin.mobile.ui.TopBarElevation + +@Composable +@OptIn(ExperimentalPagerApi::class) +fun TabbedContent( + tabTitles: List, + currentTabState: MutableState, + pageContent: @Composable (Int) -> Unit +) { + Column { + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = tabTitles.size, initialPage = 0) + + val elevationPx = with(LocalDensity.current) { TopBarElevation.toPx() } + ScrollableTabRow( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer(shadowElevation = elevationPx) + .zIndex(TopBarElevation.value), + selectedTabIndex = pagerState.currentPage, + backgroundColor = MaterialTheme.colors.primary, + divider = {}, + ) { + tabTitles.forEachIndexed { index, title -> + Tab(selected = index == pagerState.currentPage, onClick = { + coroutineScope.launch { + currentTabState.value = index + pagerState.scrollToPage(index) + } + }, text = { + Text(title) + }) + } + } + HorizontalPager(state = pagerState) { page -> + Column(modifier = Modifier.fillMaxSize()) { + pageContent(page) + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/AlbumList.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/AlbumList.kt new file mode 100644 index 00000000..daede20b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/AlbumList.kt @@ -0,0 +1,33 @@ +package org.jellyfin.mobile.ui.screen.library.music + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.model.dto.Album +import org.jellyfin.mobile.ui.screen.library.BaseMediaItem +import org.jellyfin.mobile.ui.utils.GridListFor + +@Composable +fun AlbumList( + albums: SnapshotStateList, + onClick: (Album) -> Unit = {}, +) { + GridListFor( + items = albums, + numberOfColumns = 3, + contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp), + ) { album -> + BaseMediaItem( + modifier = Modifier.fillItemMaxWidth(), + id = album.id, + title = album.name, + subtitle = album.albumArtist, + primaryImageTag = album.primaryImageTag, + onClick = { + onClick(album) + }, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/AlbumScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/AlbumScreen.kt new file mode 100644 index 00000000..2f8e780f --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/AlbumScreen.kt @@ -0,0 +1,113 @@ +package org.jellyfin.mobile.ui.screen.library.music + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.R +import org.jellyfin.mobile.model.dto.Album +import org.jellyfin.mobile.ui.DefaultCornerRounding +import org.jellyfin.mobile.ui.ToolbarUpButton +import org.jellyfin.mobile.ui.inject +import org.jellyfin.mobile.ui.utils.ApiImage +import org.jellyfin.mobile.utils.ImageResolver + +@OptIn(ExperimentalUnsignedTypes::class) +@Composable +fun AlbumScreen(album: Album) { + val onPrimaryColor = MaterialTheme.colors.onPrimary + val backgroundColor = MaterialTheme.colors.background + var titleColor: Color by remember { mutableStateOf(onPrimaryColor) } + var gradientBackgroundColor: Color by remember { mutableStateOf(backgroundColor) } + val imageResolver: ImageResolver by inject() + LaunchedEffect(Unit) { + imageResolver.getImagePalette(album.id, album.primaryImageTag)?.dominantSwatch?.run { + titleColor = Color(titleTextColor) + gradientBackgroundColor = Color(rgb) + } + } + + Surface( + color = MaterialTheme.colors.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .drawBehind { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(gradientBackgroundColor, backgroundColor), + startY = 0f, + endY = size.height, + ) + ) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TopAppBar( + title = { + Text(text = album.name) + }, + navigationIcon = { + ToolbarUpButton(onClick = {}) + }, + backgroundColor = Color.Transparent, + contentColor = titleColor, + elevation = 0.dp, + ) + Box( + modifier = Modifier.padding(top = 56.dp, bottom = 20.dp), + ) { + ApiImage( + id = album.id, + modifier = Modifier + .size(160.dp) + .clip(DefaultCornerRounding), + imageTag = album.primaryImageTag, + fallback = { + Image(painter = painterResource(R.drawable.fallback_image_album_cover), contentDescription = null) + }, + ) + } + Text( + text = album.name, + modifier = Modifier.padding(horizontal = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.h3, + ) + Text( + text = album.albumArtist, + modifier = Modifier.padding(horizontal = 16.dp), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.h5, + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/ArtistList.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/ArtistList.kt new file mode 100644 index 00000000..3dd18906 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/ArtistList.kt @@ -0,0 +1,32 @@ +package org.jellyfin.mobile.ui.screen.library.music + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.model.dto.Artist +import org.jellyfin.mobile.ui.screen.library.BaseMediaItem +import org.jellyfin.mobile.ui.utils.GridListFor + +@Composable +fun ArtistList( + artists: SnapshotStateList, + onClick: (Artist) -> Unit = {}, +) { + GridListFor( + items = artists, + numberOfColumns = 3, + contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp), + ) { artist -> + BaseMediaItem( + modifier = Modifier.fillItemMaxWidth(), + id = artist.id, + title = artist.name, + primaryImageTag = artist.primaryImageTag, + onClick = { + onClick(artist) + }, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/ArtistScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/ArtistScreen.kt new file mode 100644 index 00000000..8f2b642e --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/ArtistScreen.kt @@ -0,0 +1,16 @@ +package org.jellyfin.mobile.ui.screen.library.music + +import androidx.compose.runtime.Composable +import org.jellyfin.mobile.model.dto.Artist +import org.jellyfin.mobile.ui.ScreenScaffold + +@Composable +fun ArtistScreen(artist: Artist) { + ScreenScaffold( + title = artist.name, + canGoBack = true, + hasElevation = false, + ) { + + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/MusicCollectionScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/MusicCollectionScreen.kt new file mode 100644 index 00000000..e63976d9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/MusicCollectionScreen.kt @@ -0,0 +1,56 @@ +package org.jellyfin.mobile.ui.screen.library.music + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import org.jellyfin.mobile.R +import org.jellyfin.mobile.controller.LibraryController +import org.jellyfin.mobile.ui.ScreenScaffold +import org.jellyfin.mobile.ui.get +import org.jellyfin.mobile.ui.screen.library.TabbedContent +import java.util.UUID + +@Composable +fun MusicCollectionScreen( + collectionId: UUID, + onGoBack: () -> Unit, + onClickAlbum: () -> Unit, + onClickArtist: () -> Unit, +) { + val libraryController: LibraryController = get() + val collection = remember(collectionId) { libraryController.getCollection(collectionId) }!! // TODO + + ScreenScaffold( + title = collection.name, + canGoBack = true, + onGoBack = onGoBack, + hasElevation = false, + ) { + val viewModel = remember { MusicViewModel(collection) } + val tabTitles = remember { + listOf(R.string.library_music_tab_albums, R.string.library_music_tab_artists, R.string.library_music_tab_songs) + }.map { id -> + stringResource(id) + } + TabbedContent( + tabTitles = tabTitles, + currentTabState = viewModel.currentTab, + ) { page -> + when (page) { + 0 -> AlbumList( + albums = viewModel.albums, + onClick = { + onClickAlbum() + }, + ) + 1 -> ArtistList( + artists = viewModel.artists, + onClick = { + onClickArtist() + }, + ) + 2 -> SongList(songs = viewModel.songs) + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/MusicViewModel.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/MusicViewModel.kt new file mode 100644 index 00000000..81bbd7ab --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/MusicViewModel.kt @@ -0,0 +1,63 @@ +package org.jellyfin.mobile.ui.screen.library.music + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.jellyfin.mobile.model.BaseItemKind +import org.jellyfin.mobile.model.CollectionType +import org.jellyfin.mobile.model.dto.Album +import org.jellyfin.mobile.model.dto.Artist +import org.jellyfin.mobile.model.dto.Song +import org.jellyfin.mobile.model.dto.UserViewInfo +import org.jellyfin.mobile.model.dto.toSong +import org.jellyfin.mobile.ui.screen.library.LibraryViewModel +import org.jellyfin.sdk.model.api.ItemFields + +class MusicViewModel(viewInfo: UserViewInfo) : LibraryViewModel(viewInfo) { + val currentTab = mutableStateOf(0) + val albums = mutableStateListOf() + val artists = mutableStateListOf() + val songs = mutableStateListOf() + + init { + require(viewInfo.collectionType == CollectionType.Music) { + "Invalid ViewModel for collection type ${viewInfo.collectionType}" + } + + viewModelScope.launch { + /*launch { + apiClient.getItems(buildItemQuery(BaseItemType.MusicAlbum))?.run { + albums += items.map(BaseItemDto::toAlbumInfo) + } + } + launch { + apiClient.getAlbumArtists( + userId = apiClient.currentUserId, + parentId = viewInfo.id, + recursive = true, + sortBy = arrayOf(ItemSortBy.SortName), + startIndex = 0, + limit = 100, + )?.run { + artists += items.map(BaseItemDto::toArtistInfo) + } + }*/ + launch { + val result by itemsApi.getItemsByUserId( + parentId = viewInfo.id, + includeItemTypes = listOf(BaseItemKind.Audio.serialName), + recursive = true, + sortBy = listOf(ItemFields.SORT_NAME.serialName), + startIndex = 0, + limit = 100, + ) + + songs.clear() + result.items?.forEach { item -> + songs += item.toSong() + } + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/SongList.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/SongList.kt new file mode 100644 index 00000000..45d719dc --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/music/SongList.kt @@ -0,0 +1,104 @@ +package org.jellyfin.mobile.ui.screen.library.music + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.R +import org.jellyfin.mobile.model.dto.Song +import org.jellyfin.mobile.ui.DefaultCornerRounding +import org.jellyfin.mobile.ui.utils.ApiImage +import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber + +@Composable +fun SongList(songs: SnapshotStateList) { + LazyColumn( + contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp), + ) { + items(items = songs) { song -> + Song(song = song, onClick = { + Timber.d("Clicked ${song.title}") + }) + } + } +} + +@Stable +@Composable +fun Song( + song: Song, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onClickMenu: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(72.dp) + .clickable(onClick = onClick) + .padding(start = 16.dp, end = 4.dp) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ApiImage( + id = song.album ?: song.id, + modifier = Modifier + .size(56.dp) + .clip(DefaultCornerRounding), + imageType = ImageType.PRIMARY, + imageTag = song.primaryImageTag, + fallback = { + Image( + painter = painterResource(R.drawable.fallback_image_album_cover), + contentDescription = null, + ) + }, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + Text( + text = song.title, + modifier = Modifier.padding(bottom = 4.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = song.artists.joinToString(), + modifier = Modifier.padding(bottom = 2.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.caption, + ) + } + IconButton(onClick = onClickMenu) { + Icon( + painter = painterResource(R.drawable.ic_overflow_white_24dp), + contentDescription = null, + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/musicvideo/MusicVideoCollectionScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/musicvideo/MusicVideoCollectionScreen.kt new file mode 100644 index 00000000..34811c30 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/musicvideo/MusicVideoCollectionScreen.kt @@ -0,0 +1,146 @@ +package org.jellyfin.mobile.ui.screen.library.musicvideo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.R +import org.jellyfin.mobile.model.BaseItemKind +import org.jellyfin.mobile.model.dto.FolderInfo +import org.jellyfin.mobile.model.dto.MusicVideo +import org.jellyfin.mobile.model.dto.toFolderInfo +import org.jellyfin.mobile.model.dto.toMusicVideo +import org.jellyfin.mobile.ui.ScreenScaffold +import org.jellyfin.mobile.ui.get +import org.jellyfin.mobile.ui.screen.library.BaseMediaItem +import org.jellyfin.mobile.ui.utils.GridListFor +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.ItemFields +import java.util.UUID + +@Composable +fun MusicVideoCollectionScreen( + collectionId: UUID, + onGoBack: () -> Unit, + onClickFolder: (UUID) -> Unit, + onClickMusicVideo: (UUID) -> Unit, +) { + val itemsApi: ItemsApi = get() + val collectionItem by produceState(initialValue = null) { + val result by itemsApi.getItemsByUserId(ids = listOf(collectionId)) + + value = result.items?.firstOrNull() + } + val items by produceState>(initialValue = emptyList()) { + val result by itemsApi.getItemsByUserId( + parentId = collectionId, + sortBy = listOf("IsFolder", ItemFields.SORT_NAME.serialName), + startIndex = 0, + limit = 100, + ) + + value = result.items?.mapNotNull { item -> + when (item.type) { + BaseItemKind.Folder.serialName -> item.toFolderInfo() + BaseItemKind.MusicVideo.serialName -> item.toMusicVideo() + else -> null + } + }.orEmpty() + } + + ScreenScaffold( + title = collectionItem?.name.orEmpty(), + canGoBack = true, + onGoBack = onGoBack, + hasElevation = false, + ) { + MusicVideoList( + items = items, + onClickFolder = onClickFolder, + onClickMusicVideo = onClickMusicVideo, + ) + } +} + +@Composable +fun MusicVideoList( + items: List, + onClickFolder: (UUID) -> Unit, + onClickMusicVideo: (UUID) -> Unit, +) { + GridListFor(items = items) { item -> + when (item) { + is FolderInfo -> FolderItem( + folderInfo = item, + modifier = Modifier.fillItemMaxWidth(), + onClick = { onClickFolder(item.id) }, + ) + is MusicVideo -> MusicVideoItem( + musicVideo = item, + modifier = Modifier.fillItemMaxWidth(), + onClick = { onClickMusicVideo(item.id) } + ) + } + } +} + +@Composable +fun FolderItem( + folderInfo: FolderInfo, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + BaseMediaItem( + modifier = modifier, + id = folderInfo.id, + title = folderInfo.name, + primaryImageTag = folderInfo.primaryImageTag, + imageDecorator = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + horizontalAlignment = Alignment.End, + ) { + Icon( + painter = painterResource(R.drawable.ic_folder_white_24dp), + modifier = Modifier + .background(Color.Black.copy(alpha = 0.4f), CircleShape) + .padding(6.dp), + contentDescription = null, + ) + } + }, + onClick = onClick, + ) +} + +@Composable +fun MusicVideoItem( + musicVideo: MusicVideo, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + BaseMediaItem( + modifier = modifier, + id = musicVideo.id, + title = musicVideo.title, + subtitle = musicVideo.album, + primaryImageTag = musicVideo.primaryImageTag, + imageDecorator = { + // TODO: add watched state + }, + onClick = onClick, + ) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screen/library/musicvideo/MusicVideoViewModel.kt b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/musicvideo/MusicVideoViewModel.kt new file mode 100644 index 00000000..7616ce3a --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screen/library/musicvideo/MusicVideoViewModel.kt @@ -0,0 +1,41 @@ +package org.jellyfin.mobile.ui.screen.library.musicvideo + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.jellyfin.mobile.model.BaseItemKind +import org.jellyfin.mobile.model.CollectionType +import org.jellyfin.mobile.model.dto.UserViewInfo +import org.jellyfin.mobile.model.dto.toFolderInfo +import org.jellyfin.mobile.model.dto.toMusicVideo +import org.jellyfin.mobile.ui.screen.library.LibraryViewModel +import org.jellyfin.sdk.model.api.ItemFields + +class MusicVideoViewModel(viewInfo: UserViewInfo) : LibraryViewModel(viewInfo) { + val contents = mutableStateListOf() + + init { + require(viewInfo.collectionType == CollectionType.MusicVideos) { + "Invalid ViewModel for collection type ${viewInfo.collectionType}" + } + + viewModelScope.launch { + launch { + val result by itemsApi.getItemsByUserId( + parentId = viewInfo.id, + sortBy = listOf("IsFolder", ItemFields.SORT_NAME.serialName), + startIndex = 0, + limit = 100, + ) + + contents.clear() + result.items?.forEach { item -> + contents += when (item.type) { + BaseItemKind.Folder.serialName -> item.toFolderInfo() + else -> item.toMusicVideo() + } + } + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/ApiImage.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/ApiImage.kt new file mode 100644 index 00000000..e641c836 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/ApiImage.kt @@ -0,0 +1,118 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.ui.utils + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import com.google.accompanist.coil.rememberCoilPainter +import com.google.accompanist.imageloading.ImageLoadState +import org.jellyfin.mobile.R +import org.jellyfin.mobile.model.dto.UserInfo +import org.jellyfin.mobile.ui.inject +import org.jellyfin.sdk.api.operations.ImageApi +import org.jellyfin.sdk.model.api.ImageType +import java.util.UUID + +@Stable +@Composable +fun ApiImage( + id: UUID, + modifier: Modifier = Modifier, + imageType: ImageType = ImageType.PRIMARY, + imageTag: String? = null, + fallback: @Composable (BoxScope.(ImageLoadState.Error) -> Unit)? = null +) { + val imageApi: ImageApi by inject() + BoxWithConstraints(modifier = modifier) { + val imageUrl = remember(id, constraints, imageType, imageTag) { + imageApi.getItemImageUrl( + itemId = id, + imageType = imageType, + maxWidth = constraints.maxWidth, + maxHeight = constraints.maxHeight, + quality = 90, + tag = imageTag, + ) + } + Image( + modifier = Modifier.size(maxWidth, maxHeight), + painter = rememberCoilPainter( + request = imageUrl, + ), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + /*CoilImage( + data = imageUrl, + modifier = Modifier.size(maxWidth, maxHeight), + contentScale = ContentScale.Crop, + error = fallback, + loading = { LoadingSurface(Modifier.fillMaxSize()) }, + contentDescription = null, + )*/ + } +} + +@Stable +@Composable +fun ApiUserImage( + id: UUID, + modifier: Modifier = Modifier, + imageTag: String? = null +) { + val imageApi: ImageApi by inject() + BoxWithConstraints(modifier = modifier) { + val imageUrl = remember(id, constraints, imageTag) { + imageApi.getUserImageUrl( + userId = id, + imageType = ImageType.PRIMARY, + maxWidth = constraints.maxWidth, + maxHeight = constraints.maxHeight, + quality = 90, + tag = imageTag, + ) + } + Image( + modifier = Modifier.size(maxWidth, maxHeight), + painter = rememberCoilPainter( + request = imageUrl, + previewPlaceholder = R.drawable.fallback_image_person, + ), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + + /*CoilImage( + data = imageUrl, + modifier = Modifier.size(maxWidth, maxHeight), + contentScale = ContentScale.Crop, + error = { + Image( + painter = painterResource(R.drawable.fallback_image_person), + contentDescription = null, + ) + }, + loading = { LoadingSurface(Modifier.fillMaxSize()) }, + contentDescription = null, + )*/ + } +} + +@Composable +inline fun ApiUserImage( + userInfo: UserInfo, + modifier: Modifier = Modifier, +) { + ApiUserImage( + id = userInfo.userId, + modifier = modifier, + imageTag = userInfo.primaryImageTag, + ) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt new file mode 100644 index 00000000..174057ec --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt @@ -0,0 +1,31 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import org.jellyfin.mobile.R + +@Composable +fun AppTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val colors = remember { + darkColors( + primary = Color(ContextCompat.getColor(context, R.color.jellyfin_primary)), + primaryVariant = Color(ContextCompat.getColor(context, R.color.jellyfin_primary_dark)), + secondary = Color(ContextCompat.getColor(context, R.color.jellyfin_accent)), + background = Color(ContextCompat.getColor(context, R.color.theme_background)), + surface = Color(ContextCompat.getColor(context, R.color.theme_surface)), + error = Color(ContextCompat.getColor(context, R.color.error_text_color)), + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color.White, + onSurface = Color.White, + onError = Color.White, + ) + } + MaterialTheme(colors = colors, content = content) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/GridList.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/GridList.kt new file mode 100644 index 00000000..752ab9c9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/GridList.kt @@ -0,0 +1,86 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min + +interface GridScope { + fun Modifier.fillItemMaxWidth(): Modifier +} + +@Composable +fun GridListFor( + items: List, + modifier: Modifier = Modifier, + numberOfColumns: Int = 2, + contentPadding: PaddingValues = PaddingValues(0.dp), + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + itemContent: @Composable GridScope.(T) -> Unit +) { + BoxWithConstraints { + val maxItemWidth = with(LocalDensity.current) { + constraints.maxWidth.toDp() / numberOfColumns + } + val gridScope = GridScopeImpl(maxItemWidth) + + // TODO: Use LazyVerticalGrid in the future + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + horizontalAlignment = horizontalAlignment, + ) { + items(GridList(items, numberOfColumns)) { row -> + Row(Modifier.fillParentMaxWidth()) { + row.forEach { info -> + gridScope.itemContent(info) + } + } + } + } + } +} + +private data class GridScopeImpl(val maxWidth: Dp) : GridScope { + override fun Modifier.fillItemMaxWidth() = width(maxWidth) +} + +@Stable +internal class GridList(private val wrappedList: List, private val numberOfColumns: Int) : List> { + override val size: Int + get() { + val size = wrappedList.size / numberOfColumns + return if (wrappedList.size % numberOfColumns == 0) size else size + 1 + } + + override fun get(index: Int): List { + val startIndex = index * numberOfColumns + return wrappedList.subList(startIndex, min(startIndex + numberOfColumns, max(0, wrappedList.size))) + } + + override fun isEmpty(): Boolean = wrappedList.isEmpty() + + override fun iterator(): Iterator> = object : Iterator> { + override fun hasNext(): Boolean = false + override fun next(): List = throw UnsupportedOperationException() + } + + override fun contains(element: List) = false + override fun containsAll(elements: Collection>): Boolean = false + override fun indexOf(element: List): Int = -1 + override fun lastIndexOf(element: List): Int = -1 + override fun listIterator(): ListIterator> = throw UnsupportedOperationException() + override fun listIterator(index: Int): ListIterator> = throw UnsupportedOperationException() + override fun subList(fromIndex: Int, toIndex: Int): List> = throw UnsupportedOperationException() +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/LoadingSurface.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/LoadingSurface.kt new file mode 100644 index 00000000..19e2d014 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/LoadingSurface.kt @@ -0,0 +1,49 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +//private val surfaceColorProp = ColorPropKey() +private val LoadingBlinkEasing = CubicBezierEasing(0.5f, 0f, 0.8f, 0.4f) + +@Stable +@Composable +fun LoadingSurface( + modifier: Modifier = Modifier, + baseColor: Color = MaterialTheme.colors.onSurface +) { + /*val state = transition( + definition = createTransition(baseColor = baseColor), + initState = 0, + toState = 1, + )*/ + + Surface( + modifier = modifier, + //color = state[surfaceColorProp], + content = {}, + ) +} +/* +@Stable +@Composable +private fun createTransition(baseColor: Color) = remember(baseColor) { + transitionDefinition { + state(0) { this[surfaceColorProp] = baseColor.copy(alpha = 0.12f) } + state(1) { this[surfaceColorProp] = baseColor.copy(alpha = 0f) } + + transition { + surfaceColorProp using repeatable( + AnimationConstants.Infinite, + tween(durationMillis = 600, easing = LoadingBlinkEasing), + RepeatMode.Reverse, + ) + } + } +} +*/ diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/LocalFragmentManager.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/LocalFragmentManager.kt new file mode 100644 index 00000000..6035a52a --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/LocalFragmentManager.kt @@ -0,0 +1,8 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.fragment.app.FragmentManager + +val LocalFragmentManager = staticCompositionLocalOf { + error("Missing LocalFragmentManager") +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/ViewModel.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/ViewModel.kt new file mode 100644 index 00000000..02f6069b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/ViewModel.kt @@ -0,0 +1,36 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +/** + * Taken from + * https://github.com/android/compose-samples/blob/master/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt + */ + +/** + * Returns a [ViewModelProvider.Factory] which will return the result of [create] when it's + * [ViewModelProvider.Factory.create] function is called. + * + * If the created [ViewModel] does not match the requested class, an [IllegalArgumentException] + * exception is thrown. + */ +fun viewModelProviderFactoryOf( + create: () -> VM +): ViewModelProvider.Factory = SimpleFactory(create) + +/** + * This needs to be a named class currently to workaround a compiler issue: b/163807311 + */ +private class SimpleFactory( + private val create: () -> VM +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val vm = create() + if (modelClass.isInstance(vm)) { + @Suppress("UNCHECKED_CAST") + return vm as T + } + throw IllegalArgumentException("Can not create ViewModel for class: $modelClass") + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/ImageResolver.kt b/app/src/main/java/org/jellyfin/mobile/utils/ImageResolver.kt new file mode 100644 index 00000000..6d4ac841 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/ImageResolver.kt @@ -0,0 +1,40 @@ +package org.jellyfin.mobile.utils + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import androidx.palette.graphics.Palette +import coil.ImageLoader +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.operations.ImageApi +import org.jellyfin.sdk.model.api.ImageType +import java.util.* + +class ImageResolver( + private val context: Context, + private val imageApi: ImageApi, + private val imageLoader: ImageLoader, +) { + suspend fun getImagePalette( + id: UUID, + imageTag: String?, + imageType: ImageType = ImageType.PRIMARY + ): Palette? { + val url = imageApi.getItemImageUrl( + id, + imageType = imageType, + maxWidth = 400, + maxHeight = 400, + quality = 90, + tag = imageTag, + ) + val imageResult = imageLoader.execute(ImageRequest.Builder(context).data(url).build()) + val drawable = imageResult.drawable ?: return null + return withContext(Dispatchers.IO) { + val bitmap = drawable.toBitmap().copy(Bitmap.Config.ARGB_8888, true) + Palette.from(bitmap).generate() + } + } +} diff --git a/app/src/main/res/drawable/fallback_image_album_cover.xml b/app/src/main/res/drawable/fallback_image_album_cover.xml new file mode 100644 index 00000000..f24d127e --- /dev/null +++ b/app/src/main/res/drawable/fallback_image_album_cover.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/fallback_image_person.xml b/app/src/main/res/drawable/fallback_image_person.xml new file mode 100644 index 00000000..2e58bad2 --- /dev/null +++ b/app/src/main/res/drawable/fallback_image_person.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml new file mode 100644 index 00000000..d319b918 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_folder_white_24dp.xml b/app/src/main/res/drawable/ic_folder_white_24dp.xml new file mode 100644 index 00000000..55143b9b --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_overflow_white_24dp.xml b/app/src/main/res/drawable/ic_overflow_white_24dp.xml new file mode 100644 index 00000000..7c5f863d --- /dev/null +++ b/app/src/main/res/drawable/ic_overflow_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml new file mode 100644 index 00000000..9ea3df8a --- /dev/null +++ b/app/src/main/res/drawable/splash_background.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 00000000..f33826a9 --- /dev/null +++ b/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index ef96d3b2..62eaa265 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,6 +3,7 @@ #202020 #101010 + #00a4dc @@ -10,6 +11,7 @@ #101010 + #363636 #fafafa #cf6679 #60000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8499192..5b88b339 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,18 @@ Choose server Available servers + Authenticate with Server + Username + Password + Wrong username or password + Login + Profile + Logout + + Albums + Artists + Songs + Please disable battery optimizations for media playback while the screen is off. Allowed Network Types Do you want to allow the download to run over mobile data or roaming networks? Charges from your provider may apply. diff --git a/app/src/main/res/values/strings_donottranslate.xml b/app/src/main/res/values/strings_donottranslate.xml index 0b1f9f8a..192e2018 100644 --- a/app/src/main/res/values/strings_donottranslate.xml +++ b/app/src/main/res/values/strings_donottranslate.xml @@ -1,5 +1,6 @@ Jellyfin + @string/app_name " / " diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2a0ac240..c5acd73e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -4,7 +4,7 @@ @color/jellyfin_primary @color/jellyfin_primary_dark @color/jellyfin_accent - @color/theme_background + @drawable/splash_background