init projects

This commit is contained in:
2026-06-17 18:58:41 +03:00
parent 8543c4e241
commit 21a768edd5
505 changed files with 19998 additions and 93 deletions
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+10
View File
@@ -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();
}
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+8
View File
@@ -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 = "Был передан неправильный токен"
@@ -0,0 +1,3 @@
package ru.shadowsparky.backend.domain
class VerifyTokenException(override val message: String?) : IllegalStateException()
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+17
View File
@@ -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()
}
}
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+28
View File
@@ -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" }
}
@@ -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
}
@@ -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) }
}
}
@@ -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
}
}
@@ -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()
}
@@ -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
}
@@ -0,0 +1,5 @@
package ru.shadowsparky.http.domain
interface UserAgentProvider {
fun provide(): String?
}
@@ -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()
}
}
}
}
@@ -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)
}
}
}
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+19
View File
@@ -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" }
}
@@ -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()
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+25
View File
@@ -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" }
}
@@ -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
}
@@ -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
}
}
@@ -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
}
@@ -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