init projects

This commit is contained in:
2026-06-17 18:58:41 +03:00
parent 8543c4e241
commit 21a768edd5
505 changed files with 19998 additions and 93 deletions
+1
View File
@@ -0,0 +1 @@
build
+51
View File
@@ -0,0 +1,51 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.ktor.client)
alias(libs.plugins.convention.compose)
alias(libs.plugins.convention.serialization)
alias(libs.plugins.convention.sqldelight)
alias(libs.plugins.convention.koin)
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("ru.shadowsparky.vbox.app")
generateAsync.set(true)
}
}
}
kotlin {
sourceSets {
commonMain.dependencies {
api(project(":apps:vbox:common"))
implementation(libs.coil.compose)
implementation(project(":libs:base"))
implementation(project(":libs:ui"))
implementation(project(":libs:http-client"))
implementation(project(":libs:video-player"))
}
androidMain.dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.sqlite.framework)
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val wasmJsMain by getting {
dependencies {
implementation(libs.sqldelight.web.driver)
implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.1.0"))
implementation(npm("sql.js", "1.10.3")) // или актуальную 1.x
implementation(devNpm("copy-webpack-plugin", "11.0.0"))
}
}
}
android {
namespace = "ru.shadowsparky.vbox.shared"
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
@@ -0,0 +1,388 @@
package app.cash.sqldelight.driver.android
import android.content.Context
import android.database.AbstractWindowedCursor
import android.database.Cursor
import android.database.CursorWindow
import android.os.Build
import android.util.LruCache
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import androidx.sqlite.db.SupportSQLiteProgram
import androidx.sqlite.db.SupportSQLiteQuery
import androidx.sqlite.db.SupportSQLiteStatement
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import app.cash.sqldelight.Query
import app.cash.sqldelight.Transacter
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlCursor
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlPreparedStatement
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.Api28Impl.setWindowSize
import kotlinx.coroutines.runBlocking
private const val DEFAULT_CACHE_SIZE = 20
// workaround
// https://github.com/cashapp/sqldelight/issues/5058
class AndroidSqliteDriver private constructor(
private val openHelper: SupportSQLiteOpenHelper? = null,
database: SupportSQLiteDatabase? = null,
private val cacheSize: Int,
private val windowSizeBytes: Long? = null,
) : SqlDriver {
init {
require((openHelper != null) xor (database != null))
}
private val transactions = ThreadLocal<Transacter.Transaction>()
private val database by lazy {
openHelper?.writableDatabase ?: database!!
}
constructor(
openHelper: SupportSQLiteOpenHelper,
) : this(
openHelper = openHelper,
database = null,
cacheSize = DEFAULT_CACHE_SIZE,
windowSizeBytes = null
)
/**
* @param [cacheSize] The number of compiled sqlite statements to keep in memory per connection.
* Defaults to 20.
* @param [useNoBackupDirectory] Sets whether to use a no backup directory or not.
* @param [windowSizeBytes] Size of cursor window in bytes, per [CursorWindow] (Android 28+ only), or null to use the default.
*/
@JvmOverloads
constructor(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
context: Context,
name: String? = null,
factory: SupportSQLiteOpenHelper.Factory = FrameworkSQLiteOpenHelperFactory(),
callback: SupportSQLiteOpenHelper.Callback = Callback(schema),
cacheSize: Int = DEFAULT_CACHE_SIZE,
useNoBackupDirectory: Boolean = false,
windowSizeBytes: Long? = null,
) : this(
database = null,
openHelper = factory.create(
SupportSQLiteOpenHelper.Configuration.builder(context)
.callback(callback)
.name(name)
.noBackupDirectory(useNoBackupDirectory)
.build(),
),
cacheSize = cacheSize,
windowSizeBytes = windowSizeBytes,
)
@JvmOverloads
constructor(
database: SupportSQLiteDatabase,
cacheSize: Int = DEFAULT_CACHE_SIZE,
windowSizeBytes: Long? = null,
) : this(
openHelper = null,
database = database,
cacheSize = cacheSize,
windowSizeBytes = windowSizeBytes
)
private val statements = object : LruCache<Int, AndroidStatement>(cacheSize) {
override fun entryRemoved(
evicted: Boolean,
key: Int,
oldValue: AndroidStatement,
newValue: AndroidStatement?,
) {
if (evicted) oldValue.close()
}
}
private val listeners = linkedMapOf<String, MutableSet<Query.Listener>>()
override fun addListener(vararg queryKeys: String, listener: Query.Listener) {
synchronized(listeners) {
queryKeys.forEach {
listeners.getOrPut(it, { linkedSetOf() }).add(listener)
}
}
}
override fun removeListener(vararg queryKeys: String, listener: Query.Listener) {
synchronized(listeners) {
queryKeys.forEach {
listeners[it]?.remove(listener)
}
}
}
override fun notifyListeners(vararg queryKeys: String) {
val listenersToNotify = linkedSetOf<Query.Listener>()
synchronized(listeners) {
queryKeys.forEach { listeners[it]?.let(listenersToNotify::addAll) }
}
listenersToNotify.forEach(Query.Listener::queryResultsChanged)
}
override fun newTransaction(): QueryResult<Transacter.Transaction> {
val enclosing = transactions.get()
val transaction = Transaction(enclosing)
transactions.set(transaction)
if (enclosing == null) {
database.beginTransactionNonExclusive()
}
return QueryResult.Value(transaction)
}
override fun currentTransaction(): Transacter.Transaction? = transactions.get()
inner class Transaction(
override val enclosingTransaction: Transacter.Transaction?,
) : Transacter.Transaction() {
override fun endTransaction(successful: Boolean): QueryResult<Unit> {
if (enclosingTransaction == null) {
if (successful) {
database.setTransactionSuccessful()
database.endTransaction()
} else {
database.endTransaction()
}
}
transactions.set(enclosingTransaction)
return QueryResult.Unit
}
}
private fun <T> execute(
identifier: Int?,
createStatement: () -> AndroidStatement,
binders: (SqlPreparedStatement.() -> Unit)?,
result: AndroidStatement.() -> T,
): QueryResult.Value<T> {
var statement: AndroidStatement? = null
if (identifier != null) {
statement = statements.remove(identifier)
}
if (statement == null) {
statement = createStatement()
}
try {
if (binders != null) {
statement.binders()
}
return QueryResult.Value(statement.result())
} finally {
if (identifier != null) {
statements.put(identifier, statement)?.close()
} else {
statement.close()
}
}
}
override fun execute(
identifier: Int?,
sql: String,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
): QueryResult<Long> = execute(
identifier,
{ AndroidPreparedStatement(database.compileStatement(sql)) },
binders,
{ execute() })
override fun <R> executeQuery(
identifier: Int?,
sql: String,
mapper: (SqlCursor) -> QueryResult<R>,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
) = execute(
identifier,
{ AndroidQuery(sql, database, parameters, windowSizeBytes) },
binders
) { executeQuery(mapper) }
override fun close() {
statements.evictAll()
return openHelper?.close() ?: database.close()
}
open class Callback(
private val schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
private vararg val callbacks: AfterVersion,
) : SupportSQLiteOpenHelper.Callback(
if (schema.version > Int.MAX_VALUE) error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") else schema.version.toInt(),
) {
override fun onCreate(db: SupportSQLiteDatabase) {
runBlocking {
schema.create(AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1))
.await()
}
}
override fun onUpgrade(
db: SupportSQLiteDatabase,
oldVersion: Int,
newVersion: Int,
) {
runBlocking {
schema.migrate(
AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1),
oldVersion.toLong(),
newVersion.toLong(),
*callbacks,
).await()
}
}
}
}
internal interface AndroidStatement : SqlPreparedStatement {
fun execute(): Long
fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R
fun close()
}
private class AndroidPreparedStatement(
private val statement: SupportSQLiteStatement,
) : AndroidStatement {
override fun bindBytes(index: Int, bytes: ByteArray?) {
if (bytes == null) statement.bindNull(index + 1) else statement.bindBlob(index + 1, bytes)
}
override fun bindLong(index: Int, long: Long?) {
if (long == null) statement.bindNull(index + 1) else statement.bindLong(index + 1, long)
}
override fun bindDouble(index: Int, double: Double?) {
if (double == null) statement.bindNull(index + 1) else statement.bindDouble(
index + 1,
double
)
}
override fun bindString(index: Int, string: String?) {
if (string == null) statement.bindNull(index + 1) else statement.bindString(
index + 1,
string
)
}
override fun bindBoolean(index: Int, boolean: Boolean?) {
if (boolean == null) {
statement.bindNull(index + 1)
} else {
statement.bindLong(index + 1, if (boolean) 1L else 0L)
}
}
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R =
throw UnsupportedOperationException()
override fun execute(): Long {
return statement.executeUpdateDelete().toLong()
}
override fun close() {
statement.close()
}
}
private class AndroidQuery(
override val sql: String,
private val database: SupportSQLiteDatabase,
override val argCount: Int,
private val windowSizeBytes: Long?,
) : SupportSQLiteQuery, AndroidStatement {
private val binds = MutableList<((SupportSQLiteProgram) -> Unit)?>(argCount) { null }
override fun bindBytes(index: Int, bytes: ByteArray?) {
binds[index] =
{ if (bytes == null) it.bindNull(index + 1) else it.bindBlob(index + 1, bytes) }
}
override fun bindLong(index: Int, long: Long?) {
binds[index] =
{ if (long == null) it.bindNull(index + 1) else it.bindLong(index + 1, long) }
}
override fun bindDouble(index: Int, double: Double?) {
binds[index] =
{ if (double == null) it.bindNull(index + 1) else it.bindDouble(index + 1, double) }
}
override fun bindString(index: Int, string: String?) {
binds[index] =
{ if (string == null) it.bindNull(index + 1) else it.bindString(index + 1, string) }
}
override fun bindBoolean(index: Int, boolean: Boolean?) {
binds[index] = {
if (boolean == null) {
it.bindNull(index + 1)
} else {
it.bindLong(index + 1, if (boolean) 1L else 0L)
}
}
}
override fun execute() = throw UnsupportedOperationException()
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R {
return database.query(this)
.use { cursor -> mapper(AndroidCursor(cursor, windowSizeBytes)).value }
}
override fun bindTo(statement: SupportSQLiteProgram) {
for (action in binds) {
action!!(statement)
}
}
override fun toString() = sql
override fun close() {}
}
private class AndroidCursor(
private val cursor: Cursor,
windowSizeBytes: Long?,
) : SqlCursor {
init {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
windowSizeBytes != null &&
cursor is AbstractWindowedCursor
) {
cursor.setWindowSize(windowSizeBytes)
}
}
override fun next(): QueryResult.Value<Boolean> = QueryResult.Value(cursor.moveToNext())
override fun getString(index: Int) = if (cursor.isNull(index)) null else cursor.getString(index)
override fun getLong(index: Int) = if (cursor.isNull(index)) null else cursor.getLong(index)
override fun getBytes(index: Int) = if (cursor.isNull(index)) null else cursor.getBlob(index)
override fun getDouble(index: Int) = if (cursor.isNull(index)) null else cursor.getDouble(index)
override fun getBoolean(index: Int) =
if (cursor.isNull(index)) null else cursor.getLong(index) == 1L
}
@RequiresApi(Build.VERSION_CODES.P)
private object Api28Impl {
@JvmStatic
@DoNotInline
fun AbstractWindowedCursor.setWindowSize(windowSizeBytes: Long) {
window = CursorWindow(null, windowSizeBytes)
}
}
@@ -0,0 +1,134 @@
package ru.shadowsparky.vbox.shared.data
import app.cash.sqldelight.Query
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOneOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.domain.OfflineVideoQueries
import ru.shadowsparky.vbox.shared.domain.OfflineState
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
typealias SqlOfflineVideo = ru.shadowsparky.vbox.domain.OfflineVideo
@Factory
class AndroidOfflineStorage(
private val dbProvider: AppDatabaseProvider,
private val downloader: OfflineDownloader
) : OfflineStorage {
private suspend fun getQueries(): OfflineVideoQueries {
return dbProvider.get().offlineVideoQueries
}
override val videos: Flow<List<OfflineVideo>> = flow {
emitAll(getQueries().selectAll().toOfflineVideo())
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun Query<SqlOfflineVideo>.toOfflineVideo(): Flow<List<OfflineVideo>> {
return asFlow().mapToList(Dispatchers.IO)
.flatMapLatest { items: List<SqlOfflineVideo> ->
combine(
items.map { item ->
downloader.query(item.download_id)
.mapNotNull { state ->
if (state == null) remove(item.id)
item.toDomain(state)
}
}
) { it.toList() }.onStart { emit(emptyList()) }
}.flowOn(Dispatchers.IO)
}
override suspend fun query(id: Long): Flow<OfflineVideo?> =
withContext(Dispatchers.IO) {
getQueries()
.selectById(id)
.asFlow()
.mapToOneOrNull(Dispatchers.IO)
.map { it?.toDomain() }
}
private suspend fun queryRaw(id: Long): SqlOfflineVideo? =
withContext(Dispatchers.IO) {
getQueries()
.selectById(id)
.executeAsOneOrNull()
}
override fun queryByArgs(movieId: Long, seasonId: Long?): Flow<List<OfflineVideo>> = flow {
emitAll(
getQueries()
.selectByArgs(
movie_id = movieId,
season_id = seasonId
).toOfflineVideo()
)
}.flowOn(Dispatchers.IO)
override suspend fun find(movieId: Long, seasonId: Long?, episodeId: Long?): OfflineVideo? {
return withContext(Dispatchers.IO) {
getQueries().selectByFullArgs(movieId, seasonId, episodeId)
.executeAsOneOrNull()
?.toDomain()
}
}
override suspend fun add(item: OfflineVideo, uri: String) = withContext(Dispatchers.IO) {
val dmId = downloader.enqueue(uri)
upsertWithDm(item, dmId)
}
private suspend fun upsertWithDm(item: OfflineVideo, dmId: Long?) {
withContext(Dispatchers.IO) {
getQueries().upsert(
movie_id = item.movieId,
season_id = item.seasonId,
episode_id = item.episodeId,
id = item.id,
download_id = dmId
)
}
}
override suspend fun remove(item: OfflineVideo) {
val id = item.id ?: return
remove(id)
}
private suspend fun remove(id: Long) {
withContext(Dispatchers.IO) {
queryRaw(id)?.download_id?.let { downloader.delete(it) }
getQueries().deleteById(id)
}
}
private suspend fun SqlOfflineVideo.toDomain(state: OfflineState? = null): OfflineVideo? {
val state = state ?: run {
download_id?.let { downloader.query(it).firstOrNull() }
} ?: return null
return OfflineVideo(
id = id,
movieId = movie_id,
seasonId = season_id,
episodeId = episode_id,
state = state
)
}
}
@@ -0,0 +1,127 @@
package ru.shadowsparky.vbox.shared.data
import android.app.DownloadManager
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Environment
import android.os.Handler
import android.os.Looper
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.OfflineState
import java.util.UUID
@Factory
class OfflineDownloader(private val context: Context) {
private val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
suspend fun enqueue(url: String): Long = withContext(Dispatchers.IO) {
val request = DownloadManager.Request(url.toUri())
.setDestinationInExternalFilesDir(
context,
Environment.DIRECTORY_DOWNLOADS,
"/Videobox/${UUID.randomUUID()}.mp4"
)
dm.enqueue(request)
}
suspend fun delete(dmId: Long) = withContext(Dispatchers.IO) {
dm.remove(dmId)
}
fun query(dmId: Long?): Flow<OfflineState?> {
dmId ?: return flowOf(null)
return callbackFlow {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
trySend(Unit)
}
}
trySend(Unit)
context.contentResolver.registerContentObserver(getUri(dmId), false, observer)
awaitClose {
context.contentResolver.unregisterContentObserver(observer)
}
}.map { obtainState(dmId) }.flowOn(Dispatchers.IO)
}
private fun getUri(dmId: Long): Uri {
return "content://downloads/my_downloads/$dmId".toUri()
}
private fun obtainState(dmId: Long): OfflineState? {
val query = DownloadManager.Query().setFilterById(dmId)
dm.query(query).use { cursor ->
if (!cursor.moveToFirst()) return null
val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
return when (status) {
DownloadManager.STATUS_PENDING -> OfflineState.Pending
DownloadManager.STATUS_RUNNING -> obtainRunning(cursor)
DownloadManager.STATUS_PAUSED -> obtainPaused(cursor)
DownloadManager.STATUS_SUCCESSFUL -> {
val uri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)
) ?: return OfflineState.Error("Uri not found")
runCatching {
context.contentResolver.openFileDescriptor(uri.toUri(), "r").use {
return OfflineState.Available(uri)
}
}.getOrDefault(OfflineState.Error("File not found"))
}
DownloadManager.STATUS_FAILED -> {
val reason = cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)
)
OfflineState.Error("Download failed: reason=$reason")
}
else -> OfflineState.Error("Unknown status")
}
}
}
private fun obtainPaused(cursor: Cursor): OfflineState.Downloading {
val bytesDownloaded = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
)
val bytesTotal = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
)
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
} else 0
return OfflineState.Downloading(
isPaused = true,
progress = progress
)
}
private fun obtainRunning(cursor: Cursor): OfflineState.Downloading {
val bytesDownloaded = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
)
val bytesTotal = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
)
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
} else 0
return OfflineState.Downloading(
isPaused = false,
progress = progress
)
}
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.vbox.shared.di.factory
import android.app.Application
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.app.AppDatabase
@Factory
class AndroidDatabaseDriverFactory(private val app: Application) : DatabaseDriverFactory {
override suspend fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, app, "videobox.db")
}
}
@@ -0,0 +1,49 @@
package ru.shadowsparky.vbox.shared.di.factory
import android.app.Application
import com.seiko.imageloader.Bitmap
import com.seiko.imageloader.ImageLoader
import com.seiko.imageloader.cache.memory.MemoryCacheBuilder
import com.seiko.imageloader.cache.memory.MemoryKey
import com.seiko.imageloader.cache.memory.maxSizePercent
import com.seiko.imageloader.component.setupDefaultComponents
import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig
import com.seiko.imageloader.option.androidContext
import com.seiko.imageloader.size
import com.seiko.imageloader.util.identityHashCode
import io.ktor.client.HttpClient
import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Factory
@Factory
class AndroidImageLoaderProvider(
private val app: Application,
private val httpClient: HttpClient
) : ImageLoaderProvider {
override fun provide(): ImageLoader {
return ImageLoader {
options {
androidContext(app)
}
components {
setupDefaultComponents { httpClient }
}
interceptor {
// Set the max size to 25% of the app's available memory.
bitmapMemoryCacheConfig(
valueHashProvider = { identityHashCode(it) },
valueSizeProvider = { it.size },
block = fun MemoryCacheBuilder<MemoryKey, Bitmap>.() {
// Set the max size to 25% of the app's available memory.
maxSizePercent(app, 0.25)
}
)
diskCacheConfig {
directory(app.cacheDir.resolve("image_cache").toOkioPath())
maxSizeBytes(512L * 1024 * 1024) // 512MB
}
}
}
}
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.StateFlow
interface ActivityInfo {
val isActiveFlow: StateFlow<Boolean>
}
@@ -0,0 +1,5 @@
package ru.shadowsparky.vbox.shared.domain
actual fun getCurrentOsType(): OsType {
return OsType.ANDROID
}
@@ -0,0 +1,22 @@
package ru.shadowsparky.vbox.shared.presentation
import android.app.Application
import android.content.Intent
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
// adb shell am start -W -a android.intent.action.VIEW -d "https://box.shadowsparky.ru/deep-link/overview/191" ru.shadowsparky.videobox.multiplatform
@Factory
class AndroidShareManager(private val app: Application) : ShareManager {
override fun shareDetails(details: VideoDetails) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://box.shadowsparky.ru/overview/${details.id}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
app.startActivity(intent)
}
override fun canShare(): Boolean = true
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.vbox.shared.presentation
import android.app.Application
import android.content.Intent
import android.widget.Toast
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.cant_open_video_viewer
import androidx.core.net.toUri
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Factory
@Factory
class AndroidVideoLauncher(
private val app: Application
) : VideoLauncher {
override suspend fun launch(uri: String) {
if (!launchInternal(uri, true)) {
if (!launchInternal(uri, false)) {
Toast.makeText(
app,
getString(Res.string.cant_open_video_viewer),
Toast.LENGTH_LONG
).show()
}
}
}
private fun launchInternal(uri: String, withType: Boolean): Boolean {
val intent = Intent(Intent.ACTION_VIEW)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (withType) {
intent.setDataAndType(uri.toUri(), "video/*")
} else {
intent.data = uri.toUri()
}
try {
app.startActivity(intent)
} catch (_: Exception) {
return false
}
return true
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.vbox.shared.presentation.search
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
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.onKeyEvent
import androidx.compose.ui.input.key.type
actual fun Modifier.requestFocusOnDownEvent(focusRequester: FocusRequester): Modifier {
return onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
if (it.key == Key.DirectionDown) {
focusRequester.requestFocus()
}
}
true
}
}
@@ -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="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
</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="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" />
</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="M791,904L567,680L280,680L280,393L56,169L112,112L848,848L791,904ZM360,600L487,600L360,473L360,600ZM680,567L600,487L600,360L473,360L393,280L680,280L680,567ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z" />
</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="M382,640L155,413L212,356L382,526L748,160L805,217L382,640ZM200,800L200,720L760,720L760,800L200,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="M607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640Q555,640 607.5,587.5ZM403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568Q435,568 403.5,536.5ZM214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760Q334,760 214,678.5ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Q593,680 687.5,620.5Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#fff"
android:pathData="M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
</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="M434,663L660,436L604,380L434,550L349,465L292,522L434,663ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M700,760L740,760L740,660L840,660L840,620L740,620L740,520L700,520L700,620L600,620L600,660L700,660L700,760ZM720,840Q637,840 578.5,781.5Q520,723 520,640Q520,557 578.5,498.5Q637,440 720,440Q803,440 861.5,498.5Q920,557 920,640Q920,723 861.5,781.5Q803,840 720,840ZM160,760L160,280L480,40L800,280L800,372Q781,366 761,363Q741,360 720,360L720,320L480,140L240,320L240,680L443,680Q446,701 452,721Q458,741 467,760L160,760ZM480,410L480,410L480,410L480,410L480,410Q480,410 480,410Q480,410 480,410Q480,410 480,410Q480,410 480,410Z" />
</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,616L240,376L296,320L480,504L664,320L720,376L480,616Z" />
</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,432L296,616L240,560L480,320L720,560L664,616L480,432Z" />
</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,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z" />
</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="M322.5,522.5Q340,505 340,480Q340,455 322.5,437.5Q305,420 280,420Q255,420 237.5,437.5Q220,455 220,480Q220,505 237.5,522.5Q255,540 280,540Q305,540 322.5,522.5ZM522.5,522.5Q540,505 540,480Q540,455 522.5,437.5Q505,420 480,420Q455,420 437.5,437.5Q420,455 420,480Q420,505 437.5,522.5Q455,540 480,540Q505,540 522.5,522.5ZM722.5,522.5Q740,505 740,480Q740,455 722.5,437.5Q705,420 680,420Q655,420 637.5,437.5Q620,455 620,480Q620,505 637.5,522.5Q655,540 680,540Q705,540 722.5,522.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</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="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280L840,492Q821,484 800.5,481.5Q780,479 760,482L760,313L647,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L440,760L440,764L440,840L200,840ZM200,200L200,313L200,482Q200,485 200,494.5Q200,504 200,519L200,760L200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200ZM520,920L520,797L741,577Q750,568 761,564Q772,560 783,560Q795,560 806,564.5Q817,569 826,578L863,615Q871,624 875.5,635Q880,646 880,657Q880,668 876,679.5Q872,691 863,700L643,920L520,920ZM820,657L820,657L783,620L783,620L820,657ZM580,860L618,860L739,738L721,719L702,701L580,822L580,860ZM721,719L702,701L702,701L739,738L739,738L721,719ZM240,400L600,400L600,240L240,240L240,400ZM480,720Q481,720 482,720Q483,720 484,720L600,605Q600,603 600,602.5Q600,602 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M80,820L80,500L400,500L400,820L80,820ZM160,740L320,740L320,580L160,580L160,740ZM220,400L440,40L660,400L220,400ZM362,320L518,320L440,194L362,320ZM863,918L757,812Q736,826 711.5,833Q687,840 660,840Q585,840 532.5,787.5Q480,735 480,660Q480,585 532.5,532.5Q585,480 660,480Q735,480 787.5,532.5Q840,585 840,660Q840,686 833,710.5Q826,735 813,756L919,862L863,918ZM660,760Q702,760 731,731Q760,702 760,660Q760,618 731,589Q702,560 660,560Q618,560 589,589Q560,618 560,660Q560,702 589,731Q618,760 660,760ZM320,580L320,580L320,580L320,580ZM440,320L440,320L440,320Z" />
</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="M280,680L280,280L680,280L680,680L280,680ZM360,600L600,600L600,360L360,360L360,600ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,840L760,760L840,760Q840,793 816.5,816.5Q793,840 760,840ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M680,680Q705,680 722.5,662.5Q740,645 740,620Q740,595 722.5,577.5Q705,560 680,560Q655,560 637.5,577.5Q620,595 620,620Q620,645 637.5,662.5Q655,680 680,680ZM680,800Q711,800 737,785.5Q763,771 779,747Q757,734 732,727Q707,720 680,720Q653,720 628,727Q603,734 581,747Q597,771 623,785.5Q649,800 680,800ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,427Q781,419 761,412.5Q741,406 720,403L720,256L480,166L240,256L240,444Q240,491 252.5,538Q265,585 287.5,627.5Q310,670 342,706Q374,742 413,766L413,766Q424,798 442,827Q460,856 483,879Q482,879 481.5,879.5Q481,880 480,880ZM680,880Q597,880 538.5,821.5Q480,763 480,680Q480,597 538.5,538.5Q597,480 680,480Q763,480 821.5,538.5Q880,597 880,680Q880,763 821.5,821.5Q763,880 680,880ZM480,466L480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466L480,466L480,466L480,466L480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#fff"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
</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="M200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L520,120Q520,143 520,160Q520,177 520,200L280,200Q280,200 280,200Q280,200 280,200L280,718L480,632L680,718L680,440Q703,440 720,440Q737,440 760,440L760,840L480,720L200,840ZM280,200L280,200Q280,200 280,200Q280,200 280,200L520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200L520,200L480,200L280,200ZM680,360L680,280L600,280L600,200L680,200L680,120L760,120L760,200L840,200L840,280L760,280L760,360L680,360Z" />
</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="M713,360L600,247L656,190L713,247L854,105L911,162L713,360ZM200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L520,120Q520,143 520,160Q520,177 520,200L280,200Q280,200 280,200Q280,200 280,200L280,718L480,632L680,718L680,440Q703,440 720,440Q737,440 760,440L760,840L480,720L200,840ZM280,200L280,200Q280,200 280,200Q280,200 280,200L520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200L520,200L480,200L280,200Z" />
</vector>
@@ -0,0 +1,70 @@
<resources>
<string name="app_name">Videobox</string>
<string name="recommendations">Главная</string>
<string name="retry">Повторить</string>
<string name="recent">Недавнее</string>
<string name="details_serial">Все детали о сериале</string>
<string name="details_movie">Все детали о фильме</string>
<string name="desc">Описание</string>
<string name="watch">Смотреть</string>
<string name="seasons_overview">Доступные сезоны</string>
<string name="select_season">Выберите сезон</string>
<string name="season">Cезон</string>
<string name="episodes_overview">Доступные серии</string>
<string name="chose_episode">Выберите серию</string>
<string name="episode">Серия</string>
<string name="okay">Понятно</string>
<string name="error_title">Ошибка!</string>
<string name="unknown_error">Неизвестная ошибка</string>
<string name="timeout_error">Превышено время ожидания ответа от сервера</string>
<string name="server_not_responding">Сервер не отвечает</string>
<string name="unknown_host">Нет доступа к серверу. Проверьте подключение к интернету</string>
<string name="cant_open_video_viewer">Не найдено приложение для проигрывания видео. Пожалуйста, поставьте видео плеер</string>
<string name="not_found">Ничего не найдено по вашему запросу</string>
<string name="share">Поделиться</string>
<string name="settings">Настройки</string>
<string name="server_settings">Настройка сервера</string>
<string name="custom_serv_addr">Адрес сервера</string>
<string name="edit_custom_serv_addr">Изменение адреса сервера</string>
<string name="account">Аккаунт</string>
<string name="account_auth">Авторизация</string>
<string name="account_reg">Регистрация</string>
<string name="account_logout_auth">Выйти из аккаунта</string>
<string name="account_login">Логин</string>
<string name="account_password">Пароль</string>
<string name="account_reg_hint">У меня нет аккаунта</string>
<string name="empty_login">Логин не может быть пустым</string>
<string name="empty_pass">Пароль не может быть пустым</string>
<string name="theme_currently_active">Тема активна на данный момент</string>
<string name="saved_movies">Сохранённые</string>
<string name="saved_movies_not_found">Вы ничего не сохраняли. Может исправите это недоразумение?</string>
<string name="url_copied">Ссылка скопирована</string>
<string name="copy">Скопировать</string>
<string name="search">Поиск</string>
<string name="search_empty_now">Тут пока ничего нет</string>
<string name="edit_password">Изменить пароль</string>
<string name="old_password">Текущий пароль</string>
<string name="new_password">Новый пароль</string>
<string name="password_changed">Пароль был успешно изменён</string>
<string name="external_player_title">Использовать внешний плеер</string>
<string name="next_episode">Следующий эпизод</string>
<string name="start_from">Продолжить с %1$s</string>
<string name="offline_save">Сохранить видео</string>
<string name="offline_video">Сохранённые видео</string>
<string name="offline_empty">Нет сохраненных видео</string>
<string name="offline_remove">Удалить видео</string>
<string name="offline_state_pending">Ожидает загрузки</string>
<string name="offline_state_downloading">Загрузка: %1$d%</string>
<string name="offline_state_paused">Пауза: %1$d%</string>
<string name="offline_state_error">Ошибка: %1$s</string>
<string name="offline_state_available">Доступно офлайн</string>
<string name="user_tags">Теги</string>
<string name="user_tags_add">Добавить новый тег</string>
<string name="user_tags_save">Сохранить</string>
<string name="user_tags_edit_title">Редактирование тега</string>
<string name="user_tags_new_tag">Новое название</string>
<string name="user_tags_new_tag_save">Изменить название</string>
<string name="user_tags_tag_delete">Удалить</string>
<string name="movie_tags_empty">Настроить теги</string>
<string name="user_tags_filter_disable">Не фильтровать</string>
</resources>
@@ -0,0 +1,15 @@
package ru.shadowsparky.vbox.shared
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import ru.shadowsparky.vbox.shared.di.EventsModule
import ru.shadowsparky.vbox.shared.di.HttpModule
import ru.shadowsparky.vbox.shared.di.SavedModule
@Module(
includes = [EventsModule::class, HttpModule::class, SavedModule::class]
)
@Configuration
@ComponentScan
class VBoxSharedModule
@@ -0,0 +1,32 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.app.AppDatabase
import ru.shadowsparky.vbox.shared.di.factory.DatabaseDriverFactory
@Single
class AppDatabaseProvider(
private val factory: DatabaseDriverFactory,
private val dispatcherProvider: DispatcherProvider
) {
private val mutex = Mutex()
private var appDatabase: AppDatabase? = null
suspend fun get(): AppDatabase = withContext(dispatcherProvider.io) {
appDatabase?.let { return@withContext it }
mutex.withLock {
appDatabase?.let { return@withContext it }
val driver = factory.createDriver()
AppDatabase.Schema.migrate(driver, 2, 3)
AppDatabase.Schema.migrate(driver, 3, 4)
AppDatabase.Schema.migrate(driver, 4, 5)
AppDatabase.Schema.migrate(driver, 5, 6)
AppDatabase.Schema.migrate(driver, 6, 7)
AppDatabase.invoke(driver).also { appDatabase = it }
}
}
}
@@ -0,0 +1,87 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.utils.io.core.toByteArray
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.HttpException
import ru.shadowsparky.http.domain.TokenInfo
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.TokenRequest
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.EmptyLoginException
import ru.shadowsparky.vbox.shared.domain.model.EmptyPasswordException
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
import ru.shadowsparky.vbox.shared.domain.model.ServerLoginException
import ru.shadowsparky.vbox.shared.domain.model.UnknownLoginException
@Factory
class AuthTokenRepositoryImpl(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
private val tokenCache: TokenStorage,
private val hashCalculator: HmacSha512Calc
) : AuthTokenRepository {
override suspend fun register(loginInfo: LoginInfo): TokenInfo {
return post(loginInfo, AuthTokenRepository.REGISTER_PATH, false)
}
override suspend fun login(loginInfo: LoginInfo): TokenInfo {
return post(loginInfo, AuthTokenRepository.LOGIN_PATH, true)
}
override suspend fun update(refresh: TokenRequest): TokenInfo {
return httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, AuthTokenRepository.UPDATE_PATH)
setJsonBody(refresh)
}.body<TokenInfo>()
}
override suspend fun changePassword(request: ChangePasswordRequest) {
val oldPassHash = hashCalculator.calc(request.oldPasswordHash.toByteArray())
val newPassHash = hashCalculator.calc(request.newPasswordHash.toByteArray())
httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, AuthTokenRepository.CHANGE_PASS_PATH)
setJsonBody(ChangePasswordRequest(oldPassHash.toHexString(), newPassHash.toHexString()))
}
}
override suspend fun revoke(tokenRequest: TokenRequest) {
httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, AuthTokenRepository.REVOKE_PATH)
setJsonBody(tokenRequest)
}
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun post(loginInfo: LoginInfo, path: String, saveToCache: Boolean): TokenInfo {
if (loginInfo.login.trim().isEmpty()) {
throw EmptyLoginException()
} else if (loginInfo.passwordHash.trim().isEmpty()) {
throw EmptyPasswordException()
}
val realPassHash = hashCalculator.calc(loginInfo.passwordHash.toByteArray())
try {
return httpClient.post {
serverConfigurationProvider.getServerConfiguration().apply(this, path)
setJsonBody(loginInfo.copy(passwordHash = realPassHash.toHexString()))
}.body<TokenInfo>().apply {
if (saveToCache) {
tokenCache.updateToken(this)
}
}
} catch (e: HttpException) {
throw ServerLoginException(e.message ?: "Unknown error")
} catch (e: Exception) {
throw UnknownLoginException(e)
}
}
}
@@ -0,0 +1,30 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
@Factory
class DefaultServerConfigurationRepository(
private val storage: KeyValueStorage
) : ServerConfigurationRepository {
override val serverConfiguration: Flow<ServerConfiguration> = storage.get(KEY)
.map {
if (it != null) {
ServerConfiguration(it)
} else {
ServerConfiguration()
}
}
override suspend fun setServerConfiguration(conf: ServerConfiguration) {
storage.put(KEY, conf.asHttpStr())
}
private companion object {
const val KEY = "custom_server_uri"
}
}
@@ -0,0 +1,22 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.HeathCheck
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Factory
class HeathCheckImpl(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
) : HeathCheck {
override suspend fun check() {
httpClient.get {
serverConfigurationProvider.getServerConfiguration()
.apply(this, HeathCheck.PATH)
}
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.withContext
import okio.Buffer
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
@Factory
class HmacSha512Calc(private val dispatcherProvider: DispatcherProvider) {
private val hmacKey =
"cd4204fae910ba12b9eb6650ef90852252cf20e4c55bbb3b21ac1f8cdf8d0ab53a5f49d2298824f011dbefd30fdec21ff0b485ae835dd9bccc9d377ed54c0d80"
@OptIn(ExperimentalStdlibApi::class)
suspend fun calc(data: ByteArray): ByteArray = withContext(dispatcherProvider.io) {
val buffer = Buffer().apply { write(data) }
val hmacStr = hmacKey.hexToByteArray().toByteString()
buffer.hmacSha512(hmacStr).toByteArray()
}
}
@@ -0,0 +1,37 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
fun ServerConfiguration.apply(
builder: HttpRequestBuilder,
path: String
) {
builder.apply {
url(asHttpStr() + "/$path")
}
}
inline fun <reified T> HttpRequestBuilder.setJsonBody(body: T) {
header("Content-Type", "application/json")
setBody(body)
}
suspend inline fun <reified T> HttpClient.get(
path: String,
params: Map<String, String>,
configuration: ServerConfiguration
): T {
val rsp = get {
configuration.apply(this, path)
params.forEach { (key, value) -> parameter(key, value) }
}
return rsp.body()
}
@@ -0,0 +1,31 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.vbox.shared.domain.PlayerSettingsStorge
import ru.shadowsparky.vbox.shared.domain.isJs
@Factory
class PlayerSettingsStorgeImpl(
private val storage: KeyValueStorage
) : PlayerSettingsStorge {
override val useExternalPlayer = flow {
if (isJs) {
// в js не поддерживается.
emit(null)
} else {
emitAll(storage.getBoolean(KEY).map { it ?: false })
}
}
override suspend fun setUseExternalPlayer(use: Boolean) {
storage.putBoolean(KEY, use)
}
companion object {
private const val KEY = "use_external_player"
}
}
@@ -0,0 +1,107 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.isActive
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import kotlin.math.pow
@Factory
class RemoteEventListenerFactory(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val log: Log
) {
fun create(suffix: String): RemoteEventListener {
return RemoteEventListenerImpl(
httpClient,
serverConfigurationRepository,
authTokenCache,
json,
log,
suffix
)
}
}
private class RemoteEventListenerImpl(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val log: Log,
private val suffix: String
) : RemoteEventListener {
private val eventFlow = flow {
val config = serverConfigurationRepository.getServerConfiguration()
val token = authTokenCache.token.first() ?: return@flow
log.d(TAG, "listener $suffix started")
try {
httpClient.webSocket(
method = HttpMethod.Get,
request = { url(config.asWebSocketStr() + "/$suffix") }
) {
outgoing.send(Frame.Text(token.token))
while (currentCoroutineContext().isActive) {
val text = (incoming.receive() as? Frame.Text)?.readText()
if (text != null) {
val decoded = json.decodeFromString<RemoteEvent>(text)
log.d(TAG, "receive $text $decoded")
emit(decoded)
}
}
}
} catch (e: Exception) {
log.e(TAG, e, "websocket closed")
throw e
} finally {
log.d(TAG, "listener $suffix finished")
}
}
override val event: Flow<RemoteEvent> = eventFlow.retryExponential { true }
private fun <T> Flow<T>.retryExponential(
maxRetries: Int = Int.MAX_VALUE,
initialDelay: Long = 5000L,
maxDelay: Long = 300_000L,
factor: Double = 2.0,
shouldRetry: (Throwable) -> Boolean = { true }
): Flow<T> = retryWhen { cause, attempt ->
log.d(TAG, "error occurred $suffix", cause)
if (!shouldRetry(cause) || attempt >= maxRetries || cause is CancellationException) {
false
} else {
val delayTime = (initialDelay * factor.pow(attempt.toDouble()))
.toLong()
.coerceAtMost(maxDelay)
log.d(TAG, "retry after delay $delayTime", cause)
delay(delayTime)
true
}
}
private companion object {
const val TAG = "RemoteSearchEventListener"
}
}
@@ -0,0 +1,52 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
typealias WriteCallback<T> = suspend (List<T>) -> Unit
class RemoteSourceSynchronizer<T, K>(
private val isSync: suspend () -> Boolean,
private val setSync: suspend (Boolean) -> Unit,
private val authTokenCache: TokenStorage,
private val queryRemote: suspend () -> List<T>,
private val queryLocal: suspend () -> List<T>,
private val writeRemote: WriteCallback<T>,
private val writeLocal: WriteCallback<T>,
private val selectKey: (T) -> K
) {
suspend fun syncIfNeeded() {
if (!authTokenCache.hasAccount()) return
if (isSync()) return
runCatching {
sync()
setSync(true)
}.onFailure {
setSync(false)
}
}
private suspend fun sync() {
coroutineScope {
val remoteDef = async { queryRemote() }
val localDef = async { queryLocal() }
val remoteList = remoteDef.await()
val localList = localDef.await()
val mergedMap = mutableMapOf<K, T>()
localList.forEach { mergedMap[selectKey(it)] = it }
remoteList.forEach { mergedMap[selectKey(it)] = it }
val combinedResult = mergedMap.values.toList()
val remoteKeys = remoteList.map(selectKey).toSet()
val localKeys = localList.map(selectKey).toSet()
val toRemote = combinedResult.filterNot { selectKey(it) in remoteKeys }
val toLocal = combinedResult.filterNot { selectKey(it) in localKeys }
val writeRemoteJob = async { if (toRemote.isNotEmpty()) writeRemote(toRemote) }
val writeLocalJob = async { if (toLocal.isNotEmpty()) writeLocal(toLocal) }
awaitAll(writeRemoteJob, writeLocalJob)
}
}
}
@@ -0,0 +1,38 @@
package ru.shadowsparky.vbox.shared.data
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.SavedTimeInfo
import ru.shadowsparky.vbox.shared.domain.SavedTimeRepository
@Factory
class SqlSavedTimeRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : SavedTimeRepository {
override suspend fun query(movieId: Long, seasonId: Long?, episodeId: Long?): Long? =
withContext(dispatcherProvider.io) {
dbProvider.get().savedTimeQueries.selectSavedTime(
movieId,
seasonId ?: DEFAULT_VALUE,
episodeId ?: DEFAULT_VALUE
).awaitAsOneOrNull()?.time
}
override suspend fun setTime(time: SavedTimeInfo): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().savedTimeQueries
.upsertSavedTime(
time.movieId,
time.seasonId ?: DEFAULT_VALUE,
time.episodeId ?: DEFAULT_VALUE,
time.time
)
}
private companion object {
const val DEFAULT_VALUE = 0L
}
}
@@ -0,0 +1,71 @@
package ru.shadowsparky.vbox.shared.data.api
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.ApiCacheQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
class FallbackVideoApi(
private val dbProvider: AppDatabaseProvider,
private val wrapper: VideoApi,
private val json: Json,
private val dispatcherProvider: DispatcherProvider
) : VideoApi {
override suspend fun fetchNewVideos(query: String?): VideosResponse {
val key = "fetchNewVideos_${query};1;date;"
return try {
wrapper.fetchNewVideos(query).apply {
writeByKey(key, this)
}
} catch (e: Exception) {
getByKey(key) ?: throw e
}
}
override suspend fun fetchDetails(id: Long): VideoDetails {
val key = "fetchDetails_${id};"
return try {
wrapper.fetchDetails(id).apply {
writeByKey(key, this)
}
} catch (e: Exception) {
getByKey(key) ?: throw e
}
}
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
val key = "fetchVideoLinks_${id};$seasonId;"
return try {
wrapper.fetchVideoLinks(id, seasonId).apply {
writeByKey(key, this)
}
} catch (e: Exception) {
getByKey(key) ?: throw e
}
}
private suspend fun getCache(): ApiCacheQueries {
return dbProvider.get().apiCacheQueries
}
private suspend inline fun <reified T> getByKey(key: String): T? {
val value = getCache().selectByKey(key)
.awaitAsOneOrNull()?.cache_value
?: return null
return json.decodeFromString(value)
}
private suspend inline fun <reified T> writeByKey(key: String, value: T) = withContext(dispatcherProvider.io) {
getCache().insert(
key,
json.encodeToString(value)
)
}
}
@@ -0,0 +1,25 @@
package ru.shadowsparky.vbox.shared.data.api
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
class PosterWithoutSuffixVideoApi(
private val wrapper: VideoApi
) : VideoApi by wrapper {
override suspend fun fetchNewVideos(query: String?): VideosResponse {
return wrapper.fetchNewVideos(query).let {
it.copy(items = it.items.map { it.copy(poster = it.poster.withoutSuffix()) })
}
}
override suspend fun fetchDetails(id: Long): VideoDetails {
return wrapper.fetchDetails(id).let {
it.copy(poster = it.poster.withoutSuffix())
}
}
}
internal fun String.withoutSuffix(): String {
return replace("_180.jpg", ".jpg")
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.vbox.shared.data.api
import io.ktor.client.HttpClient
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
class VideoApiImpl(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository
) : VideoApi {
override suspend fun fetchNewVideos(query: String?): VideosResponse {
val params = hashMapOf<String, String>().apply {
query?.let { put(VideoApi.SEARCH_ARG, query) }
}
return get(VideoApi.VIDEOS_PATH, params)
}
override suspend fun fetchDetails(id: Long): VideoDetails {
val params = hashMapOf<String, String>().apply {
put(VideoApi.ID_ARG, id.toString())
}
return get(VideoApi.DETAILS_PATH, params)
}
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
val params = hashMapOf<String, String>().apply {
put(VideoApi.ID_ARG, id.toString())
seasonId?.let {
put(VideoApi.SEASON_ID_ARG, it.toString())
}
}
return get(VideoApi.LINKS_PATH, params)
}
private suspend inline fun <reified T> get(path: String, params: Map<String, String>): T {
return httpClient.get(path, params, serverConfigurationProvider.getServerConfiguration())
}
}
@@ -0,0 +1,95 @@
package ru.shadowsparky.vbox.shared.data.recent
import io.ktor.client.HttpClient
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
@Factory
@Named(DbType.REMOTE)
class RemoteRecentlyWatchedRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
@Named(EventType.RECENT) private val remoteEventListener: RemoteEventListener,
) : RecentlyWatchedRepository {
private suspend fun obtainConfig() = serverConfigurationRepository.getServerConfiguration()
override suspend fun remove(info: RecentlyWatchedInfo) {
post(info, RecentlyWatchedRepository.REMOVE)
}
override suspend fun write(info: RecentlyWatchedInfo) {
post(info, RecentlyWatchedRepository.WRITE)
}
override suspend fun writeAll(info: List<RecentlyWatchedInfo>) {
if (info.isNotEmpty()) {
post(info, RecentlyWatchedRepository.WRITE)
}
}
private suspend fun post(info: RecentlyWatchedInfo, path: String) {
post(listOf(info), path)
}
private suspend fun post(info: List<RecentlyWatchedInfo>, path: String) {
authTokenCache.token.firstOrNull() ?: return
httpClient.post {
obtainConfig().apply(this, path)
setJsonBody(RecentlyWatchedRequest(info))
}
}
override fun queryRecentlyWatched(
movieId: Long,
seasonId: Long?
): Flow<List<RecentlyWatchedInfo>> {
return remoteEventListener.event.filter { it is RemoteEvent.OnRecent && it.movieId == movieId }
.map { queryInternal(movieId, seasonId) }
.onStart { emit(queryInternal(movieId, seasonId)) }
}
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
return queryInternal(null, null)
}
private suspend fun queryInternal(
movieId: Long?,
seasonId: Long?
): List<RecentlyWatchedInfo> {
authTokenCache.token.firstOrNull() ?: return emptyList()
val map = hashMapOf<String, String>()
if (movieId != null) {
map[RecentlyWatchedRepository.MOVIE_ID_ARG] = movieId.toString()
}
if (seasonId != null) {
map[RecentlyWatchedRepository.SEASON_ID_ARG] = seasonId.toString()
}
return httpClient.get<RecentlyWatchedResponse>(
RecentlyWatchedRepository.GET_INFO,
map,
obtainConfig()
).info
}
}
@@ -0,0 +1,78 @@
package ru.shadowsparky.vbox.shared.data.recent
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.coroutines.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.RecentlyWatched
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
@Factory
@Named(DbType.LOCAL)
class SqlRecentlyWatchedRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : RecentlyWatchedRepository {
override suspend fun write(info: RecentlyWatchedInfo): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.insertRecentlyWatched(
info.movieId,
info.episode ?: UNKNOWN,
info.season ?: UNKNOWN
)
}
override suspend fun remove(info: RecentlyWatchedInfo): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.deleteRwByIds(
info.movieId,
info.episode ?: UNKNOWN,
info.season ?: UNKNOWN
)
}
override fun queryRecentlyWatched(
movieId: Long,
seasonId: Long?
): Flow<List<RecentlyWatchedInfo>> {
return flow {
emitAll(
dbProvider.get().recentlyWatchedQueries.selectByMovieId(movieId)
.asFlow()
.map { it.awaitAsList().toDomain() }
)
}.flowOn(dispatcherProvider.io)
}
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> =
withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.selectAll()
.awaitAsList()
.toDomain()
}
private fun List<RecentlyWatched>.toDomain(): List<RecentlyWatchedInfo> {
return map {
val ep = if (it.episode == UNKNOWN) null else it.episode
val season = if (it.season == UNKNOWN) null else it.season
RecentlyWatchedInfo(it.movie_id, ep, season)
}
}
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.clear()
}
private companion object {
const val UNKNOWN = -1L
}
}
@@ -0,0 +1,125 @@
package ru.shadowsparky.vbox.shared.data.recent
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
import ru.shadowsparky.vbox.shared.domain.query
import ru.shadowsparky.vbox.shared.domain.queryFlow
@Factory
class SyncClientServerRecentlyWatchedRepository(
@Named(DbType.REMOTE) private val remote: RecentlyWatchedRepository,
@Named(DbType.LOCAL) private val local: RecentlyWatchedRepository,
private val pendingRepo: PendingOperationRepository,
private val syncRepository: SyncRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : RecentlyWatchedRepository {
override fun queryRecentlyWatched(movieId: Long, seasonId: Long?): Flow<List<RecentlyWatchedInfo>> {
return authTokenCache.queryFlow(
remote.queryRecentlyWatched(movieId, seasonId),
local.queryRecentlyWatched(movieId, seasonId)
) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.RECENTLY_WATCHED)
}
}
private fun createSync(): RemoteSourceSynchronizer<RecentlyWatchedInfo, String> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.RECENTLY_WATCHED) },
setSync = { syncRepository.set(AggregateType.RECENTLY_WATCHED, true) },
authTokenCache = authTokenCache,
queryRemote = { remote.getAllRecentlyWatched() },
queryLocal = { local.getAllRecentlyWatched() },
writeRemote = { list -> remote.writeAll(list.map { it.copy(id = -1) }) },
writeLocal = { local.writeAll(it) },
selectKey = { "${it.movieId}|${it.season}|${it.episode}" }
)
}
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
return authTokenCache.query(
active = { remote.getAllRecentlyWatched() },
fallback = { local.getAllRecentlyWatched() }
)
}
override suspend fun write(info: RecentlyWatchedInfo) {
local.write(info)
if (authTokenCache.hasAccount()) {
try {
remote.write(info)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_WRITE,
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info)
)
)
}
}
}
override suspend fun remove(info: RecentlyWatchedInfo) {
local.remove(info)
if (authTokenCache.hasAccount()) {
try {
remote.remove(info)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_REMOVE,
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info)
)
)
}
}
}
override suspend fun writeAll(info: List<RecentlyWatchedInfo>) {
local.writeAll(info)
if (authTokenCache.hasAccount()) {
try {
remote.writeAll(info)
} catch (_: Exception) {
val payload = buildJsonObject {
put(LIST, json.encodeToJsonElement(info))
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_WRITE_ALL,
payload = payload
)
)
}
}
}
companion object {
const val LIST = "list"
const val ACTION_WRITE = "WRITE"
const val ACTION_REMOVE = "REMOVE"
const val ACTION_WRITE_ALL = "WRITE_ALL"
}
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.vbox.shared.data.saved
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.shadowsparky.vbox.shared.data.api.withoutSuffix
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
class PosterWithoutSuffixSavedMovieRepository(
private val wrapper: SavedMovieRepository
) : SavedMovieRepository by wrapper {
override fun getAll(): Flow<List<VideoDetails>> {
return wrapper.getAll()
.map {
it.map { it.copy(poster = it.poster.withoutSuffix()) }
}
}
}
@@ -0,0 +1,77 @@
package ru.shadowsparky.vbox.shared.data.saved
import io.ktor.client.HttpClient
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.IsSavedResponse
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.RemoveRequest
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.SavedMoviesResponse
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
class RemoteSavedMovieRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
@Named(EventType.SAVED) private val eventListener: RemoteEventListener
) : SavedMovieRepository {
override fun getAll(): Flow<List<VideoDetails>> {
return eventListener.event
.filter { it is RemoteEvent.OnSaved }
.map { getAllSingle() }
.onStart { emit(getAllSingle()) }
}
private suspend fun obtainConfig() = serverConfigurationRepository.getServerConfiguration()
private suspend fun getAllSingle(): List<VideoDetails> {
return httpClient.get<SavedMoviesResponse>(
SavedMovieRepository.QUERY_ALL,
emptyMap(),
obtainConfig()
).list
}
override suspend fun save(details: VideoDetails) {
httpClient.post {
obtainConfig().apply(this, SavedMovieRepository.WRITE)
setJsonBody(details)
}
}
override fun isSaved(id: Long): Flow<Boolean> {
return eventListener.event
.filter { it is RemoteEvent.OnSaved && it.movieId == id }
.map { isSavedSingle(id) }
.onStart { emit(isSavedSingle(id)) }
}
private suspend fun isSavedSingle(id: Long): Boolean {
return httpClient.get<IsSavedResponse>(
SavedMovieRepository.QUERY_SAVED,
mapOf(
SavedMovieRepository.MOVIE_ID_ARG to id.toString()
),
obtainConfig()
).isSaved
}
override suspend fun remove(id: Long) {
httpClient.post {
obtainConfig().apply(this, SavedMovieRepository.REMOVE)
setJsonBody(RemoveRequest(id))
}
}
}
@@ -0,0 +1,74 @@
package ru.shadowsparky.vbox.shared.data.saved
import app.cash.sqldelight.Query
import app.cash.sqldelight.async.coroutines.awaitAsList
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.SavedMovie
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
class SqlSavedMovieRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : SavedMovieRepository {
private suspend fun getRequest(): Query<SavedMovie> = withContext(dispatcherProvider.io) {
dbProvider.get().savedMovieQueries.selectAll()
}
override fun getAll(): Flow<List<VideoDetails>> {
return callbackFlow {
val listener = Query.Listener {
launch { send(obtain()) }
}
val request = getRequest()
request.addListener(listener)
send(obtain())
awaitClose { request.removeListener(listener) }
}.flowOn(dispatcherProvider.io)
}
private suspend fun obtain(): List<VideoDetails> = withContext(dispatcherProvider.io) {
val request = getRequest()
request.awaitAsList().map {
VideoDetails(
it.saved_movie_id,
it.poster_url,
it.description,
it.title,
(it.flags.toInt() and SERIAL_FLAG) != 0
)
}
}
override fun isSaved(id: Long): Flow<Boolean> {
return getAll().map { it.any { id == it.id } }
}
override suspend fun save(details: VideoDetails) = withContext(dispatcherProvider.io) {
val canSave = dbProvider.get().savedMovieQueries.selectAll().awaitAsList()
.none { it.saved_movie_id == details.id }
if (canSave) {
dbProvider.get().savedMovieQueries.insertSavedMovie(
details.id,
details.poster,
details.desc,
details.title,
details.flags.toLong()
)
}
}
override suspend fun remove(id: Long): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().savedMovieQueries.deleteSavedMovie(id)
}
}
@@ -0,0 +1,143 @@
package ru.shadowsparky.vbox.shared.data.saved
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.queryFlow
@Factory
class SyncClientSavedMovieRepository(
@Named(DbType.REMOTE) private val remote: SavedMovieRepository,
@Named(DbType.LOCAL) private val local: SavedMovieRepository,
private val pendingRepo: PendingOperationRepository,
private val syncRepository: SyncRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : SavedMovieRepository {
override suspend fun save(details: VideoDetails) {
local.save(details)
if (authTokenCache.hasAccount()) {
try {
remote.save(details)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_SAVE,
payload = json.encodeToJsonElement(VideoDetails.serializer(), details)
)
)
}
}
}
override suspend fun remove(id: Long) {
local.remove(id)
if (authTokenCache.hasAccount()) {
try {
remote.remove(id)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_ID, id)
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_REMOVE,
payload = payload
)
)
}
}
}
override fun getAll(): Flow<List<VideoDetails>> {
return authTokenCache.queryFlow(remote.getAll(), local.getAll()) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.SAVED_MOVIE)
}
}
private fun createSync(): RemoteSourceSynchronizer<VideoDetails, Long> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.SAVED_MOVIE) },
setSync = { syncRepository.set(AggregateType.SAVED_MOVIE, true) },
authTokenCache = authTokenCache,
queryRemote = { remote.getAll().first() },
queryLocal = { local.getAll().first() },
writeRemote = { remote.saveAll(it) },
writeLocal = { local.saveAll(it) },
selectKey = { it.id }
)
}
override fun isSaved(id: Long): Flow<Boolean> {
return authTokenCache.queryFlow(remote.isSaved(id), local.isSaved(id))
}
override suspend fun saveAll(details: List<VideoDetails>) {
local.saveAll(details)
if (authTokenCache.hasAccount()) {
try {
remote.saveAll(details)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_LIST, json.encodeToJsonElement(details))
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_SAVE_ALL,
payload = payload
)
)
}
}
}
override suspend fun clear() {
local.clear()
if (authTokenCache.hasAccount()) {
try {
remote.clear()
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_CLEAR,
payload = kotlinx.serialization.json.JsonNull
)
)
}
}
}
companion object {
const val ACTION_SAVE = "SAVE"
const val ACTION_REMOVE = "REMOVE"
const val ACTION_SAVE_ALL = "SAVE_ALL"
const val ACTION_CLEAR = "CLEAR"
const val KEY_ID = "id"
const val KEY_LIST = "list"
}
}
@@ -0,0 +1,76 @@
package ru.shadowsparky.vbox.shared.data.search
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest
import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest
import ru.shadowsparky.vbox.shared.domain.SearchQueryResponse
import ru.shadowsparky.vbox.shared.domain.SearchRepository
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Named(DbType.REMOTE)
@Factory
class RemoteSearchRepository(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
@Named(EventType.SEARCH) private val removeListener: RemoteEventListener
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return removeListener.event
.map { searchSingle(query) }
.onStart { emit(searchSingle(query)) }
}
private suspend fun searchSingle(query: String): List<String> {
return httpClient.get<SearchQueryResponse>(
SearchRepository.SEARCH,
hashMapOf(SearchRepository.QUERY_ARG to query),
obtainConf()
).search
}
override suspend fun addToSearch(query: String) {
addToSearch(listOf(query))
}
override suspend fun addToSearch(query: List<String>) {
httpClient.post {
obtainConf().apply(this, SearchRepository.ADD_TO_SEARCH)
setJsonBody(SearchQueryRequest(query))
}
}
override suspend fun deleteFromSearch(query: String) {
deleteInternal(query)
}
override suspend fun clear() {
deleteInternal(null)
}
private suspend fun deleteInternal(query: String?) {
httpClient.delete {
obtainConf().apply(this, SearchRepository.CLEAR)
setJsonBody(SearchDeleteRequest(query))
}
}
private suspend fun obtainConf(): ServerConfiguration {
return serverConfigurationProvider.getServerConfiguration()
}
}
@@ -0,0 +1,69 @@
package ru.shadowsparky.vbox.shared.data.search
import app.cash.sqldelight.Query
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import app.cash.sqldelight.coroutines.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.SearchInfo
import ru.shadowsparky.vbox.domain.SearchInfoQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.SearchRepository
@Named(DbType.LOCAL)
@Factory
class SqlSearchRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return flow {
emitAll(
if (query.isNotEmpty()) {
getQueries().selectByQuery(query.addWildcard()).toDomain()
} else {
getQueries().selectAll().toDomain()
}
)
}
}
private fun Query<SearchInfo>.toDomain(): Flow<List<String>> {
return asFlow()
.map { list ->
list.awaitAsList().map { it.query }
}.flowOn(dispatcherProvider.io)
}
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
getQueries().deleteAll()
}
override suspend fun deleteFromSearch(query: String): Unit = withContext(dispatcherProvider.io) {
getQueries().deleteSearchInfo(query)
}
override suspend fun addToSearch(query: String) = withContext(dispatcherProvider.io) {
if (getQueries().selectByQuery(query).awaitAsOneOrNull() == null) {
getQueries().insertSearchInfo(query)
}
}
private suspend fun getQueries(): SearchInfoQueries {
return dbProvider.get().searchInfoQueries
}
private fun String.addWildcard(): String {
return if (this.endsWith("%")) this else "$this%"
}
}
@@ -0,0 +1,119 @@
package ru.shadowsparky.vbox.shared.data.search
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.SearchRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.queryFlow
@Factory
class SyncClientServerSearchRepository(
@Named(DbType.REMOTE) private val remote: SearchRepository,
@Named(DbType.LOCAL) private val local: SearchRepository,
private val pendingRepo: PendingOperationRepository,
private val syncRepository: SyncRepository,
private val authTokenCache: TokenStorage,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return authTokenCache.queryFlow(
remote.search(query),
local.search(query)
) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.SEARCH)
}
}
private fun createSync(): RemoteSourceSynchronizer<String, String> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.SEARCH) },
setSync = { syncRepository.set(AggregateType.SEARCH, true) },
authTokenCache = authTokenCache,
queryRemote = { remote.search("").first() },
queryLocal = { local.search("").first() },
writeRemote = { remote.addToSearch(it) },
writeLocal = { local.addToSearch(it) },
selectKey = { it }
)
}
override suspend fun addToSearch(query: String) {
local.addToSearch(query)
if (authTokenCache.hasAccount()) {
try {
remote.addToSearch(query)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_QUERY, query)
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SEARCH,
actionType = ACTION_ADD,
payload = payload
)
)
}
}
}
override suspend fun deleteFromSearch(query: String) {
local.deleteFromSearch(query)
if (authTokenCache.hasAccount()) {
try {
remote.deleteFromSearch(query)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_QUERY, query)
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SEARCH,
actionType = ACTION_DELETE,
payload = payload
)
)
}
}
}
override suspend fun clear() {
local.clear()
if (authTokenCache.hasAccount()) {
try {
remote.clear()
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.SEARCH,
actionType = ACTION_CLEAR,
payload = JsonNull
)
)
}
}
}
companion object {
const val ACTION_ADD = "ADD"
const val ACTION_DELETE = "DELETE"
const val ACTION_CLEAR = "CLEAR"
const val KEY_QUERY = "query"
}
}
@@ -0,0 +1,30 @@
package ru.shadowsparky.vbox.shared.data.sync
import org.koin.core.annotation.Singleton
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
@Singleton
class PendingOperationSynchronizer(
private val pendingRepo: PendingOperationRepository,
strategies: List<AggregateSyncStrategy>
) {
private val strategiesMap = strategies.associateBy { it.aggregateType }
suspend fun sync(type: AggregateType): Boolean {
val strategy = strategiesMap[type] ?: error("No sync strategy registered for type: $type")
val operations = pendingRepo.query(type)
for (operation in operations) {
try {
strategy.execute(operation.actionType, operation.payload)
pendingRepo.delete(operation.id)
} catch (_: IllegalStateException) {
pendingRepo.delete(operation.id)
} catch (_: Exception) {
return false
}
}
return true
}
}
@@ -0,0 +1,55 @@
package ru.shadowsparky.vbox.shared.data.sync
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.PendingOperationQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import kotlin.time.Clock
@Factory
class SqlPendingOperationRepository(
private val db: AppDatabaseProvider,
private val json: Json,
private val dispatcherProvider: DispatcherProvider
) : PendingOperationRepository {
private suspend fun getQueries(): PendingOperationQueries {
return db.get().pendingOperationQueries
}
override suspend fun delete(id: Long): Unit = withContext(dispatcherProvider.io) {
getQueries().deleteOperation(id)
}
override suspend fun query(type: AggregateType): List<OperationInfo> = withContext(dispatcherProvider.io) {
val records = getQueries().getOperationsByAggregate(type.name).executeAsList()
records.map { record ->
OperationInfo(
id = record.id,
type = type,
actionType = record.actionType,
payload = record.payload?.let { json.parseToJsonElement(it) } ?: JsonNull
)
}
}
override suspend fun insert(operationInfo: OperationInfo): Unit = withContext(dispatcherProvider.io) {
val payloadString = json.encodeToString(operationInfo.payload)
getQueries().insertOperation(
aggregateType = operationInfo.type.name,
actionType = operationInfo.actionType,
payload = payloadString,
createdAt = Clock.System.now().epochSeconds
)
}
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
getQueries().clear()
}
}
@@ -0,0 +1,39 @@
package ru.shadowsparky.vbox.shared.data.sync
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.AggregateType.RECENTLY_WATCHED
import ru.shadowsparky.vbox.shared.domain.AggregateType.SAVED_MOVIE
import ru.shadowsparky.vbox.shared.domain.AggregateType.SEARCH
import ru.shadowsparky.vbox.shared.domain.SyncRepository
@Factory
class SyncRepositoryImpl(
private val storage: KeyValueStorage
) : SyncRepository {
override suspend fun isSynchronized(type: AggregateType): Boolean {
return storage.getBoolean(type.calcKey()).first() ?: false
}
override suspend fun set(type: AggregateType, state: Boolean) {
storage.putBoolean(type.calcKey(), state)
}
private fun AggregateType.calcKey(): String {
return when (this) {
SEARCH -> SEARCH_KEY
RECENTLY_WATCHED -> RECENT_KEY
SAVED_MOVIE -> SAVED_KEY
else -> name.lowercase()
}
}
companion object {
const val SEARCH_KEY = "search"
const val RECENT_KEY = "recent"
const val SAVED_KEY = "saved"
}
}
@@ -0,0 +1,33 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.tag.SyncMovieTagRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
@Factory
class MovieTagSyncStrategy(
@Named(DbType.REMOTE) private val remote: MovieTagRepository
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.MOVIE_TAG
override suspend fun execute(actionType: String, payload: JsonElement) {
val payloadObj = payload.jsonObject
val movieId = payloadObj[SyncMovieTagRepository.KEY_MOVIE_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncMovieTagRepository.KEY_MOVIE_ID}")
val tagId = payloadObj[SyncMovieTagRepository.KEY_TAG_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncMovieTagRepository.KEY_TAG_ID}")
when (actionType) {
SyncMovieTagRepository.ACTION_LINK -> remote.link(movieId, tagId)
SyncMovieTagRepository.ACTION_UNLINK -> remote.unlink(movieId, tagId)
else -> error("Unknown action type: $actionType")
}
}
}
@@ -0,0 +1,42 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.recent.SyncClientServerRecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
@Factory
class RecentlyWatchedSyncStrategy(
@Named(DbType.REMOTE) private val remote: RecentlyWatchedRepository,
private val json: Json
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.RECENTLY_WATCHED
override suspend fun execute(actionType: String, payload: JsonElement) {
when (actionType) {
SyncClientServerRecentlyWatchedRepository.ACTION_WRITE -> {
val info = json.decodeFromJsonElement<RecentlyWatchedInfo>(payload)
remote.write(info)
}
SyncClientServerRecentlyWatchedRepository.ACTION_REMOVE -> {
val info = json.decodeFromJsonElement<RecentlyWatchedInfo>(payload)
remote.remove(info)
}
SyncClientServerRecentlyWatchedRepository.ACTION_WRITE_ALL -> {
val payloadObj = payload.jsonObject
val listElement = payloadObj[SyncClientServerRecentlyWatchedRepository.LIST] ?: error("Missing list key")
val list = json.decodeFromJsonElement<List<RecentlyWatchedInfo>>(listElement)
remote.writeAll(list)
}
else -> error("Unknown action type: $actionType")
}
}
}
@@ -0,0 +1,49 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.saved.SyncClientSavedMovieRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
@Factory
class SavedMovieSyncStrategy(
@Named(DbType.REMOTE) private val remote: SavedMovieRepository,
private val json: Json
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.SAVED_MOVIE
override suspend fun execute(actionType: String, payload: JsonElement) {
when (actionType) {
SyncClientSavedMovieRepository.ACTION_SAVE -> {
val details = json.decodeFromJsonElement<VideoDetails>(payload)
remote.save(details)
}
SyncClientSavedMovieRepository.ACTION_REMOVE -> {
val id = payload.jsonObject[SyncClientSavedMovieRepository.KEY_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncClientSavedMovieRepository.KEY_ID}")
remote.remove(id)
}
SyncClientSavedMovieRepository.ACTION_SAVE_ALL -> {
val listElement = payload.jsonObject[SyncClientSavedMovieRepository.KEY_LIST]
?: error("Missing ${SyncClientSavedMovieRepository.KEY_LIST}")
val list = json.decodeFromJsonElement<List<VideoDetails>>(listElement)
remote.saveAll(list)
}
SyncClientSavedMovieRepository.ACTION_CLEAR -> {
remote.clear()
}
else -> error("Unknown action type: $actionType")
}
}
}
@@ -0,0 +1,37 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.search.SyncClientServerSearchRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.SearchRepository
@Factory
class SearchSyncStrategy(
@Named(DbType.REMOTE) private val remote: SearchRepository
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.SEARCH
override suspend fun execute(actionType: String, payload: JsonElement) {
when (actionType) {
SyncClientServerSearchRepository.ACTION_ADD -> {
val query = payload.jsonObject[SyncClientServerSearchRepository.KEY_QUERY]?.jsonPrimitive?.content
?: error("Missing ${SyncClientServerSearchRepository.KEY_QUERY}")
remote.addToSearch(query)
}
SyncClientServerSearchRepository.ACTION_DELETE -> {
val query = payload.jsonObject[SyncClientServerSearchRepository.KEY_QUERY]?.jsonPrimitive?.content
?: error("Missing ${SyncClientServerSearchRepository.KEY_QUERY}")
remote.deleteFromSearch(query)
}
SyncClientServerSearchRepository.ACTION_CLEAR -> remote.clear()
else -> error("Unknown action type: $actionType")
}
}
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.tag.SyncUserTagRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
@Factory
class UserTagSyncStrategy(
@Named(DbType.REMOTE) private val remote: UserTagRepository
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.USER_TAG
override suspend fun execute(actionType: String, payload: JsonElement) {
val payloadObj = payload.jsonObject
when (actionType) {
SyncUserTagRepository.ACTION_ADD -> {
val tag = payloadObj[SyncUserTagRepository.KEY_TAG]?.jsonPrimitive?.content
?: error("Missing ${SyncUserTagRepository.KEY_TAG}")
remote.add(tag)
}
SyncUserTagRepository.ACTION_EDIT -> {
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncUserTagRepository.KEY_ID}")
val newTag = payloadObj[SyncUserTagRepository.KEY_NEW_TAG]?.jsonPrimitive?.content
?: error("Missing ${SyncUserTagRepository.KEY_NEW_TAG}")
remote.edit(id, newTag)
}
SyncUserTagRepository.ACTION_DELETE -> {
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncUserTagRepository.KEY_ID}")
remote.delete(id)
}
else -> error("Unknown action type: $actionType")
}
}
}
@@ -0,0 +1,85 @@
package ru.shadowsparky.vbox.shared.data.tag
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.tag.LinkRequest
import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
@Named(DbType.REMOTE)
@Factory
class RemoteMovieTagRepository(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
@Named(EventType.MOVIE_TAG) private val movieTagListener: RemoteEventListener,
private val dispatcherProvider: DispatcherProvider
) : MovieTagRepository {
override fun query(movieId: Long): Flow<List<TagInfo>> {
return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>()
.filter { it.movieId == movieId }
.map { querySingle(movieId) }
.onStart { emit(querySingle(movieId)) }
.flowOn(dispatcherProvider.io)
}
private suspend fun querySingle(movieId: Long): List<TagInfo> {
return httpClient.get<TagResponse>(
MovieTagRepository.QUERY,
hashMapOf(MovieTagRepository.MOVIE_ID to movieId.toString()),
serverConfigurationProvider.getServerConfiguration()
).tags
}
override fun queryMovies(tagId: Long): Flow<Set<Long>> {
return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>()
.map { queryMoviesSingle(tagId) }
.onStart { emit(queryMoviesSingle(tagId)) }
.flowOn(dispatcherProvider.io)
}
private suspend fun queryMoviesSingle(tagId: Long): Set<Long> {
return httpClient.get<LinkedMovies>(
MovieTagRepository.QUERY_MOVIES,
hashMapOf(MovieTagRepository.TAG_ID to tagId.toString()),
serverConfigurationProvider.getServerConfiguration()
).ids
}
override suspend fun link(movieId: Long, tagId: Long) {
httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, MovieTagRepository.LINK)
setJsonBody(LinkRequest(movieId, tagId))
}
}
override suspend fun unlink(movieId: Long, tagId: Long) {
httpClient.delete {
serverConfigurationProvider.getServerConfiguration()
.apply(this, MovieTagRepository.UNLINK)
setJsonBody(LinkRequest(movieId, tagId))
}
}
}
@@ -0,0 +1,75 @@
package ru.shadowsparky.vbox.shared.data.tag
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.tag.AddRequest
import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest
import ru.shadowsparky.vbox.shared.domain.tag.EditRequest
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
@Factory
@Named(DbType.REMOTE)
class RemoteUserTagRepository(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
@Named(EventType.USER_TAG) private val userTagListener: RemoteEventListener,
private val dispatcherProvider: DispatcherProvider
) : UserTagRepository {
override val userTags: Flow<List<TagInfo>> = userTagListener.event.filterIsInstance<RemoteEvent.OnUserTag>()
.map { queryUserTagsSingle() }
.onStart { emit(queryUserTagsSingle()) }
.flowOn(dispatcherProvider.io)
private suspend fun queryUserTagsSingle(): List<TagInfo> {
return httpClient.get<TagResponse>(
UserTagRepository.LIST,
emptyMap(),
serverConfigurationProvider.getServerConfiguration()
).tags
}
override suspend fun add(tag: String) {
httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, UserTagRepository.ADD)
setJsonBody(AddRequest(tag))
}
}
override suspend fun edit(id: Long, newTag: String) {
httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, UserTagRepository.EDIT)
setJsonBody(EditRequest(id, newTag))
}
}
override suspend fun delete(id: Long) {
httpClient.delete {
serverConfigurationProvider.getServerConfiguration()
.apply(this, UserTagRepository.DELETE)
setJsonBody(DeleteRequest(id))
}
}
}
@@ -0,0 +1,71 @@
package ru.shadowsparky.vbox.shared.data.tag
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.MovieTagQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
@Named(DbType.LOCAL)
@Factory
class SqlMovieTagRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : MovieTagRepository {
override fun query(movieId: Long): Flow<List<TagInfo>> = flow {
emitAll(
getQueries().query(movieId = movieId)
.asFlow()
.mapToList(dispatcherProvider.io)
.map { tags ->
tags.map { tag ->
TagInfo(
id = tag.id,
tag = tag.tag
)
}
}
)
}
override fun queryMovies(tagId: Long): Flow<Set<Long>> = flow {
emitAll(
getQueries().queryMovies(tagId)
.asFlow()
.mapToList(dispatcherProvider.io)
.map { it.toSet() }
)
}
override suspend fun link(movieId: Long, tagId: Long) {
getQueries().link(
movieId = movieId,
tagId = tagId
)
}
override suspend fun unlink(movieId: Long, tagId: Long) {
getQueries().unlink(
movieId = movieId,
tagId = tagId
)
}
override suspend fun clear() {
getQueries().clear()
}
private suspend fun getQueries(): MovieTagQueries {
return dbProvider.get().movieTagQueries
}
}
@@ -0,0 +1,63 @@
package ru.shadowsparky.vbox.shared.data.tag
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.UserTagQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
@Named(DbType.LOCAL)
@Factory
class SqlUserTagRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : UserTagRepository {
override val userTags: Flow<List<TagInfo>> = flow {
emitAll(
getQueries().selectAll()
.asFlow()
.mapToList(dispatcherProvider.io)
.map { tags ->
tags.map { tag ->
TagInfo(
id = tag.id,
tag = tag.tag
)
}
}
)
}
override suspend fun add(tag: String) {
runCatching { getQueries().insert(tag) }
}
override suspend fun edit(id: Long, newTag: String) {
getQueries().update(
id = id,
newTag = newTag
)
}
override suspend fun delete(id: Long) {
runCatching { getQueries().delete(id) }
}
override suspend fun clear() {
getQueries().clear()
}
private suspend fun getQueries(): UserTagQueries {
return dbProvider.get().userTagQueries
}
}
@@ -0,0 +1,137 @@
package ru.shadowsparky.vbox.shared.data.tag
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.queryFlow
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
@Factory
class SyncMovieTagRepository(
@Named(DbType.LOCAL) private val local: MovieTagRepository,
@Named(DbType.REMOTE) private val remote: MovieTagRepository,
@Named(DbType.REMOTE) private val userTagRemote: UserTagRepository,
@Named(DbType.LOCAL) private val userTagLocal: UserTagRepository,
private val pendingRepo: PendingOperationRepository,
private val tokenStorage: TokenStorage,
private val syncRepository: SyncRepository,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : MovieTagRepository {
override fun query(movieId: Long): Flow<List<TagInfo>> {
return tokenStorage.queryFlow(remote.query(movieId), local.query(movieId)) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.MOVIE_TAG)
}
}
private fun createSync(): RemoteSourceSynchronizer<MovieTagLink, String> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.MOVIE_TAG) },
setSync = { syncRepository.set(AggregateType.MOVIE_TAG, true) },
authTokenCache = tokenStorage,
queryRemote = {
val allTags = userTagRemote.userTags.first()
allTags.flatMap { tag ->
val tagId = tag.id ?: return@flatMap emptyList()
remote.queryMovies(tagId).first().map { movieId ->
MovieTagLink(movieId = movieId, tagId = tagId)
}
}
},
queryLocal = {
val allTags = userTagLocal.userTags.first()
allTags.flatMap { tag ->
val tagId = tag.id ?: return@flatMap emptyList()
local.queryMovies(tagId).first().map { movieId ->
MovieTagLink(movieId = movieId, tagId = tagId)
}
}
},
writeRemote = { list ->
list.forEach { link -> remote.link(movieId = link.movieId, tagId = link.tagId) }
},
writeLocal = { list ->
list.forEach { link -> local.link(movieId = link.movieId, tagId = link.tagId) }
},
selectKey = { "${it.movieId}|${it.tagId}" }
)
}
@Serializable
data class MovieTagLink(
val movieId: Long,
val tagId: Long
)
override fun queryMovies(tagId: Long): Flow<Set<Long>> {
return tokenStorage.queryFlow(remote.queryMovies(tagId), local.queryMovies(tagId))
}
override suspend fun link(movieId: Long, tagId: Long) {
local.link(movieId, tagId)
if (tokenStorage.hasAccount()) {
try {
remote.link(movieId, tagId)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.MOVIE_TAG,
actionType = ACTION_LINK,
payload = buildPayload(movieId, tagId)
)
)
}
}
}
override suspend fun unlink(movieId: Long, tagId: Long) {
local.unlink(movieId, tagId)
if (tokenStorage.hasAccount()) {
try {
remote.unlink(movieId, tagId)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.MOVIE_TAG,
actionType = ACTION_UNLINK,
payload = buildPayload(movieId, tagId)
)
)
}
}
}
override suspend fun clear() {
local.clear()
if (tokenStorage.hasAccount()) remote.clear()
}
companion object {
const val ACTION_LINK = "LINK"
const val ACTION_UNLINK = "UNLINK"
const val KEY_MOVIE_ID = "movieId"
const val KEY_TAG_ID = "tagId"
private fun buildPayload(movieId: Long, tagId: Long) = buildJsonObject {
put(KEY_MOVIE_ID, movieId)
put(KEY_TAG_ID, tagId)
}
}
}
@@ -0,0 +1,128 @@
package ru.shadowsparky.vbox.shared.data.tag
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.queryFlow
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
@Factory
class SyncUserTagRepository(
@Named(DbType.LOCAL) private val local: UserTagRepository,
@Named(DbType.REMOTE) private val remote: UserTagRepository,
private val pendingRepo: PendingOperationRepository,
private val tokenStorage: TokenStorage,
private val syncRepository: SyncRepository,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : UserTagRepository {
override val userTags: Flow<List<TagInfo>> = tokenStorage.queryFlow(
remote.userTags,
local.userTags
) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.USER_TAG)
}
private fun createSync(): RemoteSourceSynchronizer<TagInfo, String> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.USER_TAG) },
setSync = { syncRepository.set(AggregateType.USER_TAG, true) },
authTokenCache = tokenStorage,
queryRemote = { remote.userTags.first() },
queryLocal = { local.userTags.first() },
writeRemote = { list ->
list.forEach { tagInfo -> remote.add(tagInfo.tag) }
},
writeLocal = { list ->
list.forEach { tagInfo -> local.add(tagInfo.tag) }
},
selectKey = { it.tag }
)
}
override suspend fun add(tag: String) {
local.add(tag)
if (tokenStorage.hasAccount()) {
try {
remote.add(tag)
} catch (_: Exception) {
val payload = buildJsonObject { put(KEY_TAG, tag) }
pendingRepo.insert(
OperationInfo(
type = AggregateType.USER_TAG,
actionType = ACTION_ADD,
payload = payload
)
)
}
}
}
override suspend fun edit(id: Long, newTag: String) {
local.edit(id, newTag)
if (tokenStorage.hasAccount()) {
try {
remote.edit(id, newTag)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_ID, id)
put(KEY_NEW_TAG, newTag)
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.USER_TAG,
actionType = ACTION_EDIT,
payload = payload
)
)
}
}
}
override suspend fun delete(id: Long) {
local.delete(id)
if (tokenStorage.hasAccount()) {
try {
remote.delete(id)
} catch (_: Exception) {
val payload = buildJsonObject { put(KEY_ID, id) }
pendingRepo.insert(
OperationInfo(
type = AggregateType.USER_TAG,
actionType = ACTION_DELETE,
payload = payload
)
)
}
}
}
override suspend fun clear() {
local.clear()
if (tokenStorage.hasAccount()) remote.clear()
}
companion object {
const val ACTION_ADD = "ADD"
const val ACTION_EDIT = "EDIT"
const val ACTION_DELETE = "DELETE"
const val KEY_ID = "id"
const val KEY_TAG = "tag"
const val KEY_NEW_TAG = "newTag"
}
}
@@ -0,0 +1,28 @@
package ru.shadowsparky.vbox.shared.data.token
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.RefreshTokenAction
import ru.shadowsparky.http.domain.TokenInfo
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.TokenRequest
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Factory
class RefreshTokenActionImpl(
private val serverConfigurationProvider: ServerConfigurationRepository
) : RefreshTokenAction {
override suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo {
val result = client.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, AuthTokenRepository.UPDATE_PATH)
setJsonBody(TokenRequest(refreshToken))
}
return result.body()
}
}
@@ -0,0 +1,39 @@
package ru.shadowsparky.vbox.shared.data.token
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.http.domain.TokenInfo
import ru.shadowsparky.http.domain.TokenStorage
@Factory
class TokenStorageImpl(
private val storage: KeyValueStorage
) : TokenStorage {
private val auth = storage.get(KEY)
private val refresh = storage.get(REFRESH_TOKEN)
override val realm = "server side data storage"
override val token: Flow<TokenInfo?> = auth.combine(refresh) { auth, refresh ->
auth ?: return@combine null
TokenInfo(auth, refresh)
}.distinctUntilChanged()
override suspend fun updateToken(newToken: TokenInfo) {
storage.put(KEY, newToken.token)
newToken.refresh?.let { storage.put(REFRESH_TOKEN, it) }
}
override suspend fun clear() {
storage.remove(KEY)
storage.remove(REFRESH_TOKEN)
}
private companion object {
const val KEY = "auth_token"
const val REFRESH_TOKEN = "refresh_token"
}
}
@@ -0,0 +1,6 @@
package ru.shadowsparky.vbox.shared.di
object DbType {
const val REMOTE = "remote"
const val LOCAL = "local"
}
@@ -0,0 +1,53 @@
package ru.shadowsparky.vbox.shared.di
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.RemoteEventListenerFactory
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
@Module
class EventsModule {
@Factory
@Named(EventType.RECENT)
fun provideRecentRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.RECENTLY_CHANGED)
}
@Factory
@Named(EventType.SEARCH)
fun provideSearchRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.SEARCH_CHANGED)
}
@Factory
@Named(EventType.SAVED)
fun provideSavedRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.SAVED_CHANGED)
}
@Factory
@Named(EventType.MOVIE_TAG)
fun provideMovieTagRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.MOVIE_TAG_CHANGED)
}
@Factory
@Named(EventType.USER_TAG)
fun provideUserTagRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.USER_TAG_CHANGED)
}
}
@@ -0,0 +1,31 @@
package ru.shadowsparky.vbox.shared.di
import io.ktor.client.HttpClient
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.data.api.FallbackVideoApi
import ru.shadowsparky.vbox.shared.data.api.PosterWithoutSuffixVideoApi
import ru.shadowsparky.vbox.shared.data.api.VideoApiImpl
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.VideoApi
@Module
class HttpModule {
@Single
fun provideVideoApi(
httpClient: HttpClient,
serverConfigurationProvider: ServerConfigurationRepository,
json: Json,
dbProvider: AppDatabaseProvider,
dispatcherProvider: DispatcherProvider
): VideoApi {
val impl = VideoApiImpl(httpClient, serverConfigurationProvider)
return PosterWithoutSuffixVideoApi(
FallbackVideoApi(dbProvider, impl, json, dispatcherProvider)
)
}
}
@@ -0,0 +1,40 @@
package ru.shadowsparky.vbox.shared.di
import io.ktor.client.HttpClient
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.data.saved.PosterWithoutSuffixSavedMovieRepository
import ru.shadowsparky.vbox.shared.data.saved.RemoteSavedMovieRepository
import ru.shadowsparky.vbox.shared.data.saved.SqlSavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
@Module
class SavedModule {
@Factory
@Named(DbType.LOCAL)
fun provideSavedLocalRepository(
dbProvider: AppDatabaseProvider,
dispatcherProvider: DispatcherProvider
): SavedMovieRepository {
val sql = SqlSavedMovieRepository(dbProvider, dispatcherProvider)
return PosterWithoutSuffixSavedMovieRepository(sql)
}
@Factory
@Named(DbType.REMOTE)
fun provideSavedRemoteRepository(
httpClient: HttpClient,
serverConfigurationRepository: ServerConfigurationRepository,
@Named(EventType.SAVED) eventListener: RemoteEventListener
): SavedMovieRepository {
val impl = RemoteSavedMovieRepository(httpClient, serverConfigurationRepository, eventListener)
return PosterWithoutSuffixSavedMovieRepository(impl)
}
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.vbox.shared.di.factory
import app.cash.sqldelight.db.SqlDriver
interface DatabaseDriverFactory {
suspend fun createDriver(): SqlDriver
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.vbox.shared.di.factory
import com.seiko.imageloader.ImageLoader
interface ImageLoaderProvider {
fun provide(): ImageLoader
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.vbox.shared.domain
import org.koin.core.annotation.Factory
@Factory
class AddOfflineVideoUseCase(private val offlineStorage: OfflineStorage?) {
suspend fun add(item: OfflineVideo, uri: String) {
offlineStorage ?: return
val old = offlineStorage.find(item.movieId, item.seasonId, item.episodeId)
if (old == null) {
offlineStorage.add(item, uri)
}
}
}
@@ -0,0 +1,9 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.serialization.json.JsonElement
interface AggregateSyncStrategy {
val aggregateType: AggregateType
suspend fun execute(actionType: String, payload: JsonElement)
}
@@ -0,0 +1,45 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
suspend fun TokenStorage.use(
active: suspend () -> Unit,
fallback: suspend () -> Unit
) {
if (hasAccount()) {
runCatching { active() }.onFailure { fallback() }
} else {
fallback()
}
}
fun <T> TokenStorage.queryFlow(
active: Flow<T>,
fallback: Flow<T>,
init: suspend () -> Unit = {}
): Flow<T> {
return flow {
init()
emitAll(if (hasAccount()) {
active.catch { emitAll(fallback) }
} else {
fallback
})
}
}
suspend fun <T> TokenStorage.query(
active: suspend () -> T,
fallback: suspend () -> T
): T {
return if (hasAccount()) {
runCatching { active() }.getOrDefault(fallback())
} else {
fallback()
}
}
@@ -0,0 +1,41 @@
package ru.shadowsparky.vbox.shared.domain
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import kotlinx.coroutines.flow.firstOrNull
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.LogoutAction
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
@Factory
class LogoutUseCase(
private val syncRepository: SyncRepository,
@Named(DbType.LOCAL) private val localSearchRepo: SearchRepository,
@Named(DbType.LOCAL) private val localRecentlyRepo: RecentlyWatchedRepository,
@Named(DbType.LOCAL) private val localSavedMovieRepository: SavedMovieRepository,
@Named(DbType.LOCAL) private val localMovieTagRepository: MovieTagRepository,
@Named(DbType.LOCAL) private val localUserTagRepository: UserTagRepository,
private val bearerAuthProvider: BearerAuthProvider,
private val tokenStorage: TokenStorage,
private val authTokenRepository: AuthTokenRepository,
private val pendingOperationRepository: PendingOperationRepository
) : LogoutAction {
override suspend fun logout() {
tokenStorage.token.firstOrNull()?.refresh?.let {
runCatching { authTokenRepository.revoke(TokenRequest(it)) }
}
tokenStorage.clear()
localSearchRepo.clear()
localRecentlyRepo.clear()
localSavedMovieRepository.clear()
bearerAuthProvider.clearToken()
localMovieTagRepository.clear()
localUserTagRepository.clear()
AggregateType.entries.forEach { syncRepository.set(it, false) }
pendingOperationRepository.clear()
}
}
@@ -0,0 +1,40 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.Flow
interface OfflineStorage {
val videos: Flow<List<OfflineVideo>>
suspend fun query(id: Long): Flow<OfflineVideo?>
fun queryByArgs(movieId: Long, seasonId: Long? = null): Flow<List<OfflineVideo>>
suspend fun find(movieId: Long, seasonId: Long? = null, episodeId: Long? = null): OfflineVideo?
suspend fun add(item: OfflineVideo, uri: String)
suspend fun remove(item: OfflineVideo)
}
data class OfflineVideo(
val id: Long? = null,
val movieId: Long,
val seasonId: Long? = null,
val episodeId: Long? = null,
val state: OfflineState = OfflineState.Pending
)
sealed interface OfflineState {
data object Pending : OfflineState
data class Downloading(
val isPaused: Boolean,
val progress: Int
) : OfflineState
data class Error(
val msg: String
) : OfflineState
data class Available(
val uri: String
) : OfflineState
}
@@ -0,0 +1,96 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.model.Episode
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
@Factory
class OfflineVideoInteractor(
private val videoApi: VideoApi,
private val offlineStorage: OfflineStorage?
) {
private val detailsCache = hashMapOf<Long, VideoDetails>()
private val linksCache = hashMapOf<LinksKey, VideoLinksResponse>()
val info: Flow<List<OfflineVideoInfo>> = offlineStorage?.videos
?.map { videos ->
videos.groupBy { it.movieId }
.map { info ->
val list = info.value.groupBy { it.seasonId }
.mapNotNull { seasonEntry ->
val seasonId = seasonEntry.key
val season = fetchVideoLinks(info.key, seasonId).seasons
?.firstOrNull()
if (seasonId == null || season == null) {
null
} else {
SeasonOfflineVideo(
seasonId,
season.title,
seasonEntry.value.mapNotNull { episode ->
val links = fetchVideoLinks(episode.movieId, seasonId).seasons
?.firstOrNull()
?.episodes?.firstOrNull { it.episode == episode.episodeId }
if (links == null) {
null
} else {
EpisodeOfflineVideo(links, episode)
}
}.sortedBy { it.episode.episode }
)
}
}.sortedBy { it.seasonId }
val details = fetchDetails(info.key)
OfflineVideoInfo(
info.key,
details.title,
list,
if (list.isEmpty()) {
offlineStorage.find(info.key)
} else {
null
}
)
}
} ?: emptyFlow()
suspend fun remove(video: OfflineVideo) {
offlineStorage?.remove(video)
}
private suspend fun fetchDetails(id: Long): VideoDetails {
detailsCache[id]?.let { return it }
return videoApi.fetchDetails(id).apply { detailsCache[id] = this }
}
private suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
linksCache[LinksKey(id, seasonId)]?.let { return it }
return videoApi.fetchVideoLinks(id, seasonId).apply {
linksCache[LinksKey(id, seasonId)] = this
}
}
private data class LinksKey(val id: Long, val sessionId: Long?)
}
data class OfflineVideoInfo(
val movieId: Long,
val title: String,
val seasons: List<SeasonOfflineVideo>,
val movieState: OfflineVideo?
)
data class SeasonOfflineVideo(
val seasonId: Long,
val title: String,
val episodes: List<EpisodeOfflineVideo>
)
data class EpisodeOfflineVideo(
val episode: Episode,
val video: OfflineVideo
)
@@ -0,0 +1,12 @@
package ru.shadowsparky.vbox.shared.domain
enum class OsType {
JVM,
ANDROID,
IOS,
JS
}
expect fun getCurrentOsType(): OsType
val isJs: Boolean get() = getCurrentOsType() == OsType.JS
@@ -0,0 +1,25 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.serialization.json.JsonElement
interface PendingOperationRepository {
suspend fun delete(id: Long)
suspend fun query(type: AggregateType): List<OperationInfo>
suspend fun insert(operationInfo: OperationInfo)
suspend fun clear()
}
data class OperationInfo(
val type: AggregateType,
val actionType: String,
val payload: JsonElement,
val id: Long = 0
)
enum class AggregateType {
MOVIE_TAG,
USER_TAG,
SEARCH,
RECENTLY_WATCHED,
SAVED_MOVIE
}
@@ -0,0 +1,191 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.model.Episode
import ru.shadowsparky.vbox.shared.domain.model.File
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
import ru.shadowsparky.videoplayer.ExternalPlayer
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.PlayerEvent
import ru.shadowsparky.videoplayer.PlayerState
import kotlin.math.abs
@Factory
class PlayerInteractor(
private val videoApi: VideoApi,
private val playerSettingsStorge: PlayerSettingsStorge,
private val externalPlayer: ExternalPlayer,
val playerController: PlayerController,
private val recentlyWatchedRepository: RecentlyWatchedRepository,
private val savedTimeRepository: SavedTimeRepository,
private val offlineStorage: OfflineStorage?
) {
private var lastMillis = 0L
val state: Flow<PlayerState> = playerController.state
.onEach {
if (abs(it.currentPositionMs - lastMillis) > 5_000) saveTime(it)
}
val events: Flow<PlayerEvent> = playerController.events
val useExternalPlayer = playerSettingsStorge.useExternalPlayer
private val playMutex = Mutex()
private var cache = Cache()
val canPlayNextEpisode: Boolean
get() {
val nextIndex = (cache.currentEpisodeIndex ?: return false) + 1
return cache.episodes?.getOrNull(nextIndex) != null
}
val savedMillis get() = cache.savedMillis ?: 0L
suspend fun play(
movieId: Long?,
seasonId: Long?,
episodeId: Long?,
startTime: Long?,
url: String?
): Boolean = playMutex.withLock {
cache = cache.copy(movieId = movieId, seasonId = seasonId)
val offlineUrl = url ?: loadOfflineUrl(movieId, seasonId, episodeId)
return if (seasonId == null || episodeId == null) {
val url = offlineUrl ?: fetchLinks(movieId, seasonId).files.loadUrl()
play(url, startTime)
} else {
val episodes = loadEpisodes(movieId, seasonId)
val index = episodes.indexOfFirst { it.episode == episodeId }
if (index == -1) error("Unable to find current episode id")
cache = cache.copy(currentEpisodeIndex = index)
play(offlineUrl ?: episodes[index].files.loadUrl(), startTime)
}
}
private suspend fun loadOfflineUrl(
movieId: Long?,
seasonId: Long?,
episodeId: Long?,
): String? {
movieId ?: return null
val isExternalPlayer = useExternalPlayer.firstOrNull() ?: true
return if (!isExternalPlayer) {
val state = offlineStorage?.find(movieId, seasonId, episodeId)?.state
(state as? OfflineState.Available)?.uri
} else null
}
suspend fun playNext(): Boolean {
if (playMutex.isLocked) return true
playMutex.withLock {
val nextIndex = (cache.currentEpisodeIndex ?: return false) + 1
val nextEpisode = cache.episodes?.getOrNull(nextIndex) ?: return false
cache = cache.copy(currentEpisodeIndex = nextIndex)
return play(
loadOfflineUrl(
cache.movieId ?: return false,
cache.seasonId,
nextEpisode.episode
) ?: nextEpisode.files.loadUrl(), 0L
)
}
}
private suspend fun saveWatched(episodeId: Long) {
recentlyWatchedRepository.write(
RecentlyWatchedInfo(
cache.movieId ?: return,
episodeId,
cache.seasonId
)
)
}
suspend fun release() {
playerController.release()
}
suspend fun pause() {
playerController.pause()
}
private suspend fun saveTime(state: PlayerState) {
with(cache) {
movieId?.let {
savedTimeRepository.setTime(
SavedTimeInfo(
movieId,
seasonId,
currentEpisodeIndex?.let { episodes?.getOrNull(it) }?.episode,
state.currentPositionMs
)
)
lastMillis = state.currentPositionMs
}
}
}
private fun List<File>?.loadUrl(): String {
return this?.firstOrNull()?.url ?: error("Unable to load video url")
}
private suspend fun play(url: String, startTime: Long?): Boolean {
cache.episodes?.let {
cache.currentEpisodeIndex?.let { idx -> saveWatched(it[idx].episode) }
}
if (useExternalPlayer.firstOrNull() ?: true) {
externalPlayer.play(url)
return false
} else {
loadTime()
playerController.load(url, startTime ?: state.first().currentPositionMs)
playerController.play()
}
return true
}
private suspend fun loadTime() {
with(cache) {
val millis = movieId?.let {
savedTimeRepository.query(
movieId,
seasonId,
currentEpisodeIndex?.let { episodes?.getOrNull(it) }?.episode
)
}
cache = cache.copy(savedMillis = millis)
}
}
private suspend fun loadEpisodes(movieId: Long?, seasonId: Long): List<Episode> {
cache.episodes?.let { return it }
checkNotNull(movieId) { "movieId required" }
val links = videoApi.fetchVideoLinks(movieId, seasonId)
val season = links.seasons?.firstOrNull()
return season?.episodes.apply {
cache = cache.copy(episodes = this)
} ?: error("Unable to load episodes")
}
private suspend fun fetchLinks(movieId: Long?, seasonId: Long?): VideoLinksResponse {
cache.response?.let { return it }
checkNotNull(movieId) { "movieId required" }
return videoApi.fetchVideoLinks(movieId, seasonId).apply {
cache = cache.copy(response = this)
}
}
private data class Cache(
val movieId: Long? = null,
val seasonId: Long? = null,
val episodes: List<Episode>? = null,
val currentEpisodeIndex: Int? = null,
val response: VideoLinksResponse? = null,
val savedMillis: Long? = null
)
}
@@ -0,0 +1,9 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.Flow
interface PlayerSettingsStorge {
val useExternalPlayer: Flow<Boolean?>
suspend fun setUseExternalPlayer(use: Boolean)
}
@@ -0,0 +1,13 @@
package ru.shadowsparky.vbox.shared.domain
data class SavedTimeInfo(
val movieId: Long,
val seasonId: Long?,
val episodeId: Long?,
val time: Long
)
interface SavedTimeRepository {
suspend fun query(movieId: Long, seasonId: Long?, episodeId: Long?): Long?
suspend fun setTime(time: SavedTimeInfo)
}
@@ -0,0 +1,14 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
interface ServerConfigurationRepository {
val serverConfiguration: Flow<ServerConfiguration>
suspend fun setServerConfiguration(conf: ServerConfiguration)
}
suspend fun ServerConfigurationRepository.getServerConfiguration(): ServerConfiguration {
return serverConfiguration.first()
}
@@ -0,0 +1,6 @@
package ru.shadowsparky.vbox.shared.domain
interface SyncRepository {
suspend fun isSynchronized(type: AggregateType): Boolean
suspend fun set(type: AggregateType, state: Boolean)
}
@@ -0,0 +1,118 @@
package ru.shadowsparky.vbox.shared.presentation
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.seiko.imageloader.LocalImageLoader
import ru.shadowsparky.koin
import ru.shadowsparky.ui.components.BackButton
import ru.shadowsparky.ui.nav.Nav
import ru.shadowsparky.vbox.shared.presentation.list.VideoScreen
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingScreen
import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoScreen
import ru.shadowsparky.vbox.shared.presentation.overview.OverviewScreen
import ru.shadowsparky.vbox.shared.presentation.overview.episode.EpisodesOverviewScreen
import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonOverviewScreen
import ru.shadowsparky.vbox.shared.presentation.player.PlayerScreen
import ru.shadowsparky.vbox.shared.presentation.settings.auth.AuthScreen
import ru.shadowsparky.vbox.shared.presentation.settings.pass.EditPasswordScreen
import ru.shadowsparky.vbox.shared.presentation.settings.register.RegisterScreen
import ru.shadowsparky.vbox.shared.presentation.settings.serv.EditServScreen
import ru.shadowsparky.vbox.shared.presentation.settings.tag.TagScreen
@Composable
fun getColorScheme(): ColorScheme {
return ru.shadowsparky.ui.theme.getColorScheme()
}
@Composable
fun Modifier.imePaddingInternal(): Modifier {
return koin.get<UiConfiguration>().imePadding(this)
}
@Composable
fun Nav(rootComponent: RootComponent) {
CompositionLocalProvider(
LocalImageLoader provides remember { rootComponent.imageLoaderProvider.provide() }
) { NavInternal(rootComponent) }
}
@Composable
private fun NavInternal(rootComponent: RootComponent) {
Nav(rootComponent) { child, route ->
when (child) {
is Child.PlayerChild -> PlayerScreen(child.component)
is Child.OverviewChild -> OverviewScreen(child.component)
is Child.SeasonOverviewChild -> SeasonOverviewScreen(child.component)
is Child.EpisodeOverviewChild -> EpisodesOverviewScreen(child.component)
is Child.EditServerAddrChild -> EditServScreen(child.component)
is Child.TagChild -> TagScreen(child.component)
is Child.AuthChild -> AuthScreen(child.component)
is Child.RegisterChild -> RegisterScreen(child.component)
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
is Child.LoadingChild -> LoadingScreen(child.component)
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
is Child.VideosChild,
is Child.SearchChild,
is Child.SettingsChild,
is Child.SavedMovieChild -> {
if (child is Child.VideosChild && child.component.search != null) {
VideoScreen(child.component)
} else {
MainScreen(
route as Route,
child,
rootComponent
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SimpleAppBar(
title: String,
doBack: (() -> Unit)?,
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
doBack?.let { BackButton(it) }
},
scrollBehavior = scrollBehavior,
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BaseScreen(
scrollBehavior: TopAppBarScrollBehavior? = null,
snackbarState: SnackbarHostState? = null,
modifier: Modifier = Modifier,
content: @Composable (padding: PaddingValues) -> Unit,
appBar: @Composable () -> Unit
) {
ru.shadowsparky.ui.BaseScreen(
modifier,
scrollBehavior,
snackbarState,
getColorScheme(),
content = content,
appBar = { appBar() }
)
}
@@ -0,0 +1,41 @@
package ru.shadowsparky.vbox.shared.presentation
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.home
import androiddev.apps.vbox.client.shared.generated.resources.recommendations
import androiddev.apps.vbox.client.shared.generated.resources.saved_movies
import androiddev.apps.vbox.client.shared.generated.resources.search
import androiddev.apps.vbox.client.shared.generated.resources.settings
import androiddev.apps.vbox.client.shared.generated.resources.star
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.NavigationEntry
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@Composable
fun createNavigationList(): List<NavigationEntry<Route>> {
val list = listOf(
NavigationEntry(
Route.default,
stringResource(Res.string.recommendations),
painterResource(Res.drawable.home)
),
NavigationEntry(
Route.Bookmark(),
stringResource(Res.string.saved_movies),
painterResource(Res.drawable.star)
),
NavigationEntry(
Route.SearchVideos(),
stringResource(Res.string.search),
painterResource(Res.drawable.search)
),
NavigationEntry(
Route.Settings(),
stringResource(Res.string.settings),
painterResource(Res.drawable.settings)
)
)
return list
}
@@ -0,0 +1,11 @@
package ru.shadowsparky.vbox.shared.presentation
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.url_copied
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
@Composable
fun getClipboardText(): String {
return stringResource(Res.string.url_copied)
}
@@ -0,0 +1,55 @@
package ru.shadowsparky.vbox.shared.presentation
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.server_not_responding
import androiddev.apps.vbox.client.shared.generated.resources.unknown_error
import com.arkivanov.essenty.lifecycle.LifecycleOwner
import com.arkivanov.essenty.lifecycle.doOnDestroy
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.io.IOException
import org.jetbrains.compose.resources.getString
import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.HttpException
import ru.shadowsparky.koin
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
fun LifecycleOwner.createComponentScope(onDestroy: suspend () -> Unit = {}): CoroutineScope {
val main = koin.getOrNull<DispatcherProvider>()?.main ?: Dispatchers.Main
val scope = CoroutineScope(SupervisorJob() + main)
lifecycle.doOnDestroy {
scope.launch {
onDestroy()
scope.cancel()
}
}
return scope
}
fun createExceptionHandler(
scope: CoroutineScope,
onError: suspend (String) -> Unit
): CoroutineExceptionHandler {
return CoroutineExceptionHandler { _, throwable ->
scope.launch {
koin.getOrNull<Log>()?.e("Error", throwable)
when (throwable) {
is HttpException -> {
onError(throwable.message ?: throwable.httpCode.toString())
}
else -> {
val code = when (throwable) {
is IOException -> Res.string.server_not_responding
else -> Res.string.unknown_error
}
onError(getString(code))
}
}
}
}
}
@@ -0,0 +1,136 @@
package ru.shadowsparky.vbox.shared.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.input.TextFieldValue
import ru.shadowsparky.ui.BaseScreen
import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.USE_EXPERIMENTAL_RAIL
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.currentWindowType
import ru.shadowsparky.ui.isCompact
import ru.shadowsparky.ui.isExpanded
import ru.shadowsparky.ui.rememberBlurState
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.presentation.list.VideoContent
import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieContent
import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar
import ru.shadowsparky.vbox.shared.presentation.search.SearchVideoContent
import ru.shadowsparky.vbox.shared.presentation.settings.SettingsContent
@Composable
fun MainScreen(
route: Route,
child: Child,
rootComponent: RootComponent
) {
if (child is Child.SearchChild) {
SearchScreenInternal(child, route, rootComponent)
} else {
MainScreenInternal(route, child, rootComponent)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchScreenInternal(
child: Child.SearchChild,
route: Route,
rootComponent: RootComponent,
) {
val component = child.component
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val requester by remember { mutableStateOf(FocusRequester()) }
val windowType = currentWindowType()
val searchAppBar: (@Composable () -> Unit) = {
SearchAppBar(
textFieldValue = textFieldValue,
onDone = { component.done(textFieldValue.text) },
onValueChange = { textFieldValue = it },
focusRequester = requester
)
}
val blurState = rememberBlurState()
CompositionLocalProvider(LocalBlurState provides blurState) {
BaseScreen(
appBar = {
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) searchAppBar()
},
currentRoute = route,
onSelectNavigation = { rootComponent.nav(it, true) },
navigationEntries = createNavigationList(),
colorScheme = getColorScheme(),
scrollBehavior = scrollBehavior
) {
val content: @Composable () -> Unit = {
SearchVideoContent(
child.component,
it,
requester
) { textFieldValue = it }
}
if (windowType.isExpanded && USE_EXPERIMENTAL_RAIL) {
Column {
searchAppBar()
content()
}
} else {
content()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreenInternal(route: Route, child: Child, rootComponent: RootComponent) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val navList = createNavigationList()
val windowType = currentWindowType()
val blurState = rememberBlurState()
CompositionLocalProvider(LocalBlurState provides blurState) {
BaseScreen(
appBar = {
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) {
navList.firstOrNull { it.route == route }?.title?.let {
LargeTopAppBar(
title = { Text(it) },
scrollBehavior = scrollBehavior,
modifier = Modifier.applyBlurEffect(blurState),
colors = ComponentDefaults.topAppBarColors()
)
}
}
},
currentRoute = route,
onSelectNavigation = { rootComponent.navReplace(it) },
navigationEntries = navList,
colorScheme = getColorScheme(),
scrollBehavior = scrollBehavior
) { padding ->
when (child) {
is Child.VideosChild -> VideoContent(child.component, padding)
is Child.SettingsChild -> SettingsContent(child.component, padding)
is Child.SavedMovieChild -> SavedMovieContent(child.component, padding)
else -> {}
}
}
}
}
@@ -0,0 +1,76 @@
package ru.shadowsparky.vbox.shared.presentation
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.router.stack.childStackWebNavigation
import com.arkivanov.decompose.router.webhistory.WebNavigation
import com.arkivanov.decompose.router.webhistory.WebNavigationOwner
import kotlinx.coroutines.flow.map
import kotlinx.serialization.KSerializer
import org.koin.core.Koin
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider
import ru.shadowsparky.vbox.shared.domain.HeathCheck
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalDecomposeApi::class)
class RootComponent(
componentContext: ComponentContext,
tokenStorage: TokenStorage,
override val initStack: List<Route>,
private val healthCheck: HeathCheck,
val imageLoaderProvider: ImageLoaderProvider,
override val serializer: KSerializer<Route> = Route.serializer(),
override val koin: Koin = ru.shadowsparky.koin
) : BaseRootComponent<Route>(componentContext), WebNavigationOwner {
val isLoggedIn = tokenStorage.token.map {
if (it != null) {
runCatching { healthCheck.check() }.getOrNull() ?: return@map false
true
} else {
false
}
}
override val webNavigation: WebNavigation<*> =
childStackWebNavigation(
navigator = nav,
stack = childStack,
serializer = Route.serializer(),
pathMapper = { child ->
UrlRoutes.build(child.configuration).path
},
parametersMapper = { child ->
UrlRoutes.build(child.configuration).params
}
)
}
@Factory
class RootFactory(
private val tokenStorage: TokenStorage,
private val heathCheck: HeathCheck,
private val imageLoaderProvider: ImageLoaderProvider
) {
fun create(
context: ComponentContext,
initStack: Route
): RootComponent {
return create(context, listOf(initStack))
}
fun create(
context: ComponentContext,
initStack: List<Route> = listOf(Route.default)
): RootComponent {
return RootComponent(
context,
tokenStorage,
listOf(Route.Loading(next = initStack.toSet())),
heathCheck,
imageLoaderProvider
)
}
}
@@ -0,0 +1,8 @@
package ru.shadowsparky.vbox.shared.presentation
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
interface ShareManager {
fun shareDetails(details: VideoDetails)
fun canShare(): Boolean
}
@@ -0,0 +1,13 @@
package ru.shadowsparky.vbox.shared.presentation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.koin.core.annotation.Single
@Single
class UiConfiguration {
@Composable
fun imePadding(modifier: Modifier): Modifier {
return modifier
}
}

Some files were not shown because too many files have changed in this diff Show More