add chat basics
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
+10
@@ -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
|
||||
+97
@@ -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)
|
||||
}
|
||||
+58
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+35
@@ -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. На нерелевантные темы не общайся, переводи разговор на кино в свиной тематике."
|
||||
}
|
||||
}
|
||||
@@ -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: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)
|
||||
}
|
||||
@@ -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.chat.common" }
|
||||
}
|
||||
+9
@@ -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)
|
||||
}
|
||||
+7
@@ -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>
|
||||
+140
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+78
@@ -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()
|
||||
}
|
||||
}
|
||||
+29
@@ -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>
|
||||
+10
@@ -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
|
||||
+15
@@ -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
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.updater.domain
|
||||
|
||||
interface UpdateInstaller {
|
||||
suspend fun hasInstallPermission(): Boolean
|
||||
suspend fun requestInstallPermission()
|
||||
suspend fun install(path: String)
|
||||
}
|
||||
+96
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
+111
@@ -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)
|
||||
}
|
||||
}
|
||||
+321
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/bin
|
||||
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.kmp)
|
||||
alias(libs.plugins.convention.android.lib)
|
||||
alias(libs.plugins.convention.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
alias(libs.plugins.convention.compose)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(project(":libs:ui"))
|
||||
}
|
||||
}
|
||||
val androidMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
}
|
||||
}
|
||||
}
|
||||
android { namespace = "ru.shadowsparky.videoplayer" }
|
||||
}
|
||||
+215
@@ -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
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class AndroidExternalPlayer(private val app: Application) : ExternalPlayer {
|
||||
|
||||
override fun play(url: String) {
|
||||
if (!launchInternal(url, true)) {
|
||||
if (!launchInternal(url, false)) {
|
||||
error("Unable to launch video")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchInternal(uri: String, withType: Boolean): Boolean {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (withType) {
|
||||
intent.setDataAndType(uri.toUri(), "video/*")
|
||||
} else {
|
||||
intent.data = uri.toUri()
|
||||
}
|
||||
try {
|
||||
app.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package ru.shadowsparky.videoplayer.ui
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.util.Consumer
|
||||
|
||||
internal fun Context.updatePipAutoEnter(playerView: View, enabled: Boolean) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
|
||||
val w = if (playerView.width > 0) playerView.width else 16
|
||||
val h = if (playerView.height > 0) playerView.height else 9
|
||||
|
||||
val rect = Rect()
|
||||
playerView.getGlobalVisibleRect(rect)
|
||||
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(w, h))
|
||||
.setSourceRectHint(rect)
|
||||
.setAutoEnterEnabled(enabled)
|
||||
.build()
|
||||
|
||||
findActivity()?.setPictureInPictureParams(params)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberPipMode(): State<Boolean> {
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx.findActivity() as? ComponentActivity
|
||||
val state = remember { mutableStateOf(activity?.isInPictureInPictureMode == true) }
|
||||
|
||||
DisposableEffect(activity) {
|
||||
if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return@DisposableEffect onDispose { }
|
||||
}
|
||||
|
||||
val cb = Consumer<PictureInPictureModeChangedInfo> { info ->
|
||||
state.value = info.isInPictureInPictureMode
|
||||
}
|
||||
|
||||
activity.addOnPictureInPictureModeChangedListener(cb)
|
||||
onDispose { activity.removeOnPictureInPictureModeChangedListener(cb) }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package ru.shadowsparky.videoplayer.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import ru.shadowsparky.videoplayer.AndroidPlayerController
|
||||
import ru.shadowsparky.videoplayer.CanvasEvent
|
||||
import ru.shadowsparky.videoplayer.PlayerController
|
||||
import ru.shadowsparky.videoplayer.VideoScaleType
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
actual fun VideoCanvas(
|
||||
controller: PlayerController,
|
||||
modifier: Modifier,
|
||||
scaleType: VideoScaleType,
|
||||
onCanvasEvent: (CanvasEvent) -> Unit
|
||||
) {
|
||||
val androidController = controller as? AndroidPlayerController
|
||||
?: throw IllegalArgumentException("Controller must be AndroidPlayerController")
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val pipMode by rememberPipMode()
|
||||
|
||||
LaunchedEffect(pipMode) {
|
||||
if (pipMode) onCanvasEvent(CanvasEvent.HideControls)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
context.enterFullscreen()
|
||||
onDispose {
|
||||
context.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
useController = false
|
||||
|
||||
resizeMode = when (scaleType) {
|
||||
VideoScaleType.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
VideoScaleType.FULL -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
VideoScaleType.STRETCH -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
}
|
||||
|
||||
keepScreenOn = true
|
||||
context.updatePipAutoEnter(this, true)
|
||||
}
|
||||
},
|
||||
update = { playerView ->
|
||||
if (playerView.player != androidController.getPlayerInstance()) {
|
||||
playerView.player = androidController.getPlayerInstance()
|
||||
}
|
||||
},
|
||||
onRelease = { playerView ->
|
||||
context.updatePipAutoEnter(playerView, false)
|
||||
playerView.player = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Context.findActivity(): Activity? {
|
||||
var ctx = this
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Context.enterFullscreen() {
|
||||
val activity = findActivity() ?: return
|
||||
val window = activity.window ?: return
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
|
||||
private fun Context.exitFullscreen() {
|
||||
val activity = findActivity() ?: return
|
||||
val window = activity.window ?: return
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M240,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M400,840Q334,840 287,793Q240,746 240,680Q240,614 287,567Q334,520 400,520Q423,520 442.5,525.5Q462,531 480,542L480,120L720,120L720,280L560,280L560,680Q560,746 513,793Q466,840 400,840Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M480,880Q405,880 339.5,851.5Q274,823 225.5,774.5Q177,726 148.5,660.5Q120,595 120,520L200,520Q200,637 281.5,718.5Q363,800 480,800Q597,800 678.5,718.5Q760,637 760,520Q760,403 678.5,321.5Q597,240 480,240L474,240L536,302L480,360L320,200L480,40L536,98L474,160L480,160Q555,160 620.5,188.5Q686,217 734.5,265.5Q783,314 811.5,379.5Q840,445 840,520Q840,595 811.5,660.5Q783,726 734.5,774.5Q686,823 620.5,851.5Q555,880 480,880Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<string name="player_error_title">Ошибка плеера</string>
|
||||
<string name="player_error_summary">Во время проигрывания видео произошла ошибка: %1$s</string>
|
||||
<string name="player_retry">Повторить</string>
|
||||
<string name="player_audio_track">Аудио %1$d</string>
|
||||
</resources>
|
||||
+9
@@ -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()
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
|
||||
class ControlsState(
|
||||
val isLocked: MutableState<Boolean>
|
||||
)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
interface ExternalPlayer {
|
||||
fun play(url: String)
|
||||
}
|
||||
+19
@@ -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
|
||||
)
|
||||
+10
@@ -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
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
enum class VideoScaleType {
|
||||
FIT,
|
||||
FULL,
|
||||
STRETCH
|
||||
}
|
||||
+403
@@ -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)
|
||||
)
|
||||
}
|
||||
+15
@@ -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
|
||||
)
|
||||
+183
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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()
|
||||
}
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
+11
@@ -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")
|
||||
}
|
||||
}
|
||||
+139
@@ -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) {
|
||||
}
|
||||
}
|
||||
+78
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user