add chat basics

This commit is contained in:
2026-07-28 20:45:30 +03:00
parent 1f90bf67ab
commit 644bcf9895
72 changed files with 323 additions and 14 deletions
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
@@ -0,0 +1,12 @@
plugins {
alias(libs.plugins.convention.jvm)
alias(libs.plugins.convention.ktor.client)
alias(libs.plugins.convention.koin)
alias(libs.plugins.convention.serialization)
}
dependencies {
implementation(project(":libs:backend-base"))
implementation(project(":libs:http-client"))
implementation(project(":feature:chat:chat-common"))
}
@@ -0,0 +1,10 @@
package ru.shadowsparky.chat.backend
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@ComponentScan
@Configuration
class BackendChatModule
@@ -0,0 +1,97 @@
package ru.shadowsparky.chat.backend.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.backend.domain.ChatService
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.http.domain.HttpException
import java.util.UUID
@Factory
class GigaChatService(
private val tokenManager: GigaChatTokenManager,
private val json: Json
) : ChatService {
private val httpClient by lazy { createGigaChatHttpClient() }
override suspend fun getCompletion(userId: Long, messages: List<Message>): String {
val clientId = "vbox-$userId"
val staticSessionId = UUID.nameUUIDFromBytes(clientId.toByteArray()).toString()
val requestBody = GigaChatRequest(
messages = messages.map { GigaChatMessageDto(role = it.role, content = it.content) }
)
val response = try {
executeRequest(clientId, staticSessionId, requestBody)
} catch (e: HttpException) {
if (e.httpCode == HttpStatusCode.Unauthorized.value) {
tokenManager.forceRefreshToken()
executeRequest(clientId, staticSessionId, requestBody)
} else {
throw e
}
}
return response.choices.firstOrNull()?.message?.content
?: error("unexpected response $response")
}
private suspend fun executeRequest(clientId: String, sessionId: String, body: GigaChatRequest): GigaChatResponse {
return httpClient.post("https://api.giga.chat/v1/chat/completions") {
contentType(ContentType.Application.Json)
header("X-Request-ID", UUID.randomUUID().toString())
header("X-Session-ID", sessionId)
header("X-Client-ID", clientId)
setBody(body)
}.body()
}
private fun createGigaChatHttpClient(): HttpClient {
return HttpClient {
install(ContentNegotiation) { json(json) }
install(Auth) {
bearer {
loadTokens { BearerTokens(tokenManager.getToken(), "") }
refreshTokens {
val newToken = tokenManager.forceRefreshToken()
BearerTokens(newToken, "")
}
}
}
}
}
@Serializable
private data class GigaChatRequest(
val model: String = "GigaChat-2",
val stream: Boolean = false,
@SerialName("update_interval")
val updateInterval: Int = 0,
val messages: List<GigaChatMessageDto>
)
@Serializable
private data class GigaChatMessageDto(
val role: String,
val content: String
)
@Serializable
private data class GigaChatResponse(val choices: List<GigaChatChoiceDto>)
@Serializable
private data class GigaChatChoiceDto(val message: GigaChatMessageDto)
}
@@ -0,0 +1,58 @@
package ru.shadowsparky.chat.backend.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Parameters
import io.ktor.http.contentType
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.EnvFetcher
import java.util.UUID
@Single
class GigaChatTokenManager(
private val authClient: HttpClient,
private val json: Json,
private val envFetcher: EnvFetcher
) {
private val scope: String = "GIGACHAT_API_PERS"
private val mutex = Mutex()
private var cachedToken: String? = null
suspend fun getToken(): String = mutex.withLock {
cachedToken ?: fetchNewToken()
}
suspend fun forceRefreshToken(): String = mutex.withLock {
fetchNewToken()
}
private suspend fun fetchNewToken(): String {
val auth = envFetcher.get("GIGA_CHAT_AUTH_TOKEN").ifEmpty { error("auth token not provided") }
val responseString: String = authClient.post("https://ngw.devices.sberbank.ru:9443/api/v2/oauth") {
header(HttpHeaders.Authorization, auth)
header("RqUID", UUID.randomUUID().toString())
contentType(ContentType.Application.FormUrlEncoded)
setBody(FormDataContent(Parameters.build {
append("scope", scope)
}))
}.body()
val token = json.parseToJsonElement(responseString).jsonObject["access_token"]
?.jsonPrimitive
?.content
?: error("Unable to fetch token. response $responseString")
cachedToken = token
return token
}
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.chat.backend.domain
import ru.shadowsparky.chat.domain.Message
interface ChatService {
suspend fun getCompletion(userId: Long, messages: List<Message>): String
}
@@ -0,0 +1,8 @@
package ru.shadowsparky.chat.backend.domain
import ru.shadowsparky.chat.domain.Message
interface MessageStorage {
suspend fun query(userId: Long, limit: Int): List<Message>
suspend fun insert(userId: Long, message: Message): Long
}
@@ -0,0 +1,35 @@
package ru.shadowsparky.chat.backend.domain
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.Message
@Factory
class ProcessUserMessageUseCase(
private val storage: MessageStorage,
private val chatService: ChatService
) {
suspend fun execute(userId: Long, userMessage: Message): Message {
storage.insert(userId, userMessage)
val history = storage.query(userId, limit = 10).toList()
val sortedHistory = history.sortedBy { it.timestamp }
val systemMessage = Message(
role = ChatRoles.SYSTEM,
content = SYSTEM_PROMPT,
timestamp = System.currentTimeMillis()
)
val fullContext = listOf(systemMessage) + sortedHistory
val aiResponseText = chatService.getCompletion(userId, fullContext)
val aiMessage = Message(
role = ChatRoles.ASSISTANT,
content = aiResponseText,
timestamp = System.currentTimeMillis()
)
val messageId = storage.insert(userId, aiMessage)
return aiMessage.copy(id = messageId)
}
companion object {
private const val SYSTEM_PROMPT = "Ты ИИ-помощник приложения \"Свиное рыло\". Ищи юзерам фильмы и сериалы. Правила: 1. Называй юзера \"свинорылец\" (Пример: \"Здорово, свинорылец!\"). 2. Общайся по-свойски, с юмором, иронично. 3. Если юзер сомневается, задай 2 наводящих вопроса. 4. Выдавай строго до 5 позиций максимум, даже если просят больше. Описание короткое (1-2 предложения). 5. Каждое название фильма/сериала ВСЕГДА строго оборачивай в теги [MOVIE]Название[/MOVIE]. 6. На нерелевантные темы не общайся, переводи разговор на кино в свиной тематике."
}
}
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+30
View File
@@ -0,0 +1,30 @@
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"))
implementation(project(":libs:base"))
api(project(":feature:chat:chat-common"))
implementation(libs.ui.tooling.preview)
}
}
val androidMain by getting {
dependencies {
implementation(libs.androidx.core)
}
}
}
android { namespace = "ru.shadowsparky.chat" }
}
dependencies {
androidRuntimeClasspath(libs.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+16
View File
@@ -0,0 +1,16 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.serialization)
}
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":libs:base"))
}
}
}
android { namespace = "ru.shadowsparky.chat.common" }
}
@@ -0,0 +1,9 @@
package ru.shadowsparky.chat.domain
import kotlinx.coroutines.flow.Flow
interface ChatRepository {
val messages: Flow<Message>
suspend fun sendMessage(message: Message)
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.chat.domain
object ChatRoles {
const val USER = "user"
const val SYSTEM = "system"
const val ASSISTANT = "assistant"
}
@@ -0,0 +1,11 @@
package ru.shadowsparky.chat.domain
import kotlinx.serialization.Serializable
@Serializable
data class Message(
val id: Long? = null,
val role: String,
val content: String,
val timestamp: Long
)
@@ -0,0 +1,2 @@
/build
/bin
@@ -0,0 +1,30 @@
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"))
implementation(project(":libs:base"))
api(project(":feature:updater:updater-common"))
implementation(libs.ui.tooling.preview)
}
}
val androidMain by getting {
dependencies {
implementation(libs.androidx.core)
}
}
}
android { namespace = "ru.shadowsparky.updater" }
}
dependencies {
androidRuntimeClasspath(libs.ui.tooling)
}
@@ -0,0 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
<application>
<receiver
android:name=".presentation.UpdateInstalledReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,140 @@
package ru.shadowsparky.updater.data
import android.app.Application
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInstaller
import java.io.File
import java.io.FileInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Factory
class AndroidUpdateInstaller(
private val app: Application
) : UpdateInstaller {
private val actionInstallStatus = "${app.packageName}.ACTION_INSTALL_STATUS"
override suspend fun hasInstallPermission(): Boolean =
app.packageManager.canRequestPackageInstalls()
override suspend fun requestInstallPermission() {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${app.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
app.startActivity(intent)
}
override suspend fun install(path: String) {
val file = File(path)
if (!file.exists()) {
error("APK file not found at: $path")
}
val packageInstaller = app.packageManager.packageInstaller
val sessionId = createSession(packageInstaller)
try {
writeApkToSession(packageInstaller, sessionId, file)
awaitInstallationResult(packageInstaller, sessionId)
} catch (e: Exception) {
runCatching { packageInstaller.abandonSession(sessionId) }
throw e
} finally {
file.delete()
}
}
private fun createSession(packageInstaller: PackageInstaller): Int {
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
return packageInstaller.createSession(params)
}
private fun writeApkToSession(packageInstaller: PackageInstaller, sessionId: Int, file: File) {
packageInstaller.openSession(sessionId).use { session ->
session.openWrite("update_stream", 0, file.length()).use { outputStream ->
FileInputStream(file).use { inputStream ->
val buffer = ByteArray(65536)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
}
session.fsync(outputStream)
}
}
}
}
private suspend fun awaitInstallationResult(packageInstaller: PackageInstaller, sessionId: Int) {
suspendCancellableCoroutine<Unit> { continuation ->
val receiver = createStatusReceiver(continuation)
registerStatusReceiver(receiver)
continuation.invokeOnCancellation {
runCatching { app.unregisterReceiver(receiver) }
runCatching { packageInstaller.abandonSession(sessionId) }
}
val intent = Intent(actionInstallStatus)
.setPackage(app.packageName)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
val pendingIntent = PendingIntent.getBroadcast(
app,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
packageInstaller.openSession(sessionId).use { session ->
session.commit(pendingIntent.intentSender)
}
}
}
private fun createStatusReceiver(
continuation: CancellableContinuation<Unit>
): BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val userAction = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)
userAction?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(it)
}
}
PackageInstaller.STATUS_SUCCESS -> {
app.unregisterReceiver(this)
continuation.resume(Unit)
}
else -> {
app.unregisterReceiver(this)
continuation.resumeWithException(IllegalStateException("Installation failed ($status): $message"))
}
}
}
}
private fun registerStatusReceiver(receiver: BroadcastReceiver) {
ContextCompat.registerReceiver(
app,
receiver,
IntentFilter(actionInstallStatus),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
}
@@ -0,0 +1,78 @@
package ru.shadowsparky.updater.data
import android.app.Application
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.updater.domain.DownloadProgress
import ru.shadowsparky.updater.domain.UpdateDownloader
import java.io.File
@Factory
class KtorUpdateDownloader(
private val httpClient: HttpClient,
app: Application,
private val dispatcherProvider: DispatcherProvider
) : UpdateDownloader {
private val destinationPath = "${app.cacheDir.absolutePath}/update"
override fun download(url: String): Flow<DownloadProgress> = flow {
try {
httpClient.prepareGet(url).execute { response ->
val contentLength = response.contentLength() ?: -1L
val channel: ByteReadChannel = response.bodyAsChannel()
val directory = File(destinationPath)
if (!directory.exists()) {
directory.mkdirs()
}
val fileName = "update.apk"
val fullPath = "$destinationPath/$fileName"
val file = File(fullPath)
if (file.exists()) file.delete()
val bufferSize = 8192
val buffer = ByteArray(bufferSize)
var downloadedBytes = 0L
file.outputStream().use { outputStream ->
while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read <= 0) break
outputStream.write(buffer, 0, read)
downloadedBytes += read
if (contentLength > 0) {
val progress = downloadedBytes.toFloat() / contentLength.toFloat()
emit(
DownloadProgress.Active(
progress = progress,
downloadedBytes = downloadedBytes,
totalBytes = contentLength
)
)
}
}
}
}
val finalFile = File("$destinationPath/update.apk")
emit(DownloadProgress.Success(path = finalFile.absolutePath))
} catch (e: Exception) {
emit(DownloadProgress.Error(e))
}
}.flowOn(dispatcherProvider.io)
override suspend fun clean(): Unit = withContext(dispatcherProvider.io) {
File(destinationPath).deleteRecursively()
}
}
@@ -0,0 +1,29 @@
package ru.shadowsparky.updater.presentation
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ru.shadowsparky.koin
import ru.shadowsparky.updater.domain.UpdateDownloader
class UpdateInstalledReceiver : BroadcastReceiver() {
override fun onReceive(p0: Context?, p1: Intent?) {
if (p1?.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
koin.getOrNull<UpdateDownloader>()?.let { downloader ->
val scope = CoroutineScope(Job() + Dispatchers.Main)
val async = goAsync()
scope.launch {
try {
downloader.clean()
} finally {
async.finish()
}
}
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updater_isolated_cache" path="update/" />
</paths>
@@ -0,0 +1,20 @@
<resources>
<string name="updater_title_available">Доступно обновление на версию %1$s</string>
<string name="updater_title_not_found">Обновления не найдены</string>
<string name="updater_title_completed">Обновление успешно установлено</string>
<string name="updater_btn_close">Закрыть</string>
<string name="updater_btn_download">Скачать</string>
<string name="updater_changelog_title">Что нового:</string>
<string name="updater_changelog_empty">Улучшения производительности и исправления ошибок.</string>
<string name="updater_title_permission">Нужно разрешение</string>
<string name="updater_desc_permission">Для установки обновления приложению требуется разрешение на установку из неизвестных источников. Пожалуйста, включите его в настройках.</string>
<string name="updater_btn_grant">В настройки</string>
<string name="updater_title_downloading">Скачивание обновления…</string>
<string name="updater_download_progress">%1$s МБ из %2$s МБ</string>
<string name="updater_title_ready">Обновление готово к установке</string>
<string name="updater_btn_install">Установить</string>
<string name="updater_title_error">Ошибка обновления</string>
<string name="updater_btn_retry">Повторить</string>
</resources>
@@ -0,0 +1,10 @@
package ru.shadowsparky.updater
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@ComponentScan
@Configuration
class UpdaterModule
@@ -0,0 +1,15 @@
package ru.shadowsparky.updater.domain
import kotlinx.coroutines.flow.Flow
interface UpdateDownloader {
fun download(url: String): Flow<DownloadProgress>
suspend fun clean()
}
sealed interface DownloadProgress {
data class Active(val progress: Float, val downloadedBytes: Long, val totalBytes: Long) : DownloadProgress
data class Success(val path: String) : DownloadProgress
data class Error(val throwable: Throwable) : DownloadProgress
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.updater.domain
interface UpdateInstaller {
suspend fun hasInstallPermission(): Boolean
suspend fun requestInstallPermission()
suspend fun install(path: String)
}
@@ -0,0 +1,96 @@
package ru.shadowsparky.updater.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.AppConfig
@Factory
class UpdateInteractor(
private val fetcher: UpdateFetcher?,
private val downloader: UpdateDownloader?,
private val installer: UpdateInstaller?,
private val appConfig: AppConfig?
) {
val hasSupport = fetcher != null && downloader != null && installer != null &&
appConfig?.versionCode != null
fun checkUpdate(): Flow<UpdateState> = flow {
if (appConfig?.versionCode == null || fetcher == null) {
emit(UpdateState.UpToDate)
return@flow
}
emit(UpdateState.Checking)
try {
val currentVersion = appConfig.versionCode ?: 0L
val updateInfo = fetcher.fetch(currentVersion)
val serverVersion = updateInfo.versionCode ?: 0L
if (serverVersion <= currentVersion) {
emit(UpdateState.UpToDate)
} else {
emit(
UpdateState.UpdateAvailable(
url = checkNotNull(updateInfo.url) { "url required" },
versionName = updateInfo.versionName ?: "v$serverVersion",
changeLog = updateInfo.changelog,
isForceUpdate = updateInfo.force
)
)
}
} catch (e: Exception) {
emit(UpdateState.Error(message = e.message ?: "Ошибка проверки", throwable = e))
}
}
fun startDownload(url: String): Flow<UpdateState> = flow {
if (installer == null || downloader == null) {
emit(UpdateState.UpToDate)
return@flow
}
if (!installer.hasInstallPermission()) {
emit(UpdateState.InstallPermissionRequired)
return@flow
}
emit(UpdateState.Downloading(0f, 0, 0))
downloader.download(url).firstOrNull { progress ->
when (progress) {
is DownloadProgress.Active -> {
emit(
UpdateState.Downloading(
progress = progress.progress,
downloadedBytes = progress.downloadedBytes,
totalBytes = progress.totalBytes
)
)
false
}
is DownloadProgress.Success -> {
emit(UpdateState.ReadyToInstall(filePath = progress.path))
true
}
is DownloadProgress.Error -> {
emit(UpdateState.Error(message = progress.throwable.message ?: "Ошибка скачивания", throwable = progress.throwable))
true
}
}
}
}
suspend fun requestInstallPermission() {
installer?.requestInstallPermission()
}
suspend fun installApk(path: String) {
installer?.install(path)
}
suspend fun reset() {
downloader?.clean()
}
suspend fun hasInstallPermission(): Boolean {
return installer?.hasInstallPermission() ?: false
}
}
@@ -0,0 +1,37 @@
package ru.shadowsparky.updater.domain
import androidx.compose.runtime.Immutable
@Immutable
sealed interface UpdateState {
data object Idle : UpdateState
data object Checking : UpdateState
data class UpdateAvailable(
val url: String,
val versionName: String,
val changeLog: String?,
val isForceUpdate: Boolean
) : UpdateState
data object InstallPermissionRequired : UpdateState
data object UpToDate : UpdateState
data object Completed : UpdateState
data class Downloading(
val progress: Float,
val downloadedBytes: Long,
val totalBytes: Long
) : UpdateState
data class ReadyToInstall(val filePath: String) : UpdateState
data class Error(
val message: String,
val throwable: Throwable? = null
) : UpdateState
}
@@ -0,0 +1,111 @@
package ru.shadowsparky.updater.presentation
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnDestroy
import com.arkivanov.essenty.lifecycle.doOnResume
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInteractor
import ru.shadowsparky.updater.domain.UpdateState
class UpdateComponent(
componentContext: ComponentContext,
private val interactor: UpdateInteractor
) : ComponentContext by componentContext {
val hasSupport = interactor.hasSupport
var isForceUpdate = false
private set
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
_state.value = UpdateState.Error(throwable.message ?: "$throwable", throwable)
}
private var lastJob: Job? = null
private val scope = CoroutineScope(exceptionHandler + SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableValue<UpdateState>(UpdateState.Idle)
val state: Value<UpdateState> = _state
private var pendingUrl: String? = null
init {
lifecycle.doOnDestroy { scope.cancel() }
lifecycle.doOnResume {
val url = pendingUrl
if (_state.value is UpdateState.InstallPermissionRequired && url != null) {
scope.launch {
if (interactor.hasInstallPermission()) downloadUpdate(url)
}
}
}
}
fun checkUpdates(byUser: Boolean = true) {
if (!hasSupport) return
lastJob = scope.launch {
interactor.checkUpdate().collect {
isForceUpdate = (it as? UpdateState.UpdateAvailable)?.isForceUpdate ?: false
_state.value = if (byUser) {
it
} else {
when (it) {
UpdateState.Checking,
UpdateState.UpToDate,
is UpdateState.Error -> UpdateState.Idle
else -> it
}
}
}
}
}
fun downloadUpdate(url: String) {
pendingUrl = url
lastJob = scope.launch {
interactor.startDownload(url).collect { newState ->
_state.value = newState
if (newState !is UpdateState.InstallPermissionRequired) {
pendingUrl = null
}
if (newState is UpdateState.ReadyToInstall) {
installApk(newState.filePath)
}
}
}
}
fun requestPermission() {
scope.launch { interactor.requestInstallPermission() }
}
fun installApk(path: String) {
scope.launch {
_state.value = UpdateState.Checking
interactor.installApk(path)
_state.value = UpdateState.Completed
}
}
fun reset() {
scope.launch {
_state.value = UpdateState.Checking
if (lastJob?.isActive == true) lastJob?.cancel()
interactor.reset()
_state.value = UpdateState.Idle
}
}
}
@Factory
class UpdateComponentFactory(private val interactor: UpdateInteractor) {
fun create(componentContext: ComponentContext): UpdateComponent {
return UpdateComponent(componentContext, interactor)
}
}
@@ -0,0 +1,321 @@
package ru.shadowsparky.updater.presentation
import androiddev.libs.updater.updater_client.generated.resources.Res
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_close
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_download
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_grant
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_install
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_retry
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_empty
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_title
import androiddev.libs.updater.updater_client.generated.resources.updater_desc_permission
import androiddev.libs.updater.updater_client.generated.resources.updater_download_progress
import androiddev.libs.updater.updater_client.generated.resources.updater_title_available
import androiddev.libs.updater.updater_client.generated.resources.updater_title_completed
import androiddev.libs.updater.updater_client.generated.resources.updater_title_downloading
import androiddev.libs.updater.updater_client.generated.resources.updater_title_error
import androiddev.libs.updater.updater_client.generated.resources.updater_title_not_found
import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission
import androiddev.libs.updater.updater_client.generated.resources.updater_title_ready
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.updater.domain.UpdateState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateDialog(
component: UpdateComponent,
modifier: Modifier = Modifier
) {
val state by component.state.subscribeAsState()
val canDismiss = remember(state) { !component.isForceUpdate }
val isVisible = remember(state) { state !is UpdateState.Idle }
if (isVisible) {
AlertContent(
modifier = modifier,
onDismissRequest = { component.reset() },
canDismiss = canDismiss,
content = {
when (val currentState = state) {
is UpdateState.Checking -> UpdateCheckingContent()
is UpdateState.UpdateAvailable -> UpdateAvailableContent(
versionName = currentState.versionName,
changeLog = currentState.changeLog,
onDownloadClick = { component.downloadUpdate(currentState.url) }
)
is UpdateState.InstallPermissionRequired -> UpdatePermissionContent(
onGrantClick = { component.requestPermission() }
)
is UpdateState.Downloading -> UpdateDownloadingContent(
progress = currentState.progress,
downloadedBytes = currentState.downloadedBytes,
totalBytes = currentState.totalBytes
)
is UpdateState.ReadyToInstall -> UpdateReadyContent(
onInstallClick = { component.installApk(currentState.filePath) }
)
is UpdateState.Error -> UpdateErrorContent(
message = currentState.message,
onRetryClick = { component.checkUpdates() }
)
is UpdateState.UpToDate -> {
UpdateFinishedContent(onSkip = { component.reset() })
}
is UpdateState.Completed -> {
UpdateFinishedContent(
Res.string.updater_title_completed,
onSkip = { component.reset() }
)
}
else -> Unit
}
}
)
}
}
@Composable
private fun UpdateCheckingContent() {
Loading(Modifier.fillMaxWidth())
}
@Composable
private fun UpdateAvailableContent(
versionName: String,
changeLog: String?,
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_available, versionName),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
Text(
text = stringResource(Res.string.updater_changelog_title),
style = MaterialTheme.typography.titleMedium
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
Text(
text = changeLog ?: stringResource(Res.string.updater_changelog_empty),
style = MaterialTheme.typography.bodyMedium
)
}
item {
Button(onClick = onDownloadClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_download),
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
}
@Composable
private fun UpdatePermissionContent(
onGrantClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_permission),
style = MaterialTheme.typography.titleLarge
)
Text(
text = stringResource(Res.string.updater_desc_permission),
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = onGrantClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_grant),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
private fun UpdateDownloadingContent(
progress: Float,
downloadedBytes: Long,
totalBytes: Long,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_downloading),
style = MaterialTheme.typography.titleMedium
)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
val downloadedMb = ((downloadedBytes / (1024f * 1024f)) * 10).toInt() / 10.0
val totalMb = ((totalBytes / (1024f * 1024f)) * 10).toInt() / 10.0
Text(
text = stringResource(
Res.string.updater_download_progress,
downloadedMb.toString(),
totalMb.toString()
),
style = MaterialTheme.typography.bodySmall
)
}
}
@Composable
private fun UpdateReadyContent(
onInstallClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_ready),
style = MaterialTheme.typography.titleLarge
)
Button(onClick = onInstallClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_install),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
private fun UpdateFinishedContent(
text: StringResource = Res.string.updater_title_not_found,
onSkip: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(text), style = MaterialTheme.typography.titleLarge)
Button(onClick = onSkip, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_close),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
private fun UpdateErrorContent(
message: String,
onRetryClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_error),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.error
)
Text(text = message, style = MaterialTheme.typography.bodyMedium)
Button(onClick = onRetryClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_retry),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Preview
@Composable
private fun UpdateAvailablePreview() {
MaterialTheme {
UpdateAvailableContent(
versionName = "v1.4.0",
changeLog = "- Добавили темную тему\n- Исправили падение на главном экране\n- Повысили стабильность",
onDownloadClick = {}
)
}
}
@Preview
@Composable
private fun UpdateDownloadingPreview() {
MaterialTheme {
UpdateDownloadingContent(
progress = 0.45f,
downloadedBytes = 24_117_248L, // ~23 MB
totalBytes = 52_428_800L // 50 MB
)
}
}
@Preview
@Composable
private fun UpdatePermissionPreview() {
MaterialTheme {
UpdatePermissionContent(onGrantClick = {})
}
}
@Preview
@Composable
private fun UpdateErrorPreview() {
MaterialTheme {
UpdateErrorContent(message = "Http status 503: Service Unavailable", onRetryClick = {})
}
}
@@ -0,0 +1,2 @@
/build
/bin
@@ -0,0 +1,16 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.serialization)
}
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":libs:base"))
}
}
}
android { namespace = "ru.shadowsparky.updater.common" }
}
@@ -0,0 +1,9 @@
package ru.shadowsparky.updater.domain
interface UpdateFetcher {
suspend fun fetch(versionCode: Long): UpdateInfo
companion object {
const val ARG_VERSION_CODE = "versionCode"
}
}
@@ -0,0 +1,12 @@
package ru.shadowsparky.updater.domain
import kotlinx.serialization.Serializable
@Serializable
data class UpdateInfo(
val versionCode: Long? = null,
val versionName: String? = null,
val url: String? = null,
val changelog: String? = null,
val force: Boolean = false
)
+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,215 @@
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
import kotlin.time.Duration.Companion.milliseconds
@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) {
isTrackInitialized = false
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.milliseconds)
}
}
}
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)
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.videoplayer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface PlayerController {
val state: StateFlow<PlayerState>
val events: Flow<PlayerEvent>
val audioTracks: StateFlow<List<AudioTrackInfo>>
suspend fun load(url: String, startPositionMs: Long = 0L)
suspend fun play()
suspend fun pause()
suspend fun seekTo(positionMs: Long)
suspend fun selectAudioTrack(id: String)
suspend fun release()
}
@@ -0,0 +1,8 @@
package ru.shadowsparky.videoplayer
sealed interface PlayerEvent {
data object Idle : PlayerEvent
data object Playing : PlayerEvent
data class Error(val message: String) : PlayerEvent
data object Ended : PlayerEvent
}
@@ -0,0 +1,18 @@
package ru.shadowsparky.videoplayer
data class PlayerState(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val isEnded: Boolean = false,
val durationMs: Long = 0L,
val currentPositionMs: Long = 0L,
val bufferedPositionMs: Long = 0L,
val videoWidth: Int = 0,
val videoHeight: Int = 0,
val volume: Float = 1.0f,
val videoUrl: String? = null
)
@@ -0,0 +1,10 @@
package ru.shadowsparky.videoplayer
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@Configuration
@ComponentScan
class VideoPlayerModule
@@ -0,0 +1,7 @@
package ru.shadowsparky.videoplayer
enum class VideoScaleType {
FIT,
FULL,
STRETCH
}
@@ -0,0 +1,403 @@
package ru.shadowsparky.videoplayer.ui
import androiddev.libs.video_player.generated.resources.Res
import androiddev.libs.video_player.generated.resources.lock_24px
import androiddev.libs.video_player.generated.resources.lock_open_24px
import androiddev.libs.video_player.generated.resources.music_note_24px
import androiddev.libs.video_player.generated.resources.pause_24px
import androiddev.libs.video_player.generated.resources.play_arrow_24px
import androiddev.libs.video_player.generated.resources.replay_24px
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import ru.shadowsparky.ui.components.isTv
import ru.shadowsparky.videoplayer.AudioTrackInfo
import ru.shadowsparky.videoplayer.ControlsState
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.PlayerState
import kotlin.time.Duration.Companion.milliseconds
private const val DEF_SEEK_TIME = 10L
@Composable
fun DefaultControls(
state: PlayerState,
controlsState: ControlsState,
controller: PlayerController,
modifier: Modifier = Modifier
) {
val isTv = isTv()
val scope = rememberCoroutineScope()
val audioTracksState by controller.audioTracks.collectAsState()
val isUiLocked = controlsState.isLocked
var isDragging by rememberSaveable { mutableStateOf(false) }
var sliderPosition by rememberSaveable { mutableStateOf(0f) }
val currentUiPosition = if (isDragging) sliderPosition else state.currentPositionMs.toFloat()
val totalDuration = state.durationMs.toFloat().coerceAtLeast(1f)
val focus = remember { FocusContainer() }
Box(
modifier = modifier
.fillMaxSize()
.background(if (isUiLocked.value) Color.Transparent else Color.Black.copy(alpha = 0.4f))
) {
SideDoubleTapSeekZones(controller)
Row(modifier = Modifier.align(Alignment.TopEnd).focusGroup().padding(8.dp)) {
if (audioTracksState.isNotEmpty() && !isUiLocked.value) {
AudioTracksButton(
focus,
tracks = audioTracksState,
onSelect = { id ->
scope.launch { controller.selectAudioTrack(id) }
}
)
}
if (!isTv) {
LockUiButton(focus, isUiLocked.value) { isUiLocked.value = it }
}
}
if (!isUiLocked.value) {
CenterControls(focus, state, controller)
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp)
.controlsBackground()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatDuration(currentUiPosition.toLong()),
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = formatDuration(state.durationMs),
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
}
Slider(
value = currentUiPosition,
valueRange = 0f..totalDuration,
onValueChange = { newPos ->
isDragging = true
sliderPosition = newPos
},
onValueChangeFinished = {
isDragging = false
scope.launch {
controller.seekTo(sliderPosition.toLong())
}
},
modifier = Modifier.fillMaxWidth()
.focusableWithBorder(focus.slider)
.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyDown) {
when (it.key) {
Key.DirectionLeft -> {
scope.launch { controller.seek(true) }
}
Key.DirectionRight -> {
scope.launch { controller.seek(false) }
}
Key.DirectionUp -> focus.play.requestFocus()
Key.DirectionDown -> focus.audioTrack.requestFocus()
else -> return@onPreviewKeyEvent false
}
}
true
}
)
}
}
}
}
@Composable
private fun LockUiButton(
focusContainer: FocusContainer,
state: Boolean,
onChanged: (Boolean) -> Unit
) {
IconButton(
onClick = { onChanged(!state) },
modifier = Modifier.focusableWithBorder(focusContainer.lock)
) {
val icon = if (state) Res.drawable.lock_24px else Res.drawable.lock_open_24px
Icon(painterResource(icon), null, tint = Color.White)
}
}
@Composable
private fun AudioTracksButton(
focusContainer: FocusContainer,
tracks: List<AudioTrackInfo>,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
val tracks = tracks.map { track -> track to FocusRequester() }
IconButton(
onClick = { expanded = true },
modifier = Modifier.focusableWithBorder(focusContainer.audioTrack)
) {
Icon(
painter = painterResource(Res.drawable.music_note_24px),
contentDescription = "Audio tracks",
tint = Color.White
)
}
LaunchedEffect(expanded) {
if (expanded) {
delay(500.milliseconds)
tracks.firstOrNull()?.second?.requestFocus()
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.focusable()
) {
for (i in tracks.indices) {
val (track, requester) = tracks[i]
DropdownMenuItem(
text = {
Text(
text = if (track.isSelected)
"● ${track.description}"
else
track.description
)
},
onClick = {
expanded = false
onSelect(track.id)
},
modifier = Modifier.focusableWithBorder(requester)
)
}
}
}
}
@Composable
private fun BoxScope.SideDoubleTapSeekZones(controller: PlayerController, enabled: Boolean = true) {
val scope = rememberCoroutineScope()
if (!enabled) return
Row(modifier = Modifier.matchParentSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(0.3f)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scope.launch { controller.seek(true) }
}
)
}
)
Spacer(modifier = Modifier.fillMaxHeight().weight(0.3f))
Box(
modifier = Modifier
.fillMaxHeight()
.weight(0.3f)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scope.launch {
controller.seek(false)
}
}
)
}
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun BoxScope.CenterControls(
focusContainer: FocusContainer,
state: PlayerState,
controller: PlayerController
) {
val scope = rememberCoroutineScope()
var canRequestFocus by remember { mutableStateOf(false) }
Box(
modifier = Modifier.align(Alignment.Center)
.focusGroup()
.controlsBackground()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
when {
state.isBuffering -> CircularWavyProgressIndicator()
state.isEnded -> {
IconButton(
onClick = {
scope.launch {
controller.seekTo(0)
controller.play()
}
}
) {
Icon(
painter = painterResource(Res.drawable.replay_24px),
contentDescription = "Replay",
modifier = Modifier.size(64.dp),
tint = Color.White
)
}
}
else -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
IconButton(
onClick = {
scope.launch {
if (state.isPlaying) controller.pause() else controller.play()
}
}, modifier = Modifier.focusableWithBorder(focusContainer.play)
) {
Icon(
painter = painterResource(
if (state.isPlaying)
Res.drawable.pause_24px
else
Res.drawable.play_arrow_24px
),
contentDescription = "Play/Pause",
modifier = Modifier.size(64.dp),
tint = Color.White
)
}
}
canRequestFocus = true
}
}
}
LaunchedEffect(state.videoUrl) { canRequestFocus = false }
LaunchedEffect(canRequestFocus) {
if (canRequestFocus) {
withFrameNanos {}
focusContainer.play.requestFocus()
}
}
}
private suspend fun PlayerController.seek(minus: Boolean) {
val state = state.value
val newPos = if (minus) {
(state.currentPositionMs - DEF_SEEK_TIME * 1_000)
.coerceAtLeast(0L)
} else {
(state.currentPositionMs + DEF_SEEK_TIME * 1_000)
.coerceAtMost(state.durationMs)
}
seekTo(newPos)
}
private fun Modifier.controlsBackground(): Modifier {
return background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
}
private fun formatDuration(millis: Long): String {
if (millis < 0) return "00:00:00"
val totalSeconds = millis / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
val h = hours.toString().padStart(2, '0')
val m = minutes.toString().padStart(2, '0')
val s = seconds.toString().padStart(2, '0')
return "$h:$m:$s"
}
private class FocusContainer(
val audioTrack: FocusRequester = FocusRequester(),
val lock: FocusRequester = FocusRequester(),
val play: FocusRequester = FocusRequester(),
val slider: FocusRequester = FocusRequester()
)
@Composable
private fun Modifier.focusableWithBorder(requester: FocusRequester): Modifier {
var isFocused by remember { mutableStateOf(false) }
return this.focusRequester(requester)
.onFocusChanged { isFocused = it.isFocused }
.border(
width = 2.dp,
color = if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
Color.Transparent
},
shape = RoundedCornerShape(24.dp)
)
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.videoplayer.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.VideoScaleType
@Composable
expect fun VideoCanvas(
controller: PlayerController,
modifier: Modifier,
scaleType: VideoScaleType = VideoScaleType.FIT,
onCanvasEvent: (CanvasEvent) -> Unit
)
@@ -0,0 +1,183 @@
package ru.shadowsparky.videoplayer.ui
import androiddev.libs.video_player.generated.resources.Res
import androiddev.libs.video_player.generated.resources.player_error_summary
import androiddev.libs.video_player.generated.resources.player_error_title
import androiddev.libs.video_player.generated.resources.player_retry
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.isTv
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.ControlsState
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.PlayerEvent
import ru.shadowsparky.videoplayer.PlayerState
@Composable
fun VideoPlayer(
controller: PlayerController,
modifier: Modifier = Modifier,
overlayContent: @Composable (PlayerState, ControlsState) -> Unit = { player, controls ->
DefaultControls(player, controls, controller = controller)
}
) {
val isTv = isTv()
val state by controller.state.collectAsState()
val scope = rememberCoroutineScope()
val controlsState by remember {
mutableStateOf(ControlsState(mutableStateOf(false)))
}
var areControlsVisible by remember { mutableStateOf(true) }
var lastErrorMessage by remember { mutableStateOf<String?>(null) }
var interactionCount by remember { mutableStateOf(0) }
LaunchedEffect(controller) {
controller.events.collect { event ->
when (event) {
is PlayerEvent.Error -> {
lastErrorMessage = event.message
areControlsVisible = true
}
is PlayerEvent.Ended -> {
areControlsVisible = true
}
is PlayerEvent.Idle, is PlayerEvent.Playing -> {
lastErrorMessage = null
areControlsVisible = true
}
}
}
}
LaunchedEffect(areControlsVisible, state.isPlaying, lastErrorMessage, interactionCount) {
if (areControlsVisible && state.isPlaying && lastErrorMessage == null) {
delay(5000)
areControlsVisible = false
}
}
val rootModifier = if (isTv) {
modifier.focusable(enabled = !areControlsVisible).onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
areControlsVisible = true
interactionCount++
}
false
}
} else {
modifier.clickable {
areControlsVisible = !areControlsVisible
}
}
Box(modifier = rootModifier.background(Color.Black)) {
VideoCanvas(
controller = controller,
modifier = rootModifier.fillMaxSize(),
onCanvasEvent = {
areControlsVisible = when (it) {
CanvasEvent.HideControls -> false
}
}
)
if (lastErrorMessage != null) {
PlaybackError(lastErrorMessage!!, scope, state, controller) {
lastErrorMessage = null
}
} else {
AnimatedVisibility(
visible = areControlsVisible || !state.isPlaying,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize()
) { overlayContent(state, controlsState) }
}
}
}
@Composable
private fun PlaybackError(
lastErrorMessage: String,
scope: CoroutineScope,
state: PlayerState,
controller: PlayerController,
onReset: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.8f))
.clickable(enabled = false) {},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(Res.string.player_error_title),
style = MaterialTheme.typography.titleSmall,
color = Color.White
)
Text(
text = stringResource(Res.string.player_error_summary, lastErrorMessage),
style = MaterialTheme.typography.bodyMedium,
color = Color.LightGray,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
onReset()
scope.launch {
val currentUrl = state.videoUrl
val currentPos = state.currentPositionMs
if (currentUrl != null) {
controller.load(currentUrl, currentPos)
controller.play()
}
}
},
) {
Text(stringResource(Res.string.player_retry))
}
}
}
}
@@ -0,0 +1,10 @@
package ru.shadowsparky.videoplayer
import org.koin.core.annotation.Factory
@Factory
class DesktopExternalPlayer : ExternalPlayer {
override fun play(url: String) {
throw NotImplementedError()
}
}
@@ -0,0 +1,17 @@
package ru.shadowsparky.videoplayer.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.VideoScaleType
@Composable
actual fun VideoCanvas(
controller: PlayerController,
modifier: Modifier,
scaleType: VideoScaleType,
onCanvasEvent: (CanvasEvent) -> Unit
) {
throw NotImplementedError()
}
@@ -0,0 +1,11 @@
package ru.shadowsparky.videoplayer
import kotlinx.browser.window
import org.koin.core.annotation.Factory
@Factory
class WasmExternalPlayer : ExternalPlayer {
override fun play(url: String) {
window.open(url, "_blank", "noreferrer")
}
}
@@ -0,0 +1,139 @@
package ru.shadowsparky.videoplayer
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.await
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
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 org.koin.core.annotation.Factory
import org.w3c.dom.HTMLVideoElement
@Factory
class WasmPlayerController : PlayerController {
internal val videoElement = (document.createElement("video") as HTMLVideoElement).apply {
style.apply {
setProperty("position", "absolute")
setProperty("left", "0")
setProperty("top", "0")
setProperty("width", "100%")
setProperty("height", "100%")
setProperty("background-color", "black")
}
setAttribute("controls", "")
setAttribute("preload", "auto")
}
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: StateFlow<List<AudioTrackInfo>> = MutableStateFlow(emptyList())
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
setupListeners()
}
private fun setupListeners() {
videoElement.onplay = { updateState() }
videoElement.onpause = { updateState() }
videoElement.ontimeupdate = { updateState() }
videoElement.onwaiting = {
_state.update { it.copy(isBuffering = true) }
}
videoElement.onplaying = {
_state.update { it.copy(isBuffering = false) }
}
videoElement.onended = {
updateState()
_events.trySend(PlayerEvent.Ended)
}
videoElement.addEventListener("error") {
val err = videoElement.error?.toString() ?: "Unknown HTML5 Video Error"
_events.trySend(PlayerEvent.Error(err))
}
videoElement.onloadedmetadata = {
updateState()
_state.update {
it.copy(
videoWidth = videoElement.videoWidth,
videoHeight = videoElement.videoHeight
)
}
}
}
override suspend fun load(url: String, startPositionMs: Long) {
println("load $url called")
videoElement.src = url
videoElement.load()
if (startPositionMs > 0) {
videoElement.currentTime = startPositionMs / 1000.0
}
_state.update { it.copy(videoUrl = url) }
_events.trySend(PlayerEvent.Playing)
println("load $url end")
}
@OptIn(ExperimentalWasmJsInterop::class)
override suspend fun play() {
try {
println("play called")
videoElement.play().await<JsAny?>()
println("play end")
} catch (e: Throwable) {
println("Play failed (autoplay policy?): $e. src: ${videoElement?.src?.toString()}")
}
}
override suspend fun pause() {
videoElement.pause()
}
override suspend fun seekTo(positionMs: Long) {
videoElement.currentTime = positionMs / 1000.0
updateState()
}
override suspend fun release() {
videoElement.pause()
videoElement.src = ""
videoElement.remove()
scope.cancel()
}
private fun updateState() {
_state.update { s ->
s.copy(
isPlaying = !videoElement.paused && !videoElement.ended,
isBuffering = false,
isEnded = videoElement.ended,
durationMs = (videoElement.duration * 1000).toLong().coerceAtLeast(0),
currentPositionMs = (videoElement.currentTime * 1000).toLong().coerceAtLeast(0),
bufferedPositionMs = (videoElement.currentTime * 1000).toLong(),
volume = videoElement.volume.toFloat()
)
}
}
override suspend fun selectAudioTrack(id: String) {
}
}
@@ -0,0 +1,78 @@
package ru.shadowsparky.videoplayer.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.VideoScaleType
import ru.shadowsparky.videoplayer.WasmPlayerController
private const val VIDEO_LAYER = "video-layer"
private const val ROOT_LAYER = "compose-root"
@Composable
actual fun VideoCanvas(
controller: PlayerController,
modifier: Modifier,
scaleType: VideoScaleType,
onCanvasEvent: (CanvasEvent) -> Unit
) {
val wasmController = controller as? WasmPlayerController ?: return
DisposableEffect(Unit) {
showVideoLayer(wasmController, scaleType)
onDispose {
hideVideoLayer(wasmController)
}
}
}
private fun showVideoLayer(
wasmController: WasmPlayerController,
scaleType: VideoScaleType
) {
setVideoLayer(true)
val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement
val video = wasmController.videoElement
videoLayer.appendChild(video)
video.style.apply {
val fit = when (scaleType) {
VideoScaleType.FIT -> "contain"
VideoScaleType.FULL -> "cover"
VideoScaleType.STRETCH -> "fill"
}
setProperty("object-fit", fit)
}
}
private fun hideVideoLayer(wasmController: WasmPlayerController) {
val video = wasmController.videoElement
video.pause()
video.removeAttribute("src")
video.load()
setVideoLayer(false)
}
private fun setVideoLayer(isVideoEnabled: Boolean) {
val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement
val composeRoot = document.getElementById(ROOT_LAYER) as HTMLElement
videoLayer.innerHTML = ""
videoLayer.setVisibility(isVideoEnabled)
composeRoot.setVisibility(!isVideoEnabled)
}
private fun HTMLElement.setVisibility(isVisible: Boolean) {
if (isVisible) {
style.setProperty("visibility", "visible")
style.removeProperty("pointer-events")
} else {
style.setProperty("visibility", "hidden")
style.setProperty("pointer-events", "none")
}
}