init projects
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.android.lib)
|
||||
}
|
||||
|
||||
extensions.configure<LibraryExtension> {
|
||||
namespace = "ru.shadowsparky.androidstub"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.android.internal.telephony;
|
||||
|
||||
public interface ITelephony {
|
||||
boolean endCall();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,8 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.jvm)
|
||||
alias(libs.plugins.convention.koin)
|
||||
}
|
||||
dependencies {
|
||||
implementation(libs.java.jwt)
|
||||
implementation(libs.logback.classic)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.backend
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@ComponentScan
|
||||
@Configuration
|
||||
class BackendCommon
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.shadowsparky.backend.data
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class EnvFetcher {
|
||||
fun get(key: String, default: String = ""): String {
|
||||
val value = System.getenv(key)
|
||||
return value?.ifEmpty { default } ?: default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.shadowsparky.backend.data
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.backend.domain.JwtInfo
|
||||
import ru.shadowsparky.backend.domain.LOGIN_NAME
|
||||
import ru.shadowsparky.backend.domain.USER_ID_ARG
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Single
|
||||
class JwtPreparer(
|
||||
private val jwtInfo: JwtInfo,
|
||||
private val envFetcher: EnvFetcher
|
||||
) {
|
||||
private val tokenTimeInMinutes = envFetcher.get("JWT_DURATION", "30").toLong()
|
||||
fun prepare(
|
||||
login: String,
|
||||
userId: Long
|
||||
): String {
|
||||
val exp = Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(tokenTimeInMinutes))
|
||||
val token = JWT.create()
|
||||
.withAudience(jwtInfo.audience)
|
||||
.withIssuer(jwtInfo.issuer)
|
||||
.withClaim(LOGIN_NAME, login)
|
||||
.withClaim(USER_ID_ARG, userId)
|
||||
.withSubject(userId.toString())
|
||||
.withExpiresAt(exp)
|
||||
.sign(Algorithm.HMAC512(jwtInfo.secret))
|
||||
return token
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.backend.data
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
@Single
|
||||
class Logger {
|
||||
private val loggers = hashMapOf<String, Logger>()
|
||||
|
||||
fun info(tag: String, message: String) = obtainLogger(tag).info(message)
|
||||
fun debug(tag: String, message: String) = obtainLogger(tag).debug(message)
|
||||
fun error(tag: String, e: Exception) = obtainLogger(tag).error("error occurred", e)
|
||||
|
||||
private fun obtainLogger(tag: String): Logger {
|
||||
loggers[tag]?.let { return it }
|
||||
loggers[tag] = LoggerFactory.getLogger(tag)
|
||||
return loggers[tag]!!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.backend.data
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class StringFetcher {
|
||||
fun get(resource: StringResource): String {
|
||||
return resource.message
|
||||
}
|
||||
}
|
||||
|
||||
enum class StringResource(val message: String) {
|
||||
UNKNOWN_USER("Пользователь не найден или неверный пароль"),
|
||||
INVALID_PASSWORD("Вы ввели неверный пароль"),
|
||||
NO_CHANGES_PASS("Старый и новый пароль должны отличаться"),
|
||||
USER_BLOCKED("Пользователь заблокирован"),
|
||||
REG_BLOCKED("Регистрация недоступна. Доступ по приглашениям."),
|
||||
ALREADY_REGISTERED("Пользователь уже существует"),
|
||||
VIDEO_NOT_FOUND("Видео не найдено.")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.shadowsparky.backend.data
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import com.auth0.jwt.interfaces.DecodedJWT
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.domain.JwtInfo
|
||||
import ru.shadowsparky.backend.domain.LOGIN_NAME
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
|
||||
@Factory
|
||||
class TokenVerifier(
|
||||
private val jwtInfo: JwtInfo,
|
||||
private val loginVerifier: LoginVerifier
|
||||
) {
|
||||
|
||||
val verifier = JWT.require(Algorithm.HMAC512(jwtInfo.secret))
|
||||
.withAudience(jwtInfo.audience)
|
||||
.withIssuer(jwtInfo.issuer)
|
||||
.build()
|
||||
|
||||
suspend fun verify(token: String): DecodedJWT {
|
||||
val jwt = verifier.verify(token)
|
||||
loginVerifier.verify(jwt.getClaim(LOGIN_NAME).asString())
|
||||
return jwt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.shadowsparky.backend.domain
|
||||
|
||||
data class JwtInfo(
|
||||
val secret: String,
|
||||
val issuer: String,
|
||||
val audience: String,
|
||||
val realm: String
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.backend.domain
|
||||
|
||||
interface LoginVerifier {
|
||||
suspend fun verify(login: String)
|
||||
}
|
||||
|
||||
const val LOGIN_NAME = "login"
|
||||
const val USER_ID_ARG = "user_id"
|
||||
|
||||
const val INVALID_TOKEN_MSG = "Был передан неправильный токен"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package ru.shadowsparky.backend.domain
|
||||
|
||||
class VerifyTokenException(override val message: String?) : IllegalStateException()
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.kmp)
|
||||
alias(libs.plugins.convention.android.lib)
|
||||
alias(libs.plugins.convention.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.multiplatform.settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
android { namespace = "ru.shadowsparky" }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.Clip
|
||||
import ru.shadowsparky.domain.ClipboardManager
|
||||
|
||||
@Factory
|
||||
class AndroidClipboardManager(private val context: Context) : ClipboardManager {
|
||||
private val service by lazy {
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
}
|
||||
|
||||
override suspend fun setText(clip: Clip) {
|
||||
service.setPrimaryClip(ClipData.newPlainText(clip.title, clip.text))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.domain.Log
|
||||
|
||||
private typealias Impl = android.util.Log
|
||||
|
||||
@Single
|
||||
class AndroidLog : Log {
|
||||
override fun d(tag: String, msg: String?, e: Throwable?) {
|
||||
Impl.d(tag, msg, e)
|
||||
}
|
||||
|
||||
override fun e(tag: String, e: Throwable?, msg: String?) {
|
||||
Impl.e(tag, msg, e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.SharedPreferencesSettings
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class AndroidSettingsFactory(private val app: Application) : SettingsFactory {
|
||||
override fun create(name: String): Settings {
|
||||
val prefs = app.getSharedPreferences(name, Context.MODE_PRIVATE)
|
||||
return SharedPreferencesSettings(prefs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.shadowsparky
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.Koin
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.koin.core.component.KoinComponent
|
||||
import ru.shadowsparky.data.SettingsFactory
|
||||
import ru.shadowsparky.data.SettingsKeyValueStorage
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.domain.KeyValueStorage
|
||||
|
||||
val koin: Koin get() = object : KoinComponent {}.getKoin()
|
||||
|
||||
@Module
|
||||
@ComponentScan
|
||||
@Configuration
|
||||
class CommonModule {
|
||||
|
||||
@Single
|
||||
fun provideJson(): Json {
|
||||
return Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
|
||||
@Single
|
||||
fun provideSettings(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
settingsFactory: SettingsFactory
|
||||
): KeyValueStorage {
|
||||
return SettingsKeyValueStorage(dispatcherProvider, "app_settings", settingsFactory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koin.core.annotation.Singleton
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
|
||||
@Singleton
|
||||
class DispatcherProviderImpl : DispatcherProvider {
|
||||
override val io: CoroutineDispatcher = Dispatchers.Default
|
||||
override val main: CoroutineDispatcher = Dispatchers.Main
|
||||
override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined
|
||||
override val default: CoroutineDispatcher = Dispatchers.Default
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import com.russhwolf.settings.ObservableSettings
|
||||
import com.russhwolf.settings.Settings
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.domain.KeyValueStorage
|
||||
|
||||
private typealias NotifyCallback = ((String?) -> Unit)?
|
||||
|
||||
class SettingsKeyValueStorage(
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val name: String,
|
||||
private val settingsFactory: SettingsFactory
|
||||
) : KeyValueStorage {
|
||||
private val impl: BaseImpl by lazy {
|
||||
val settings = settingsFactory.create(name)
|
||||
if (settings is ObservableSettings) {
|
||||
DefaultImpl(settings)
|
||||
} else {
|
||||
FallbackImpl(settings)
|
||||
}
|
||||
}
|
||||
|
||||
override fun get(key: String): Flow<String?> {
|
||||
return impl.get(key)
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String) = withContext(dispatcherProvider.io) {
|
||||
impl.remove(key)
|
||||
}
|
||||
|
||||
override suspend fun put(key: String, value: String) = withContext(dispatcherProvider.io) {
|
||||
impl.put(key, value)
|
||||
}
|
||||
|
||||
private abstract class BaseImpl(private val settings: Settings) {
|
||||
|
||||
open fun remove(key: String) {
|
||||
settings.remove(key)
|
||||
}
|
||||
|
||||
open fun put(key: String, value: String) {
|
||||
settings.putString(key, value)
|
||||
}
|
||||
|
||||
abstract fun get(key: String): Flow<String?>
|
||||
}
|
||||
|
||||
private class DefaultImpl(private val settings: ObservableSettings) : BaseImpl(settings) {
|
||||
override fun get(key: String): Flow<String?> {
|
||||
return callbackFlow {
|
||||
val listener: (String?) -> Unit = {
|
||||
launch { send(it) }
|
||||
}
|
||||
send(settings.getStringOrNull(key))
|
||||
val disposable = settings.addStringOrNullListener(key, listener)
|
||||
awaitClose { disposable.deactivate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FallbackImpl(private val settings: Settings) : BaseImpl(settings) {
|
||||
private val listenerMap = hashMapOf<String, MutableList<NotifyCallback>>()
|
||||
|
||||
override fun get(key: String): Flow<String?> = callbackFlow {
|
||||
val listener: (String?) -> Unit = {
|
||||
launch { send(it) }
|
||||
}
|
||||
send(settings.getStringOrNull(key))
|
||||
put(key, listener)
|
||||
awaitClose {
|
||||
remove(key, listener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun put(key: String, listener: NotifyCallback) {
|
||||
val list = listenerMap[key] ?: mutableListOf()
|
||||
list.add(listener)
|
||||
listenerMap[key] = list
|
||||
}
|
||||
|
||||
private fun remove(key: String, listener: NotifyCallback) {
|
||||
val list = listenerMap[key] ?: return
|
||||
val index = list.indexOf(listener)
|
||||
if (index >= 0) {
|
||||
list.removeAt(index)
|
||||
}
|
||||
listenerMap[key] = list
|
||||
}
|
||||
|
||||
override fun remove(key: String) {
|
||||
super.remove(key)
|
||||
listenerMap[key]?.forEach {
|
||||
it?.invoke(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun put(key: String, value: String) {
|
||||
super.put(key, value)
|
||||
listenerMap[key]?.forEach {
|
||||
it?.invoke(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
interface SettingsFactory {
|
||||
fun create(name: String): Settings
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.domain
|
||||
|
||||
interface AppConfig {
|
||||
fun isDebug(): Boolean
|
||||
fun isBackend(): Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.domain
|
||||
|
||||
interface ClipboardManager {
|
||||
suspend fun setText(clip: Clip)
|
||||
}
|
||||
|
||||
data class Clip(val title: String, val text: String)
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.domain
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
interface DispatcherProvider {
|
||||
val io: CoroutineDispatcher
|
||||
val main: CoroutineDispatcher
|
||||
val unconfined: CoroutineDispatcher
|
||||
val default: CoroutineDispatcher
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.shadowsparky.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
interface KeyValueStorage {
|
||||
fun get(key: String): Flow<String?>
|
||||
fun getBoolean(key: String): Flow<Boolean?> = get(key).map { it?.toBoolean() }
|
||||
fun getInt(key: String): Flow<Int?> = get(key).map { it?.toInt() }
|
||||
|
||||
suspend fun remove(key: String)
|
||||
suspend fun put(key: String, value: String)
|
||||
suspend fun putBoolean(key: String, value: Boolean) = put(key, value.toString())
|
||||
suspend fun putInt(key: String, value: Int) = put(key, value.toString())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.domain
|
||||
|
||||
interface Log {
|
||||
fun d(tag: String, msg: String? = null, e: Throwable? = null)
|
||||
fun e(tag: String, e: Throwable? = null, msg: String? = null)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.domain.Log
|
||||
|
||||
@Single
|
||||
class DesktopLog : Log {
|
||||
|
||||
override fun d(tag: String, msg: String?, e: Throwable?) {
|
||||
println("$tag $msg")
|
||||
e?.printStackTrace()
|
||||
}
|
||||
|
||||
override fun e(tag: String, e: Throwable?, msg: String?) {
|
||||
println("$tag $msg")
|
||||
e?.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import com.russhwolf.settings.PreferencesSettings
|
||||
import com.russhwolf.settings.Settings
|
||||
import org.koin.core.annotation.Single
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
@Single
|
||||
class DesktopSettingsFactory : SettingsFactory {
|
||||
override fun create(name: String): Settings {
|
||||
return PreferencesSettings(Preferences.userRoot().node(name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.domain.Log
|
||||
|
||||
@Single
|
||||
class WasmLog : Log {
|
||||
override fun d(tag: String, msg: String?, e: Throwable?) {
|
||||
println("$tag $msg")
|
||||
e?.printStackTrace()
|
||||
}
|
||||
|
||||
override fun e(tag: String, e: Throwable?, msg: String?) {
|
||||
println("$tag $msg")
|
||||
e?.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.data
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.StorageSettings
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class WasmSettingsFactory : SettingsFactory {
|
||||
override fun create(name: String): Settings {
|
||||
return StorageSettings()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,28 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.kmp)
|
||||
alias(libs.plugins.convention.android.lib)
|
||||
alias(libs.plugins.convention.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
alias(libs.plugins.convention.ktor.client)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(project(":libs:base"))
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.kotlinx.rpc.krpc.client)
|
||||
// Server API
|
||||
implementation(libs.kotlinx.rpc.krpc.server)
|
||||
// Serialization module. Also, protobuf and cbor are provided
|
||||
implementation(libs.kotlinx.rpc.krpc.serialization.json)
|
||||
|
||||
// Transport implementation for Ktor
|
||||
implementation(libs.kotlinx.rpc.krpc.ktor.client)
|
||||
implementation(libs.kotlinx.rpc.krpc.ktor.server)
|
||||
}
|
||||
}
|
||||
}
|
||||
android { namespace = "ru.shadowsparky.http" }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) {
|
||||
|
||||
actual fun create(): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
settings.setup(this)
|
||||
engine {
|
||||
config {
|
||||
retryOnConnectionFailure(true)
|
||||
connectTimeout(15, TimeUnit.SECONDS)
|
||||
readTimeout(15, TimeUnit.SECONDS)
|
||||
writeTimeout(15, TimeUnit.SECONDS)
|
||||
}
|
||||
preconfigured = OkHttpClient.Builder()
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package ru.shadowsparky.http
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import io.ktor.client.plugins.auth.providers.RefreshTokensParams
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.http.data.HttpClientFactory
|
||||
import ru.shadowsparky.http.data.HttpClientSettings
|
||||
import ru.shadowsparky.http.data.RefreshTokenRunner
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.http.domain.LogoutAction
|
||||
import ru.shadowsparky.http.domain.RefreshTokenAction
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.koin
|
||||
|
||||
@Module
|
||||
@Configuration
|
||||
@ComponentScan
|
||||
class HttpModule {
|
||||
|
||||
@Single
|
||||
fun createHttpClient(settings: HttpClientSettings): HttpClient {
|
||||
return HttpClientFactory(settings).create()
|
||||
}
|
||||
|
||||
@Single
|
||||
fun createBearerAuthProvider(
|
||||
refreshTokenRunner: RefreshTokenRunner
|
||||
): BearerAuthProvider {
|
||||
val tokenStorage = koin.getOrNull<TokenStorage>()
|
||||
return BearerAuthProvider(
|
||||
refreshTokens = {
|
||||
refreshTokenRunner.runOrJoin {
|
||||
refreshTokenInternal(tokenStorage)
|
||||
}
|
||||
},
|
||||
loadTokens = {
|
||||
tokenStorage?.token?.firstOrNull()?.toKtor()
|
||||
},
|
||||
realm = tokenStorage?.realm
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun RefreshTokensParams.refreshTokenInternal(tokenStorage: TokenStorage?): BearerTokens? {
|
||||
val token = tokenStorage?.token?.firstOrNull()?.refresh ?: return null
|
||||
return try {
|
||||
val newToken = koin.getOrNull<RefreshTokenAction>()
|
||||
?.refresh(this.client, token)
|
||||
?: return null
|
||||
tokenStorage.updateToken(newToken)
|
||||
newToken.toKtor()
|
||||
} catch (e: Throwable) {
|
||||
if (e is HttpException) {
|
||||
koin.getOrNull<LogoutAction>()?.logout()
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TokenInfo.toKtor(): BearerTokens {
|
||||
return BearerTokens(token, refresh)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
expect class HttpClientFactory(settings: HttpClientSettings) {
|
||||
fun create(): HttpClient
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.HttpResponseValidator
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||
import io.ktor.client.plugins.auth.Auth
|
||||
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.AppConfig
|
||||
import ru.shadowsparky.domain.Log
|
||||
import ru.shadowsparky.http.domain.ClientKtorExtension
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.http.domain.UserAgentProvider
|
||||
import ru.shadowsparky.koin
|
||||
|
||||
const val USE_HTTP_CUSTOM_CERTS = "use_http_custom_certs"
|
||||
|
||||
private const val MESSAGE_LONG = "message"
|
||||
private const val MESSAGE = "msg"
|
||||
|
||||
@Factory
|
||||
class HttpClientSettings(
|
||||
private val json: Json,
|
||||
private val userAgentProvider: UserAgentProvider?,
|
||||
private val bearerAuthProvider: BearerAuthProvider,
|
||||
private val appenderList: List<ClientKtorExtension>,
|
||||
private val log: Log
|
||||
) {
|
||||
|
||||
fun setup(
|
||||
clientConfig: HttpClientConfig<*>
|
||||
) = with(clientConfig) {
|
||||
install(Auth) {
|
||||
providers.add(bearerAuthProvider)
|
||||
}
|
||||
expectSuccess = false
|
||||
|
||||
HttpResponseValidator {
|
||||
validateResponse {
|
||||
val code = it.status.value
|
||||
if (code in 400..599) {
|
||||
val body = it.body<JsonElement>()
|
||||
if (body is JsonObject) {
|
||||
val msg = when {
|
||||
body.containsKey(MESSAGE_LONG) -> body[MESSAGE_LONG]?.jsonPrimitive?.content
|
||||
body.containsKey(MESSAGE) -> body[MESSAGE]?.jsonPrimitive?.content
|
||||
else -> body.toString()
|
||||
}
|
||||
throw HttpException(code, msg)
|
||||
} else {
|
||||
throw HttpException(code, body.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
val isDebug = koin.getOrNull<AppConfig>()?.isDebug() ?: true
|
||||
if (isDebug) {
|
||||
this.level = LogLevel.ALL
|
||||
this.logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
log.d("Http", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(json)
|
||||
}
|
||||
|
||||
install(WebSockets) {
|
||||
// как часто слать ping кадры
|
||||
pingIntervalMillis = 30_000 // или: pingInterval = 20.seconds (в новых API)
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
header(HttpHeaders.Accept, ContentType.Application.Json)
|
||||
header(HttpHeaders.ContentType, ContentType.Application.Json)
|
||||
userAgentProvider?.provide()?.let {
|
||||
header(HttpHeaders.UserAgent, it)
|
||||
}
|
||||
}
|
||||
|
||||
// Таймауты на уровне Ktor
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||
connectTimeoutMillis = 20_000
|
||||
}
|
||||
|
||||
appenderList.forEach { it.append(this) }
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
|
||||
|
||||
@Factory
|
||||
class RefreshTokenRunner(private val dispatcherProvider: DispatcherProvider) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io)
|
||||
private val lock = Mutex()
|
||||
private var inFlight: Deferred<BearerTokens?>? = null
|
||||
|
||||
suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? {
|
||||
val deferred = lock.withLock {
|
||||
inFlight?.takeIf { it.isActive } ?: startNew(block).also { inFlight = it }
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
private fun startNew(block: suspend () -> BearerTokens?): Deferred<BearerTokens?> {
|
||||
val d = scope.async(dispatcherProvider.io) { block() }
|
||||
d.invokeOnCompletion {
|
||||
scope.launch {
|
||||
lock.withLock {
|
||||
if (inFlight === d) inFlight = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import io.ktor.client.HttpClientConfig
|
||||
|
||||
interface ClientKtorExtension {
|
||||
fun append(clientConfig: HttpClientConfig<*>)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
|
||||
open class HttpException(val httpCode: Int, override val message: String?) : RuntimeException()
|
||||
|
||||
class BadRequestException(msg: String) : HttpException(HttpStatusCode.BadRequest.value, msg)
|
||||
open class NotFoundException(msg: String) : HttpException(HttpStatusCode.NotFound.value, msg)
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
interface LogoutAction {
|
||||
suspend fun logout()
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
interface RefreshTokenAction {
|
||||
suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TokenInfo(
|
||||
val token: String,
|
||||
val refresh: String? = null
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
interface TokenStorage {
|
||||
val realm: String
|
||||
val token: Flow<TokenInfo?>
|
||||
|
||||
suspend fun updateToken(newToken: TokenInfo)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
suspend fun TokenStorage.hasAccount(): Boolean {
|
||||
return token.firstOrNull()?.token != null
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.http.domain
|
||||
|
||||
interface UserAgentProvider {
|
||||
fun provide(): String?
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) {
|
||||
actual fun create(): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
settings.setup(this)
|
||||
engine {
|
||||
config {
|
||||
retryOnConnectionFailure(true)
|
||||
connectTimeout(15, TimeUnit.SECONDS)
|
||||
readTimeout(15, TimeUnit.SECONDS)
|
||||
writeTimeout(15, TimeUnit.SECONDS)
|
||||
}
|
||||
preconfigured = OkHttpClient.Builder().pingInterval(30, TimeUnit.SECONDS).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.http.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.js.Js
|
||||
|
||||
actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) {
|
||||
actual fun create(): HttpClient {
|
||||
return HttpClient(Js) {
|
||||
settings.setup(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,19 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.kmp)
|
||||
alias(libs.plugins.convention.android.lib)
|
||||
alias(libs.plugins.convention.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
alias(libs.plugins.convention.compose)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
}
|
||||
}
|
||||
}
|
||||
android { namespace = "ru.shadowsparky.ui" }
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun isTv(): Boolean {
|
||||
val context = LocalContext.current
|
||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.shadowsparky.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun lightDynamicScheme(): ColorScheme? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
dynamicLightColorScheme(LocalContext.current)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun darkDynamicScheme(): ColorScheme? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
dynamicDarkColorScheme(LocalContext.current)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z" />
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M480,640Q555,640 607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640ZM480,568Q435,568 403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568ZM480,760Q334,760 214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,680Q593,680 687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M644,532L586,474Q595,427 559,386Q523,345 466,354L408,296Q425,288 442.5,284Q460,280 480,280Q555,280 607.5,332.5Q660,385 660,460Q660,480 656,497.5Q652,515 644,532ZM772,658L714,602Q752,573 781.5,538.5Q811,504 832,460Q782,359 688.5,299.5Q595,240 480,240Q451,240 423,244Q395,248 368,256L306,194Q347,177 390,168.5Q433,160 480,160Q631,160 749,243.5Q867,327 920,460Q897,519 859.5,569.5Q822,620 772,658ZM792,904L624,738Q589,749 553.5,754.5Q518,760 480,760Q329,760 211,676.5Q93,593 40,460Q61,407 93,361.5Q125,316 166,280L56,168L112,112L848,848L792,904ZM222,336Q193,362 169,393Q145,424 128,460Q178,561 271.5,620.5Q365,680 480,680Q500,680 519,677.5Q538,675 558,672L522,634Q511,637 501,638.5Q491,640 480,640Q405,640 352.5,587.5Q300,535 300,460Q300,449 301.5,439Q303,429 306,418L222,336ZM541,429L541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429ZM390,504Q390,504 390,504Q390,504 390,504L390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,166 @@
|
||||
package ru.shadowsparky.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.shadowsparky.ui.nav.Route
|
||||
import ru.shadowsparky.ui.theme.ComponentDefaults
|
||||
import ru.shadowsparky.ui.theme.getColorScheme
|
||||
|
||||
const val USE_EXPERIMENTAL_RAIL = false
|
||||
|
||||
@Immutable
|
||||
data class NavigationEntry<out T : Route>(
|
||||
val route: T,
|
||||
val title: String,
|
||||
val painter: Painter
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BaseScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.pinnedScrollBehavior(),
|
||||
snackbarState: SnackbarHostState? = null,
|
||||
colorScheme: ColorScheme = getColorScheme(),
|
||||
appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit,
|
||||
content: @Composable (padding: PaddingValues) -> Unit,
|
||||
) {
|
||||
BaseScreen<Route>(
|
||||
modifier,
|
||||
scrollBehavior,
|
||||
snackbarState,
|
||||
colorScheme,
|
||||
appBar = appBar,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun <T : Route> BaseScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.pinnedScrollBehavior(),
|
||||
snackbarState: SnackbarHostState? = null,
|
||||
colorScheme: ColorScheme = getColorScheme(),
|
||||
currentRoute: T? = null,
|
||||
navigationEntries: List<NavigationEntry<T>>? = null,
|
||||
onSelectNavigation: ((T) -> Unit)? = null,
|
||||
appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit,
|
||||
content: @Composable (padding: PaddingValues) -> Unit,
|
||||
) {
|
||||
val windowType = currentWindowType()
|
||||
MaterialExpressiveTheme(colorScheme = colorScheme) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) {
|
||||
BottomBar(navigationEntries, currentRoute, onSelectNavigation)
|
||||
}
|
||||
},
|
||||
topBar = {
|
||||
appBar(scrollBehavior)
|
||||
}, content = {
|
||||
if (windowType.isExpanded && navigationEntries?.isNotEmpty() == true && USE_EXPERIMENTAL_RAIL) {
|
||||
ExpandedContent(
|
||||
it,
|
||||
navigationEntries,
|
||||
currentRoute,
|
||||
onSelectNavigation,
|
||||
content
|
||||
)
|
||||
} else {
|
||||
content(it)
|
||||
}
|
||||
}, modifier = if (scrollBehavior != null) {
|
||||
modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
} else {
|
||||
modifier
|
||||
}, snackbarHost = {
|
||||
snackbarState?.let { SnackbarHost(it) }
|
||||
}, contentWindowInsets = WindowInsets.safeDrawing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T : Route> BottomBar(
|
||||
navigationEntries: List<NavigationEntry<T>>?,
|
||||
currentRoute: T?,
|
||||
onSelectNavigation: ((T) -> Unit)?
|
||||
) {
|
||||
val blurState = LocalBlurState.current
|
||||
navigationEntries?.let {
|
||||
NavigationBar(
|
||||
containerColor = ComponentDefaults.navBarContentColor(),
|
||||
modifier = Modifier.applyBlurEffect(blurState)
|
||||
) {
|
||||
it.forEach { entry ->
|
||||
NavigationBarItem(
|
||||
currentRoute == entry.route,
|
||||
onClick = { onSelectNavigation?.invoke(entry.route) },
|
||||
icon = {
|
||||
Icon(entry.painter, contentDescription = entry.title)
|
||||
},
|
||||
label = { Text(entry.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun <T : Route> ExpandedContent(
|
||||
values: PaddingValues,
|
||||
navigationEntries: List<NavigationEntry<T>>,
|
||||
currentRoute: T?,
|
||||
onSelectNavigation: ((T) -> Unit)?,
|
||||
content: @Composable ((PaddingValues) -> Unit)
|
||||
) {
|
||||
Row(Modifier.fillMaxSize().padding(values)) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
NavigationRail {
|
||||
navigationEntries.forEach { entry ->
|
||||
NavigationRailItem(
|
||||
selected = currentRoute == entry.route,
|
||||
onClick = { onSelectNavigation?.invoke(entry.route) },
|
||||
icon = {
|
||||
Icon(entry.painter, contentDescription = entry.title)
|
||||
},
|
||||
label = { Text(entry.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Box(Modifier.weight(1f)) {
|
||||
content(PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.shadowsparky.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import ru.shadowsparky.ui.components.isTv
|
||||
|
||||
private const val BLUR_ENABLED = true
|
||||
|
||||
val LocalBlurState = compositionLocalOf<BlurState?> { null }
|
||||
|
||||
@Composable
|
||||
internal fun isBlurEnabled(): Boolean {
|
||||
return BLUR_ENABLED && !isTv()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBlurState(): BlurState? {
|
||||
if (!isBlurEnabled()) return null
|
||||
val hazeState = rememberHazeState()
|
||||
return remember(hazeState) { BlurState(hazeState) }
|
||||
}
|
||||
|
||||
fun Modifier.applyBlurSource(state: BlurState?): Modifier {
|
||||
state ?: return this
|
||||
return hazeSource(state.hazeState)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun Modifier.applyBlurEffect(state: BlurState?): Modifier {
|
||||
state ?: return this
|
||||
return hazeEffect(state.hazeState, HazeMaterials.thick())
|
||||
}
|
||||
|
||||
@Stable
|
||||
class BlurState(internal val hazeState: HazeState)
|
||||
@@ -0,0 +1,30 @@
|
||||
package ru.shadowsparky.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
|
||||
@Composable
|
||||
private fun currentWindowWidthDp(): Int {
|
||||
val info = LocalWindowInfo.current
|
||||
val density = LocalDensity.current
|
||||
return with(density) { info.containerSize.width.toDp().value.toInt() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun currentWindowType(): WindowType {
|
||||
return widthToSizeClass(currentWindowWidthDp())
|
||||
}
|
||||
|
||||
sealed interface WindowType {
|
||||
object Compact : WindowType
|
||||
object Expanded : WindowType
|
||||
}
|
||||
|
||||
val WindowType.isExpanded: Boolean get() = this is WindowType.Expanded
|
||||
val WindowType.isCompact: Boolean get() = this is WindowType.Compact
|
||||
|
||||
private fun widthToSizeClass(widthDp: Int): WindowType = when {
|
||||
widthDp >= 840 -> WindowType.Expanded
|
||||
else -> WindowType.Compact
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androiddev.libs.ui.generated.resources.Res
|
||||
import androiddev.libs.ui.generated.resources.back
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@Composable
|
||||
fun BackButton(doBack: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = { doBack() },
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.iconButtonVibrantColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(0.1f))
|
||||
) { Icon(painterResource(Res.drawable.back), contentDescription = null) }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val ROUNDED_MAX = 24
|
||||
private const val ROUNDED_MIN = 6
|
||||
|
||||
@DslMarker private annotation class ExpressiveListDsl
|
||||
|
||||
internal data class ExpressiveNode(
|
||||
val key: Any?,
|
||||
val contentType: Any?,
|
||||
val content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@ExpressiveListDsl
|
||||
class ExpressiveSectionScope {
|
||||
internal val nodes = mutableListOf<ExpressiveNode>()
|
||||
|
||||
fun item(key: Any? = null, contentType: Any? = null, content: @Composable () -> Unit) {
|
||||
nodes += ExpressiveNode(key, contentType, content)
|
||||
}
|
||||
|
||||
fun <T> items(
|
||||
items: List<T>,
|
||||
key: ((T) -> Any)? = null,
|
||||
contentType: ((T) -> Any)? = null,
|
||||
itemContent: @Composable (T) -> Unit
|
||||
) {
|
||||
items.forEach { t ->
|
||||
nodes += ExpressiveNode(
|
||||
key = key?.invoke(t),
|
||||
contentType = contentType?.invoke(t),
|
||||
content = { itemContent(t) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExpressiveListDsl
|
||||
class ExpressiveLazyListScope {
|
||||
internal val sections = mutableListOf<ExpressiveSectionScope>()
|
||||
|
||||
fun section(content: ExpressiveSectionScope.() -> Unit) {
|
||||
val s = ExpressiveSectionScope().apply(content)
|
||||
if (s.nodes.isNotEmpty()) sections += s
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveLazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
horizontalPadding: Dp = 8.dp,
|
||||
verticalSpacing: Dp = 1.dp,
|
||||
sectionSpacing: Dp = 8.dp,
|
||||
roundedMax: Dp = ROUNDED_MAX.dp,
|
||||
roundedMin: Dp = ROUNDED_MIN.dp,
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
content: ExpressiveLazyListScope.() -> Unit
|
||||
) {
|
||||
val scope = ExpressiveLazyListScope().apply(content)
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement
|
||||
) {
|
||||
scope.sections.forEachIndexed { sectionIndex, section ->
|
||||
|
||||
if (sectionIndex != 0 && sectionSpacing > 0.dp) {
|
||||
item(key = "sectionSpacer_$sectionIndex") {
|
||||
Spacer(Modifier.height(sectionSpacing))
|
||||
}
|
||||
}
|
||||
|
||||
section.nodes.forEachIndexed { index, node ->
|
||||
val topRadius = if (index == 0) roundedMax else roundedMin
|
||||
val bottomRadius = if (index == section.nodes.lastIndex) roundedMax else roundedMin
|
||||
|
||||
item(key = node.key, contentType = node.contentType) {
|
||||
ExpressiveCard(
|
||||
topRadius,
|
||||
bottomRadius,
|
||||
horizontalPadding,
|
||||
verticalSpacing,
|
||||
content = node.content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveCard(
|
||||
topRadius: Dp = ROUNDED_MAX.dp,
|
||||
bottomRadius: Dp = ROUNDED_MAX.dp,
|
||||
horizontalPadding: Dp = 8.dp,
|
||||
verticalSpacing: Dp = 1.dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
),
|
||||
shape = RoundedCornerShape(
|
||||
topStart = topRadius,
|
||||
topEnd = topRadius,
|
||||
bottomStart = bottomRadius,
|
||||
bottomEnd = bottomRadius
|
||||
),
|
||||
modifier = modifier.padding(horizontal = horizontalPadding, vertical = verticalSpacing)
|
||||
) { content() }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.OverscrollEffect
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberOverscrollEffect
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
expect fun isTv(): Boolean
|
||||
|
||||
/**
|
||||
* LazyColumn — обходной путь для бага LazyColumn + contentPadding на Android TV.
|
||||
*
|
||||
* Ниже — визуальные схемы, которые показывают,
|
||||
* КАК ломается фокус и КАК работает обход.
|
||||
*
|
||||
* ------------------------------------------------------------
|
||||
* 1) Как работает обычный LazyColumn с contentPadding
|
||||
* (и почему это ломает DPAD-фокус на Android TV)
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* ┌───────────────────────────────────────────────┐
|
||||
* │ LazyColumn(contentPadding = paddingValues) │
|
||||
* │ │
|
||||
* │ ↑ ВЕРХНИЙ PADDING (невидимая область) │
|
||||
* │ │ (создаётся внутри списка) │
|
||||
* │ │ │
|
||||
* │ ┌─▼─────────────────────────────────────────┐ │
|
||||
* │ │ Item 1 │ │
|
||||
* │ └───────────────────────────────────────────┘ │
|
||||
* │ ┌────────────────────────────────────────────┐│
|
||||
* │ │ Item 2 (кнопка, карточка, что угодно) ││
|
||||
* │ └────────────────────────────────────────────┘│
|
||||
* │ │
|
||||
* │ ↓ НИЖНИЙ PADDING (ещё одна невидимая зона) │
|
||||
* │ │
|
||||
* └───────────────────────────────────────────────┘
|
||||
*
|
||||
* ПРОБЛЕМА:
|
||||
* Для DPAD-навигации эти зоны — не просто отступы.
|
||||
* Это ПУСТЫЕ ОБЛАСТИ, которые участвуют в алгоритме Focus Search.
|
||||
*
|
||||
* Алгоритм думает так:
|
||||
*
|
||||
* "Нажали вниз → ниже есть пространство? Да. Есть фокусируемый элемент? Нет."
|
||||
*
|
||||
* В итоге фокус:
|
||||
* - не двигается вниз,
|
||||
* - может даже перестать попадать в список,
|
||||
* - прокрутка вниз не инициируется.
|
||||
*
|
||||
*
|
||||
* ------------------------------------------------------------
|
||||
* 2) Что делает InsetLazyColumn на Android TV
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* Вместо внутреннего contentPadding мы делаем:
|
||||
*
|
||||
* modifier.padding(contentPadding)
|
||||
*
|
||||
* А внутрь LazyColumn передаём:
|
||||
*
|
||||
* contentPadding = PaddingValues(0.dp)
|
||||
*
|
||||
*
|
||||
* ┌───────────────────────────────────────────────┐
|
||||
* │ Modifier.padding(...) ← PADDING СНАРУЖИ │
|
||||
* │ │
|
||||
* │ ┌───────────────────────────────────────────┐ │
|
||||
* │ │ LazyColumn(contentPadding = 0.dp) │ │
|
||||
* │ │ │ │
|
||||
* │ │ Item 1 │ │
|
||||
* │ │ Item 2 │ │
|
||||
* │ │ Item 3 │ │
|
||||
* │ │ │ │
|
||||
* │ └───────────────────────────────────────────┘ │
|
||||
* │ │
|
||||
* └───────────────────────────────────────────────┘
|
||||
*
|
||||
* РЕЗУЛЬТАТ:
|
||||
*
|
||||
* ✔ Визуально всё выглядит так же (insets соблюдены).
|
||||
* ✔ Внутри LazyColumn нет искусственных «дыр».
|
||||
* ✔ DPAD-навигация работает корректно.
|
||||
* ✔ Список правильно скроллится вниз при фокусе.
|
||||
* ✔ Алгоритм Focus Search видит только настоящие элементы.
|
||||
*
|
||||
*
|
||||
* ------------------------------------------------------------
|
||||
* 3) Итоговая мысль
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* Весь баг сводится к тому, что LazyColumn на TV неправильно
|
||||
* интерпретирует внутренний contentPadding как "дополнительные"
|
||||
* focus-space зоны, и DPAD начинает вести себя некорректно.
|
||||
*
|
||||
* Перенос padding наружу через Modifier полностью устраняет проблему,
|
||||
* сохраняя визуальное поведение.
|
||||
*/
|
||||
@Composable
|
||||
fun LazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(),
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val isTv = isTv()
|
||||
androidx.compose.foundation.lazy.LazyColumn(
|
||||
if (isTv) modifier.padding(contentPadding) else modifier,
|
||||
state,
|
||||
if (isTv) PaddingValues(0.dp) else contentPadding,
|
||||
reverseLayout,
|
||||
verticalArrangement,
|
||||
horizontalAlignment,
|
||||
flingBehavior,
|
||||
userScrollEnabled,
|
||||
overscrollEffect,
|
||||
content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.OverscrollEffect
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.rememberOverscrollEffect
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LazyVerticalGrid(
|
||||
columns: GridCells,
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyGridState = rememberLazyGridState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(),
|
||||
content: LazyGridScope.() -> Unit,
|
||||
) {
|
||||
val isTv = isTv()
|
||||
androidx.compose.foundation.lazy.grid.LazyVerticalGrid(
|
||||
columns,
|
||||
if (isTv) modifier.padding(contentPadding) else modifier,
|
||||
state,
|
||||
if (isTv) PaddingValues(0.dp) else contentPadding,
|
||||
reverseLayout,
|
||||
verticalArrangement,
|
||||
horizontalArrangement,
|
||||
flingBehavior,
|
||||
userScrollEnabled,
|
||||
overscrollEffect,
|
||||
content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> ListPreference(
|
||||
title: String,
|
||||
items: Map<T, String>,
|
||||
selectedItem: T,
|
||||
onItemSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val entries = remember(items) { items.entries.toList() }
|
||||
val selectedLabel = remember(items, selectedItem) { items[selectedItem].orEmpty() }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { if (enabled) expanded = !expanded },
|
||||
modifier = modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {}, // readOnly
|
||||
readOnly = true,
|
||||
label = { Text(title) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled)
|
||||
.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded && enabled,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
entries.forEach { (value, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
if (value != selectedItem) onItemSelected(value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun Loading(modifier: Modifier) {
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
LoadingIndicator(Modifier.size(96.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androiddev.libs.ui.generated.resources.Res
|
||||
import androiddev.libs.ui.generated.resources.visibility
|
||||
import androiddev.libs.ui.generated.resources.visibility_off
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.vectorResource
|
||||
|
||||
@Composable
|
||||
fun PasswordTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
labelRes: StringResource,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onDone: (() -> Unit)? = null
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = if (onDone == null) ImeAction.Next else ImeAction.Done,
|
||||
keyboardType = KeyboardType.Password
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { onDone?.invoke() }),
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
label = { Text(stringResource(labelRes)) },
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
trailingIcon = {
|
||||
val image = if (passwordVisible) {
|
||||
Res.drawable.visibility
|
||||
} else {
|
||||
Res.drawable.visibility_off
|
||||
}
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(imageVector = vectorResource(image), contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PreferenceCategory(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = modifier
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
top = 24.dp,
|
||||
end = 16.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
@Composable
|
||||
fun SwitchPreference(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
summary: String? = null,
|
||||
state: Boolean,
|
||||
onStateChanged: (Boolean) -> Unit
|
||||
) {
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(Color.Transparent),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(
|
||||
value = state,
|
||||
role = Role.Switch,
|
||||
onValueChange = onStateChanged
|
||||
),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
supportingContent = summary?.let {
|
||||
{
|
||||
Text(
|
||||
it,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state,
|
||||
onCheckedChange = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
@Composable
|
||||
fun TextPreference(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
summary: String? = null
|
||||
) {
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(Color.Transparent),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
supportingContent = summary?.let {
|
||||
{
|
||||
Text(
|
||||
it,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ru.shadowsparky.ui.nav
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.router.stack.ChildStack
|
||||
import com.arkivanov.decompose.router.stack.StackNavigation
|
||||
import com.arkivanov.decompose.router.stack.bringToFront
|
||||
import com.arkivanov.decompose.router.stack.childStack
|
||||
import com.arkivanov.decompose.router.stack.pop
|
||||
import com.arkivanov.decompose.router.stack.pushNew
|
||||
import com.arkivanov.decompose.router.stack.replaceAll
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.essenty.backhandler.BackHandlerOwner
|
||||
import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.KSerializer
|
||||
import org.koin.core.Koin
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
abstract class BaseRootComponent<R : Route>(
|
||||
private val componentContext: ComponentContext,
|
||||
) : ComponentContext by componentContext, BackHandlerOwner {
|
||||
protected abstract val initStack: List<R>
|
||||
protected abstract val serializer: KSerializer<R>
|
||||
protected abstract val koin: Koin
|
||||
|
||||
private val scope = coroutineScope()
|
||||
|
||||
protected val nav = StackNavigation<R>()
|
||||
|
||||
protected val childStack by lazy {
|
||||
childStack(
|
||||
source = nav,
|
||||
serializer = serializer,
|
||||
initialStack = { initStack },
|
||||
childFactory = ::newChild
|
||||
)
|
||||
}
|
||||
|
||||
private val _onFinish = MutableStateFlow<Unit?>(null)
|
||||
val onFinish = _onFinish.filterNotNull()
|
||||
|
||||
fun navReplace(vararg route: R) {
|
||||
nav.replaceAll(*route)
|
||||
}
|
||||
|
||||
fun nav(route: R, bringToFront: Boolean = false) {
|
||||
if (bringToFront) {
|
||||
nav.bringToFront(route)
|
||||
} else {
|
||||
nav.pushNew(route)
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
nav.pop { popped ->
|
||||
if (!popped) finish()
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
scope.launch { _onFinish.emit(Unit) }
|
||||
}
|
||||
|
||||
val valueChildStack: Value<ChildStack<*, Child>> by lazy { childStack }
|
||||
|
||||
protected open fun newChild(route: Route, ctx: ComponentContext): Child {
|
||||
val factory = route.provideFactory<Child, Route>(koin)
|
||||
return factory.create(route, ctx, this as BaseRootComponent<Route>)
|
||||
}
|
||||
|
||||
private fun <T : Child, R : Route> Route.provideFactory(koin: Koin): ComponentFactory<T, R, Route> {
|
||||
return koin.get<ComponentFactory<T, R, Route>>(named(routeId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.shadowsparky.ui.nav
|
||||
|
||||
interface Child
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.ui.nav
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
|
||||
interface ComponentFactory<T : Child, R : Route, BaseRoute : Route> {
|
||||
fun create(route: R, child: ComponentContext, root: BaseRootComponent<BaseRoute>): T
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.shadowsparky.ui.nav
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.arkivanov.decompose.ExperimentalDecomposeApi
|
||||
import com.arkivanov.decompose.extensions.compose.stack.Children
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.androidPredictiveBackAnimatableV2
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
|
||||
|
||||
@OptIn(ExperimentalDecomposeApi::class)
|
||||
@Composable
|
||||
fun Nav(
|
||||
rootComponent: BaseRootComponent<*>,
|
||||
process: @Composable (child: Child, route: Route) -> Unit
|
||||
) {
|
||||
Children(
|
||||
stack = rootComponent.valueChildStack,
|
||||
animation = predictiveBackAnimation(
|
||||
backHandler = rootComponent.backHandler,
|
||||
fallbackAnimation = stackAnimation(fade()),
|
||||
onBack = rootComponent::onBack,
|
||||
selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) },
|
||||
),
|
||||
) {
|
||||
process(it.instance, it.configuration as Route)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.shadowsparky.ui.nav
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
abstract class Route {
|
||||
abstract val routeId: String
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package ru.shadowsparky.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val primaryLight = Color(0xFF465D91)
|
||||
val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
val primaryContainerLight = Color(0xFFD9E2FF)
|
||||
val onPrimaryContainerLight = Color(0xFF2D4678)
|
||||
val secondaryLight = Color(0xFF575E71)
|
||||
val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLight = Color(0xFFDBE2F9)
|
||||
val onSecondaryContainerLight = Color(0xFF3F4759)
|
||||
val tertiaryLight = Color(0xFF725573)
|
||||
val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLight = Color(0xFFFCD7FB)
|
||||
val onTertiaryContainerLight = Color(0xFF593E5A)
|
||||
val errorLight = Color(0xFFBA1A1A)
|
||||
val onErrorLight = Color(0xFFFFFFFF)
|
||||
val errorContainerLight = Color(0xFFFFDAD6)
|
||||
val onErrorContainerLight = Color(0xFF93000A)
|
||||
val backgroundLight = Color(0xFFFAF9FF)
|
||||
val onBackgroundLight = Color(0xFF1A1B20)
|
||||
val surfaceLight = Color(0xFFFAF9FF)
|
||||
val onSurfaceLight = Color(0xFF1A1B20)
|
||||
val surfaceVariantLight = Color(0xFFE1E2EC)
|
||||
val onSurfaceVariantLight = Color(0xFF44464F)
|
||||
val outlineLight = Color(0xFF757780)
|
||||
val outlineVariantLight = Color(0xFFC5C6D0)
|
||||
val scrimLight = Color(0xFF000000)
|
||||
val inverseSurfaceLight = Color(0xFF2F3036)
|
||||
val inverseOnSurfaceLight = Color(0xFFF1F0F7)
|
||||
val inversePrimaryLight = Color(0xFFAFC6FF)
|
||||
val surfaceDimLight = Color(0xFFDAD9E0)
|
||||
val surfaceBrightLight = Color(0xFFFAF9FF)
|
||||
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLight = Color(0xFFF3F3FA)
|
||||
val surfaceContainerLight = Color(0xFFEEEDF4)
|
||||
val surfaceContainerHighLight = Color(0xFFE8E7EF)
|
||||
val surfaceContainerHighestLight = Color(0xFFE2E2E9)
|
||||
|
||||
val primaryLightMediumContrast = Color(0xFF1A3566)
|
||||
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightMediumContrast = Color(0xFF556CA1)
|
||||
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightMediumContrast = Color(0xFF2F3648)
|
||||
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightMediumContrast = Color(0xFF666D80)
|
||||
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightMediumContrast = Color(0xFF472D49)
|
||||
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightMediumContrast = Color(0xFF816382)
|
||||
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorLightMediumContrast = Color(0xFF740006)
|
||||
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
|
||||
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightMediumContrast = Color(0xFFFAF9FF)
|
||||
val onBackgroundLightMediumContrast = Color(0xFF1A1B20)
|
||||
val surfaceLightMediumContrast = Color(0xFFFAF9FF)
|
||||
val onSurfaceLightMediumContrast = Color(0xFF0F1116)
|
||||
val surfaceVariantLightMediumContrast = Color(0xFFE1E2EC)
|
||||
val onSurfaceVariantLightMediumContrast = Color(0xFF34363E)
|
||||
val outlineLightMediumContrast = Color(0xFF50525A)
|
||||
val outlineVariantLightMediumContrast = Color(0xFF6B6D75)
|
||||
val scrimLightMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightMediumContrast = Color(0xFF2F3036)
|
||||
val inverseOnSurfaceLightMediumContrast = Color(0xFFF1F0F7)
|
||||
val inversePrimaryLightMediumContrast = Color(0xFFAFC6FF)
|
||||
val surfaceDimLightMediumContrast = Color(0xFFC6C6CD)
|
||||
val surfaceBrightLightMediumContrast = Color(0xFFFAF9FF)
|
||||
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightMediumContrast = Color(0xFFF3F3FA)
|
||||
val surfaceContainerLightMediumContrast = Color(0xFFE8E7EF)
|
||||
val surfaceContainerHighLightMediumContrast = Color(0xFFDCDCE3)
|
||||
val surfaceContainerHighestLightMediumContrast = Color(0xFFD1D1D8)
|
||||
|
||||
val primaryLightHighContrast = Color(0xFF0D2A5B)
|
||||
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightHighContrast = Color(0xFF30487B)
|
||||
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightHighContrast = Color(0xFF252C3D)
|
||||
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightHighContrast = Color(0xFF42495B)
|
||||
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightHighContrast = Color(0xFF3C233E)
|
||||
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightHighContrast = Color(0xFF5B405D)
|
||||
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorLightHighContrast = Color(0xFF600004)
|
||||
val onErrorLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightHighContrast = Color(0xFF98000A)
|
||||
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightHighContrast = Color(0xFFFAF9FF)
|
||||
val onBackgroundLightHighContrast = Color(0xFF1A1B20)
|
||||
val surfaceLightHighContrast = Color(0xFFFAF9FF)
|
||||
val onSurfaceLightHighContrast = Color(0xFF000000)
|
||||
val surfaceVariantLightHighContrast = Color(0xFFE1E2EC)
|
||||
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
|
||||
val outlineLightHighContrast = Color(0xFF292C33)
|
||||
val outlineVariantLightHighContrast = Color(0xFF474951)
|
||||
val scrimLightHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightHighContrast = Color(0xFF2F3036)
|
||||
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
|
||||
val inversePrimaryLightHighContrast = Color(0xFFAFC6FF)
|
||||
val surfaceDimLightHighContrast = Color(0xFFB8B8BF)
|
||||
val surfaceBrightLightHighContrast = Color(0xFFFAF9FF)
|
||||
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightHighContrast = Color(0xFFF1F0F7)
|
||||
val surfaceContainerLightHighContrast = Color(0xFFE2E2E9)
|
||||
val surfaceContainerHighLightHighContrast = Color(0xFFD4D4DB)
|
||||
val surfaceContainerHighestLightHighContrast = Color(0xFFC6C6CD)
|
||||
|
||||
val primaryDark = Color(0xFFAFC6FF)
|
||||
val onPrimaryDark = Color(0xFF132F60)
|
||||
val primaryContainerDark = Color(0xFF2D4678)
|
||||
val onPrimaryContainerDark = Color(0xFFD9E2FF)
|
||||
val secondaryDark = Color(0xFFBFC6DC)
|
||||
val onSecondaryDark = Color(0xFF293042)
|
||||
val secondaryContainerDark = Color(0xFF3F4759)
|
||||
val onSecondaryContainerDark = Color(0xFFDBE2F9)
|
||||
val tertiaryDark = Color(0xFFDFBBDE)
|
||||
val onTertiaryDark = Color(0xFF412743)
|
||||
val tertiaryContainerDark = Color(0xFF593E5A)
|
||||
val onTertiaryContainerDark = Color(0xFFFCD7FB)
|
||||
val errorDark = Color(0xFFFFB4AB)
|
||||
val onErrorDark = Color(0xFF690005)
|
||||
val errorContainerDark = Color(0xFF93000A)
|
||||
val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
val backgroundDark = Color(0xFF121318)
|
||||
val onBackgroundDark = Color(0xFFE2E2E9)
|
||||
val surfaceDark = Color(0xFF121318)
|
||||
val onSurfaceDark = Color(0xFFE2E2E9)
|
||||
val surfaceVariantDark = Color(0xFF44464F)
|
||||
val onSurfaceVariantDark = Color(0xFFC5C6D0)
|
||||
val outlineDark = Color(0xFF8F9099)
|
||||
val outlineVariantDark = Color(0xFF44464F)
|
||||
val scrimDark = Color(0xFF000000)
|
||||
val inverseSurfaceDark = Color(0xFFE2E2E9)
|
||||
val inverseOnSurfaceDark = Color(0xFF2F3036)
|
||||
val inversePrimaryDark = Color(0xFF465D91)
|
||||
val surfaceDimDark = Color(0xFF121318)
|
||||
val surfaceBrightDark = Color(0xFF38393E)
|
||||
val surfaceContainerLowestDark = Color(0xFF0C0E13)
|
||||
val surfaceContainerLowDark = Color(0xFF1A1B20)
|
||||
val surfaceContainerDark = Color(0xFF1E1F25)
|
||||
val surfaceContainerHighDark = Color(0xFF282A2F)
|
||||
val surfaceContainerHighestDark = Color(0xFF33353A)
|
||||
|
||||
val primaryDarkMediumContrast = Color(0xFFD0DCFF)
|
||||
val onPrimaryDarkMediumContrast = Color(0xFF042355)
|
||||
val primaryContainerDarkMediumContrast = Color(0xFF7990C7)
|
||||
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val secondaryDarkMediumContrast = Color(0xFFD5DCF3)
|
||||
val onSecondaryDarkMediumContrast = Color(0xFF1E2536)
|
||||
val secondaryContainerDarkMediumContrast = Color(0xFF8990A5)
|
||||
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val tertiaryDarkMediumContrast = Color(0xFFF6D1F4)
|
||||
val onTertiaryDarkMediumContrast = Color(0xFF351D37)
|
||||
val tertiaryContainerDarkMediumContrast = Color(0xFFA786A7)
|
||||
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val errorDarkMediumContrast = Color(0xFFFFD2CC)
|
||||
val onErrorDarkMediumContrast = Color(0xFF540003)
|
||||
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
|
||||
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val backgroundDarkMediumContrast = Color(0xFF121318)
|
||||
val onBackgroundDarkMediumContrast = Color(0xFFE2E2E9)
|
||||
val surfaceDarkMediumContrast = Color(0xFF121318)
|
||||
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkMediumContrast = Color(0xFF44464F)
|
||||
val onSurfaceVariantDarkMediumContrast = Color(0xFFDBDCE6)
|
||||
val outlineDarkMediumContrast = Color(0xFFB0B1BB)
|
||||
val outlineVariantDarkMediumContrast = Color(0xFF8E9099)
|
||||
val scrimDarkMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkMediumContrast = Color(0xFFE2E2E9)
|
||||
val inverseOnSurfaceDarkMediumContrast = Color(0xFF282A2F)
|
||||
val inversePrimaryDarkMediumContrast = Color(0xFF2E4779)
|
||||
val surfaceDimDarkMediumContrast = Color(0xFF121318)
|
||||
val surfaceBrightDarkMediumContrast = Color(0xFF43444A)
|
||||
val surfaceContainerLowestDarkMediumContrast = Color(0xFF06070C)
|
||||
val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1D23)
|
||||
val surfaceContainerDarkMediumContrast = Color(0xFF26282D)
|
||||
val surfaceContainerHighDarkMediumContrast = Color(0xFF313238)
|
||||
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C3D43)
|
||||
|
||||
val primaryDarkHighContrast = Color(0xFFECEFFF)
|
||||
val onPrimaryDarkHighContrast = Color(0xFF000000)
|
||||
val primaryContainerDarkHighContrast = Color(0xFFAAC2FD)
|
||||
val onPrimaryContainerDarkHighContrast = Color(0xFF000A23)
|
||||
val secondaryDarkHighContrast = Color(0xFFECEFFF)
|
||||
val onSecondaryDarkHighContrast = Color(0xFF000000)
|
||||
val secondaryContainerDarkHighContrast = Color(0xFFBBC2D8)
|
||||
val onSecondaryContainerDarkHighContrast = Color(0xFF040B1B)
|
||||
val tertiaryDarkHighContrast = Color(0xFFFFEAFB)
|
||||
val onTertiaryDarkHighContrast = Color(0xFF000000)
|
||||
val tertiaryContainerDarkHighContrast = Color(0xFFDBB8DA)
|
||||
val onTertiaryContainerDarkHighContrast = Color(0xFF18031B)
|
||||
val errorDarkHighContrast = Color(0xFFFFECE9)
|
||||
val onErrorDarkHighContrast = Color(0xFF000000)
|
||||
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
|
||||
val onErrorContainerDarkHighContrast = Color(0xFF220001)
|
||||
val backgroundDarkHighContrast = Color(0xFF121318)
|
||||
val onBackgroundDarkHighContrast = Color(0xFFE2E2E9)
|
||||
val surfaceDarkHighContrast = Color(0xFF121318)
|
||||
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkHighContrast = Color(0xFF44464F)
|
||||
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val outlineDarkHighContrast = Color(0xFFEFEFF9)
|
||||
val outlineVariantDarkHighContrast = Color(0xFFC1C2CC)
|
||||
val scrimDarkHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkHighContrast = Color(0xFFE2E2E9)
|
||||
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
|
||||
val inversePrimaryDarkHighContrast = Color(0xFF2E4779)
|
||||
val surfaceDimDarkHighContrast = Color(0xFF121318)
|
||||
val surfaceBrightDarkHighContrast = Color(0xFF4F5056)
|
||||
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
|
||||
val surfaceContainerLowDarkHighContrast = Color(0xFF1E1F25)
|
||||
val surfaceContainerDarkHighContrast = Color(0xFF2F3036)
|
||||
val surfaceContainerHighDarkHighContrast = Color(0xFF3A3B41)
|
||||
val surfaceContainerHighestDarkHighContrast = Color(0xFF45464C)
|
||||
@@ -0,0 +1,47 @@
|
||||
package ru.shadowsparky.ui.theme
|
||||
|
||||
import androidx.compose.material3.AppBarWithSearchColors
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.NavigationBarDefaults
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import ru.shadowsparky.ui.isBlurEnabled
|
||||
|
||||
object ComponentDefaults {
|
||||
|
||||
@Composable
|
||||
fun topAppBarColors(): TopAppBarColors {
|
||||
return if (isBlurEnabled()) {
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
Color.Transparent,
|
||||
Color.Transparent
|
||||
)
|
||||
} else {
|
||||
TopAppBarDefaults.topAppBarColors()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun navBarContentColor(): Color {
|
||||
return if (isBlurEnabled()) {
|
||||
Color.Transparent
|
||||
} else {
|
||||
NavigationBarDefaults.containerColor
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun appBarWithSearchDefaults(): AppBarWithSearchColors {
|
||||
return if (isBlurEnabled()) {
|
||||
SearchBarDefaults.appBarWithSearchColors(
|
||||
appBarContainerColor = Color.Transparent
|
||||
)
|
||||
} else {
|
||||
SearchBarDefaults.appBarWithSearchColors()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package ru.shadowsparky.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
val lightScheme = lightColorScheme(
|
||||
primary = primaryLight,
|
||||
onPrimary = onPrimaryLight,
|
||||
primaryContainer = primaryContainerLight,
|
||||
onPrimaryContainer = onPrimaryContainerLight,
|
||||
secondary = secondaryLight,
|
||||
onSecondary = onSecondaryLight,
|
||||
secondaryContainer = secondaryContainerLight,
|
||||
onSecondaryContainer = onSecondaryContainerLight,
|
||||
tertiary = tertiaryLight,
|
||||
onTertiary = onTertiaryLight,
|
||||
tertiaryContainer = tertiaryContainerLight,
|
||||
onTertiaryContainer = onTertiaryContainerLight,
|
||||
error = errorLight,
|
||||
onError = onErrorLight,
|
||||
errorContainer = errorContainerLight,
|
||||
onErrorContainer = onErrorContainerLight,
|
||||
background = backgroundLight,
|
||||
onBackground = onBackgroundLight,
|
||||
surface = surfaceLight,
|
||||
onSurface = onSurfaceLight,
|
||||
surfaceVariant = surfaceVariantLight,
|
||||
onSurfaceVariant = onSurfaceVariantLight,
|
||||
outline = outlineLight,
|
||||
outlineVariant = outlineVariantLight,
|
||||
scrim = scrimLight,
|
||||
inverseSurface = inverseSurfaceLight,
|
||||
inverseOnSurface = inverseOnSurfaceLight,
|
||||
inversePrimary = inversePrimaryLight,
|
||||
surfaceDim = surfaceDimLight,
|
||||
surfaceBright = surfaceBrightLight,
|
||||
surfaceContainerLowest = surfaceContainerLowestLight,
|
||||
surfaceContainerLow = surfaceContainerLowLight,
|
||||
surfaceContainer = surfaceContainerLight,
|
||||
surfaceContainerHigh = surfaceContainerHighLight,
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
val darkScheme = darkColorScheme(
|
||||
primary = primaryDark,
|
||||
onPrimary = onPrimaryDark,
|
||||
primaryContainer = primaryContainerDark,
|
||||
onPrimaryContainer = onPrimaryContainerDark,
|
||||
secondary = secondaryDark,
|
||||
onSecondary = onSecondaryDark,
|
||||
secondaryContainer = secondaryContainerDark,
|
||||
onSecondaryContainer = onSecondaryContainerDark,
|
||||
tertiary = tertiaryDark,
|
||||
onTertiary = onTertiaryDark,
|
||||
tertiaryContainer = tertiaryContainerDark,
|
||||
onTertiaryContainer = onTertiaryContainerDark,
|
||||
error = errorDark,
|
||||
onError = onErrorDark,
|
||||
errorContainer = errorContainerDark,
|
||||
onErrorContainer = onErrorContainerDark,
|
||||
background = backgroundDark,
|
||||
onBackground = onBackgroundDark,
|
||||
surface = surfaceDark,
|
||||
onSurface = onSurfaceDark,
|
||||
surfaceVariant = surfaceVariantDark,
|
||||
onSurfaceVariant = onSurfaceVariantDark,
|
||||
outline = outlineDark,
|
||||
outlineVariant = outlineVariantDark,
|
||||
scrim = scrimDark,
|
||||
inverseSurface = inverseSurfaceDark,
|
||||
inverseOnSurface = inverseOnSurfaceDark,
|
||||
inversePrimary = inversePrimaryDark,
|
||||
surfaceDim = surfaceDimDark,
|
||||
surfaceBright = surfaceBrightDark,
|
||||
surfaceContainerLowest = surfaceContainerLowestDark,
|
||||
surfaceContainerLow = surfaceContainerLowDark,
|
||||
surfaceContainer = surfaceContainerDark,
|
||||
surfaceContainerHigh = surfaceContainerHighDark,
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = getColorScheme(),
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getColorScheme(): ColorScheme {
|
||||
return when {
|
||||
isSystemInDarkTheme() -> darkDynamicScheme() ?: darkScheme
|
||||
else -> lightDynamicScheme() ?: lightScheme
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun lightDynamicScheme(): ColorScheme?
|
||||
|
||||
@Composable
|
||||
expect fun darkDynamicScheme(): ColorScheme?
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val AppTypography = Typography()
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun isTv(): Boolean = false
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun lightDynamicScheme(): ColorScheme? = null
|
||||
|
||||
@Composable
|
||||
actual fun darkDynamicScheme(): ColorScheme? = null
|
||||
@@ -0,0 +1,80 @@
|
||||
package ru.shadowsparky.ui
|
||||
|
||||
import com.arkivanov.essenty.lifecycle.Lifecycle
|
||||
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
|
||||
import com.arkivanov.essenty.lifecycle.create
|
||||
import com.arkivanov.essenty.lifecycle.pause
|
||||
import com.arkivanov.essenty.lifecycle.resume
|
||||
import com.arkivanov.essenty.lifecycle.start
|
||||
import com.arkivanov.essenty.lifecycle.stop
|
||||
import kotlinx.browser.document
|
||||
import org.w3c.dom.events.Event
|
||||
|
||||
@JsFun(
|
||||
"""
|
||||
function hideElementById(id) {
|
||||
var el = document.getElementById(id);
|
||||
el.style.display = 'none';
|
||||
}
|
||||
"""
|
||||
)
|
||||
external fun hideElementById(id: String)
|
||||
|
||||
@JsFun("(function(){ return document.visibilityState; })")
|
||||
external fun pageVisibilityState(): String
|
||||
|
||||
fun hookPageVisibility(lifecycle: LifecycleRegistry) {
|
||||
|
||||
fun bootstrap() {
|
||||
lifecycle.resume()
|
||||
}
|
||||
|
||||
fun onVisibilityChange() {
|
||||
when (pageVisibilityState()) {
|
||||
"visible" -> lifecycle.resume()
|
||||
else -> lifecycle.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun onPageHideShow(hidden: Boolean) {
|
||||
if (hidden) {
|
||||
// уходим глубже: STARTED → CREATED
|
||||
when (lifecycle.state) {
|
||||
Lifecycle.State.RESUMED -> {
|
||||
lifecycle.pause(); lifecycle.stop()
|
||||
}
|
||||
|
||||
Lifecycle.State.STARTED -> {
|
||||
lifecycle.stop()
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
// pageshow: поднимемся обратно
|
||||
when (lifecycle.state) {
|
||||
Lifecycle.State.INITIALIZED -> {
|
||||
lifecycle.create(); lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume()
|
||||
}
|
||||
|
||||
Lifecycle.State.CREATED -> {
|
||||
lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume()
|
||||
}
|
||||
|
||||
Lifecycle.State.STARTED -> {
|
||||
if (pageVisibilityState() == "visible") lifecycle.resume()
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
||||
val visHandler: (Event) -> Unit = { onVisibilityChange() }
|
||||
document.addEventListener("visibilitychange", visHandler)
|
||||
|
||||
document.addEventListener("pagehide", { onPageHideShow(true) })
|
||||
document.addEventListener("pageshow", { onPageHideShow(false) })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun isTv(): Boolean = false
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun lightDynamicScheme(): ColorScheme? = null
|
||||
|
||||
@Composable
|
||||
actual fun darkDynamicScheme(): ColorScheme? = null
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.kmp)
|
||||
alias(libs.plugins.convention.android.lib)
|
||||
alias(libs.plugins.convention.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
alias(libs.plugins.convention.compose)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(project(":libs:ui"))
|
||||
}
|
||||
}
|
||||
val androidMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
}
|
||||
}
|
||||
}
|
||||
android { namespace = "ru.shadowsparky.videoplayer" }
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
import android.content.Context
|
||||
import androiddev.libs.video_player.generated.resources.Res
|
||||
import androiddev.libs.video_player.generated.resources.player_audio_track
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class AndroidPlayerController(context: Context) : PlayerController {
|
||||
private val exoPlayer by lazy { ExoPlayer.Builder(context).build() }
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
override val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<PlayerEvent>(Channel.BUFFERED)
|
||||
override val events: Flow<PlayerEvent> = _events.receiveAsFlow()
|
||||
override val audioTracks = MutableStateFlow<List<AudioTrackInfo>>(emptyList())
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var progressJob: Job? = null
|
||||
private var isListenerBound = false
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
updateState()
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
_events.trySend(PlayerEvent.Ended)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
updateState()
|
||||
if (isPlaying) startProgressWatcher() else stopProgressWatcher()
|
||||
}
|
||||
|
||||
private var trackJob: Job? = null
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
updateState()
|
||||
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||
trackJob?.cancel()
|
||||
trackJob = scope.launch { updateAudioTracks() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_events.trySend(PlayerEvent.Error(error.message ?: "Unknown ExoPlayer error"))
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
_state.update {
|
||||
it.copy(videoWidth = videoSize.width, videoHeight = videoSize.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init { bindListenerIfNeeded(false) }
|
||||
|
||||
private fun bindListenerIfNeeded(allowFail: Boolean) {
|
||||
try {
|
||||
if (isListenerBound) return
|
||||
exoPlayer.addListener(listener)
|
||||
isListenerBound = true
|
||||
} catch (t: Throwable) {
|
||||
if (allowFail) throw t
|
||||
}
|
||||
}
|
||||
|
||||
private var isTrackInitialized = false
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun updateAudioTracks() {
|
||||
val player = exoPlayer
|
||||
val tracks = player.currentTracks
|
||||
|
||||
val list = tracks.groups.mapIndexedNotNull { groupIndex, group ->
|
||||
if (group.type != C.TRACK_TYPE_AUDIO) return@mapIndexedNotNull null
|
||||
|
||||
val trackGroup = group.mediaTrackGroup
|
||||
if (trackGroup.length == 0) return@mapIndexedNotNull null
|
||||
|
||||
(0 until trackGroup.length).map { trackIndex ->
|
||||
val desc = getString(Res.string.player_audio_track, groupIndex)
|
||||
AudioTrackInfo(
|
||||
id = "$groupIndex:$trackIndex",
|
||||
groupIndex = groupIndex,
|
||||
trackIndex = trackIndex,
|
||||
description = desc.trim(),
|
||||
isSelected = group.isTrackSelected(trackIndex)
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
audioTracks.value = list
|
||||
|
||||
if (!isTrackInitialized && list.isNotEmpty()) {
|
||||
isTrackInitialized = true
|
||||
list.firstOrNull()?.let { selectAudioTrack(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override suspend fun selectAudioTrack(id: String) = withContext(Dispatchers.Main) {
|
||||
val parts = id.split(":")
|
||||
if (parts.size != 2) return@withContext
|
||||
val groupIndex = parts.first().toIntOrNull() ?: return@withContext
|
||||
val trackIndex = parts.last().toIntOrNull() ?: return@withContext
|
||||
|
||||
val tracks = exoPlayer.currentTracks
|
||||
val group = tracks.groups.getOrNull(groupIndex) ?: return@withContext
|
||||
if (group.type != C.TRACK_TYPE_AUDIO) return@withContext
|
||||
|
||||
val trackGroup = group.mediaTrackGroup
|
||||
if (trackIndex !in 0 until trackGroup.length) return@withContext
|
||||
|
||||
val override = TrackSelectionOverride(trackGroup, listOf(trackIndex))
|
||||
|
||||
exoPlayer.trackSelectionParameters =
|
||||
exoPlayer.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
.addOverride(override)
|
||||
.build()
|
||||
|
||||
updateAudioTracks()
|
||||
}
|
||||
|
||||
override suspend fun load(url: String, startPositionMs: Long): Unit =
|
||||
withContext(Dispatchers.Main) {
|
||||
bindListenerIfNeeded(true)
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
if (startPositionMs > 0) exoPlayer.seekTo(startPositionMs)
|
||||
_state.update { it.copy(videoUrl = url) }
|
||||
_events.trySend(PlayerEvent.Playing)
|
||||
}
|
||||
|
||||
override suspend fun play() = withContext(Dispatchers.Main) {
|
||||
exoPlayer.play()
|
||||
}
|
||||
|
||||
override suspend fun pause() = withContext(Dispatchers.Main) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
|
||||
override suspend fun seekTo(positionMs: Long) = withContext(Dispatchers.Main) {
|
||||
exoPlayer.seekTo(positionMs)
|
||||
updateState()
|
||||
}
|
||||
|
||||
override suspend fun release() = withContext(Dispatchers.Main) {
|
||||
stopProgressWatcher()
|
||||
exoPlayer.removeListener(listener)
|
||||
exoPlayer.release()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun updateState() {
|
||||
_state.update { s ->
|
||||
s.copy(
|
||||
isPlaying = exoPlayer.isPlaying,
|
||||
isBuffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
|
||||
isEnded = exoPlayer.playbackState == Player.STATE_ENDED,
|
||||
durationMs = exoPlayer.duration.coerceAtLeast(0),
|
||||
currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0),
|
||||
bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0),
|
||||
volume = exoPlayer.volume
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startProgressWatcher() {
|
||||
if (progressJob?.isActive == true) return
|
||||
progressJob = scope.launch {
|
||||
while (isActive) {
|
||||
updateState()
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopProgressWatcher() {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
internal fun getPlayerInstance(): ExoPlayer = exoPlayer
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class AndroidExternalPlayer(private val app: Application) : ExternalPlayer {
|
||||
|
||||
override fun play(url: String) {
|
||||
if (!launchInternal(url, true)) {
|
||||
if (!launchInternal(url, false)) {
|
||||
error("Unable to launch video")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchInternal(uri: String, withType: Boolean): Boolean {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (withType) {
|
||||
intent.setDataAndType(uri.toUri(), "video/*")
|
||||
} else {
|
||||
intent.data = uri.toUri()
|
||||
}
|
||||
try {
|
||||
app.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package ru.shadowsparky.videoplayer.ui
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.util.Consumer
|
||||
|
||||
internal fun Context.updatePipAutoEnter(playerView: View, enabled: Boolean) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
|
||||
val w = if (playerView.width > 0) playerView.width else 16
|
||||
val h = if (playerView.height > 0) playerView.height else 9
|
||||
|
||||
val rect = Rect()
|
||||
playerView.getGlobalVisibleRect(rect)
|
||||
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(w, h))
|
||||
.setSourceRectHint(rect)
|
||||
.setAutoEnterEnabled(enabled)
|
||||
.build()
|
||||
|
||||
findActivity()?.setPictureInPictureParams(params)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberPipMode(): State<Boolean> {
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx.findActivity() as? ComponentActivity
|
||||
val state = remember { mutableStateOf(activity?.isInPictureInPictureMode == true) }
|
||||
|
||||
DisposableEffect(activity) {
|
||||
if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return@DisposableEffect onDispose { }
|
||||
}
|
||||
|
||||
val cb = Consumer<PictureInPictureModeChangedInfo> { info ->
|
||||
state.value = info.isInPictureInPictureMode
|
||||
}
|
||||
|
||||
activity.addOnPictureInPictureModeChangedListener(cb)
|
||||
onDispose { activity.removeOnPictureInPictureModeChangedListener(cb) }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package ru.shadowsparky.videoplayer.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import ru.shadowsparky.videoplayer.AndroidPlayerController
|
||||
import ru.shadowsparky.videoplayer.CanvasEvent
|
||||
import ru.shadowsparky.videoplayer.PlayerController
|
||||
import ru.shadowsparky.videoplayer.VideoScaleType
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
actual fun VideoCanvas(
|
||||
controller: PlayerController,
|
||||
modifier: Modifier,
|
||||
scaleType: VideoScaleType,
|
||||
onCanvasEvent: (CanvasEvent) -> Unit
|
||||
) {
|
||||
val androidController = controller as? AndroidPlayerController
|
||||
?: throw IllegalArgumentException("Controller must be AndroidPlayerController")
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val pipMode by rememberPipMode()
|
||||
|
||||
LaunchedEffect(pipMode) {
|
||||
if (pipMode) onCanvasEvent(CanvasEvent.HideControls)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
context.enterFullscreen()
|
||||
onDispose {
|
||||
context.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
useController = false
|
||||
|
||||
resizeMode = when (scaleType) {
|
||||
VideoScaleType.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
VideoScaleType.FULL -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
VideoScaleType.STRETCH -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
}
|
||||
|
||||
keepScreenOn = true
|
||||
context.updatePipAutoEnter(this, true)
|
||||
}
|
||||
},
|
||||
update = { playerView ->
|
||||
if (playerView.player != androidController.getPlayerInstance()) {
|
||||
playerView.player = androidController.getPlayerInstance()
|
||||
}
|
||||
},
|
||||
onRelease = { playerView ->
|
||||
context.updatePipAutoEnter(playerView, false)
|
||||
playerView.player = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Context.findActivity(): Activity? {
|
||||
var ctx = this
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Context.enterFullscreen() {
|
||||
val activity = findActivity() ?: return
|
||||
val window = activity.window ?: return
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
|
||||
private fun Context.exitFullscreen() {
|
||||
val activity = findActivity() ?: return
|
||||
val window = activity.window ?: return
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M240,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M400,840Q334,840 287,793Q240,746 240,680Q240,614 287,567Q334,520 400,520Q423,520 442.5,525.5Q462,531 480,542L480,120L720,120L720,280L560,280L560,680Q560,746 513,793Q466,840 400,840Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M480,880Q405,880 339.5,851.5Q274,823 225.5,774.5Q177,726 148.5,660.5Q120,595 120,520L200,520Q200,637 281.5,718.5Q363,800 480,800Q597,800 678.5,718.5Q760,637 760,520Q760,403 678.5,321.5Q597,240 480,240L474,240L536,302L480,360L320,200L480,40L536,98L474,160L480,160Q555,160 620.5,188.5Q686,217 734.5,265.5Q783,314 811.5,379.5Q840,445 840,520Q840,595 811.5,660.5Q783,726 734.5,774.5Q686,823 620.5,851.5Q555,880 480,880Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<string name="player_error_title">Ошибка плеера</string>
|
||||
<string name="player_error_summary">Во время проигрывания видео произошла ошибка: %1$s</string>
|
||||
<string name="player_retry">Повторить</string>
|
||||
<string name="player_audio_track">Аудио %1$d</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
data class AudioTrackInfo(
|
||||
val id: String,
|
||||
val groupIndex: Int,
|
||||
val trackIndex: Int,
|
||||
val description: String,
|
||||
val isSelected: Boolean
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
sealed class CanvasEvent {
|
||||
data object HideControls : CanvasEvent()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
|
||||
class ControlsState(
|
||||
val isLocked: MutableState<Boolean>
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
interface ExternalPlayer {
|
||||
fun play(url: String)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user