Initial commit: LCK Control Phone App
Native Android phone app with Kotlin + Jetpack Compose + Material 3 dark theme. Features: TikTok-style video feed (public, no auth required), pairing code login, stream management, social profiles, P2P WebRTC device communication, file transfer. Feed starts without auth; social actions and other tabs gate behind pairing.
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/build
|
||||
*.hprof
|
||||
132
app/build.gradle.kts
Normal file
132
app/build.gradle.kts
Normal file
@@ -0,0 +1,132 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.omixlab.lckcontrol.app"
|
||||
compileSdk {
|
||||
version = release(36) {
|
||||
minorApiLevel = 1
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = rootProject.file("lck-control-app.keystore")
|
||||
storePassword = "4gx%wx4NOhS6"
|
||||
keyAlias = "lck-control-app"
|
||||
keyPassword = "4gx%wx4NOhS6"
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.omixlab.lckcontrol.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += setOf(
|
||||
"META-INF/DEPENDENCIES",
|
||||
"META-INF/LICENSE",
|
||||
"META-INF/LICENSE.txt",
|
||||
"META-INF/NOTICE",
|
||||
"META-INF/NOTICE.txt",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
// Compose
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// Networking
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.converter.moshi)
|
||||
implementation(libs.moshi.kotlin)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
// Security
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
// Browser (Custom Tabs for OAuth flows)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// Media3 (ExoPlayer)
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.media3.datasource)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
|
||||
// Image Loading
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// WebRTC
|
||||
implementation(libs.webrtc)
|
||||
|
||||
// Test
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
10
app/proguard-rules.pro
vendored
Normal file
10
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in the Android SDK tools directory.
|
||||
|
||||
# Keep Moshi adapters
|
||||
-keep class com.omixlab.lckcontrol.app.data.remote.** { *; }
|
||||
-keepclassmembers class com.omixlab.lckcontrol.app.data.remote.** { *; }
|
||||
|
||||
# Keep Room entities
|
||||
-keep class com.omixlab.lckcontrol.app.data.local.entity.** { *; }
|
||||
65
app/src/main/AndroidManifest.xml
Normal file
65
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".LckControlPhoneApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LCKControlApp"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.LCKControlApp">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- YouTube OAuth redirect handler -->
|
||||
<activity
|
||||
android:name=".auth.YouTubeAuthRedirectActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="com.omixlab.lckcontrol.app"
|
||||
android:host="youtube"
|
||||
android:pathPrefix="/callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Twitch OAuth redirect handler -->
|
||||
<activity
|
||||
android:name=".auth.TwitchAuthRedirectActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="com.omixlab.lckcontrol.app"
|
||||
android:host="twitch"
|
||||
android:pathPrefix="/callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.omixlab.lckcontrol.app
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class LckControlPhoneApp : Application()
|
||||
27
app/src/main/java/com/omixlab/lckcontrol/app/MainActivity.kt
Normal file
27
app/src/main/java/com/omixlab/lckcontrol/app/MainActivity.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.omixlab.lckcontrol.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.omixlab.lckcontrol.app.ui.navigation.AppNavigation
|
||||
import com.omixlab.lckcontrol.app.ui.theme.LCKControlAppTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
LCKControlAppTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.omixlab.lckcontrol.app.auth
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
class TwitchAuthRedirectActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val forwardIntent = Intent(this, Class.forName("com.omixlab.lckcontrol.app.MainActivity"))
|
||||
forwardIntent.data = intent.data
|
||||
forwardIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
startActivity(forwardIntent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.omixlab.lckcontrol.app.auth
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
class YouTubeAuthRedirectActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Forward the deep link intent to MainActivity
|
||||
val forwardIntent = Intent(this, Class.forName("com.omixlab.lckcontrol.app.MainActivity"))
|
||||
forwardIntent.data = intent.data
|
||||
forwardIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
startActivity(forwardIntent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.omixlab.lckcontrol.app.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppPreferences @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var feedFilter: String
|
||||
get() = prefs.getString(KEY_FEED_FILTER, "trending") ?: "trending"
|
||||
set(value) = prefs.edit().putString(KEY_FEED_FILTER, value).apply()
|
||||
|
||||
var darkTheme: Boolean
|
||||
get() = prefs.getBoolean(KEY_DARK_THEME, true)
|
||||
set(value) = prefs.edit().putBoolean(KEY_DARK_THEME, value).apply()
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "lck_control_app_prefs"
|
||||
private const val KEY_FEED_FILTER = "feed_filter"
|
||||
private const val KEY_DARK_THEME = "dark_theme"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.omixlab.lckcontrol.app.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.FeedItemDao
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.LinkedAccountDao
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.UserProfileDao
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.CachedFeedItemEntity
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.CachedUserProfileEntity
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.LinkedAccountEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
CachedFeedItemEntity::class,
|
||||
CachedUserProfileEntity::class,
|
||||
LinkedAccountEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class LckPhoneDatabase : RoomDatabase() {
|
||||
abstract fun feedItemDao(): FeedItemDao
|
||||
abstract fun userProfileDao(): UserProfileDao
|
||||
abstract fun linkedAccountDao(): LinkedAccountDao
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.omixlab.lckcontrol.app.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TokenStore @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
|
||||
private val prefs: SharedPreferences = try {
|
||||
createEncryptedPrefs(context)
|
||||
} catch (e: Exception) {
|
||||
Log.w("TokenStore", "Corrupted keyset, clearing and recreating", e)
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().clear().commit()
|
||||
createEncryptedPrefs(context)
|
||||
}
|
||||
|
||||
private fun createEncryptedPrefs(context: Context): SharedPreferences =
|
||||
EncryptedSharedPreferences.create(
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
fun getJwt(): String? = prefs.getString(KEY_JWT, null)
|
||||
|
||||
fun getRefreshToken(): String? = prefs.getString(KEY_REFRESH_TOKEN, null)
|
||||
|
||||
fun saveSession(jwt: String, refreshToken: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_JWT, jwt)
|
||||
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clearSession() {
|
||||
prefs.edit()
|
||||
.remove(KEY_JWT)
|
||||
.remove(KEY_REFRESH_TOKEN)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun isLoggedIn(): Boolean = getJwt() != null
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "lck_control_app_tokens"
|
||||
private const val KEY_JWT = "session_jwt"
|
||||
private const val KEY_REFRESH_TOKEN = "session_refresh_token"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.omixlab.lckcontrol.app.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.CachedFeedItemEntity
|
||||
|
||||
@Dao
|
||||
interface FeedItemDao {
|
||||
|
||||
@Query("SELECT * FROM cached_feed_items WHERE filter = :filter ORDER BY sortOrder ASC")
|
||||
suspend fun getByFilter(filter: String): List<CachedFeedItemEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(items: List<CachedFeedItemEntity>)
|
||||
|
||||
@Query("DELETE FROM cached_feed_items WHERE filter = :filter")
|
||||
suspend fun deleteByFilter(filter: String)
|
||||
|
||||
@Query("DELETE FROM cached_feed_items")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.omixlab.lckcontrol.app.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.LinkedAccountEntity
|
||||
|
||||
@Dao
|
||||
interface LinkedAccountDao {
|
||||
|
||||
@Query("SELECT * FROM cached_linked_accounts")
|
||||
suspend fun getAll(): List<LinkedAccountEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(accounts: List<LinkedAccountEntity>)
|
||||
|
||||
@Query("DELETE FROM cached_linked_accounts")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.omixlab.lckcontrol.app.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.CachedUserProfileEntity
|
||||
|
||||
@Dao
|
||||
interface UserProfileDao {
|
||||
|
||||
@Query("SELECT * FROM cached_user_profiles WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedUserProfileEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(profile: CachedUserProfileEntity)
|
||||
|
||||
@Query("DELETE FROM cached_user_profiles WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM cached_user_profiles")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.omixlab.lckcontrol.app.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "cached_feed_items")
|
||||
data class CachedFeedItemEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val type: String, // "stream" or "video"
|
||||
val planId: String?,
|
||||
val videoId: String?,
|
||||
val title: String?,
|
||||
val previewUrl: String?,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
val isLiked: Boolean,
|
||||
val userId: String?,
|
||||
val userDisplayName: String?,
|
||||
val userAvatarUrl: String?,
|
||||
val gameId: String?,
|
||||
val status: String?,
|
||||
val filter: String,
|
||||
val sortOrder: Int,
|
||||
val cachedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.omixlab.lckcontrol.app.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "cached_user_profiles")
|
||||
data class CachedUserProfileEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val displayName: String?,
|
||||
val email: String?,
|
||||
val avatarUrl: String?,
|
||||
val bio: String?,
|
||||
val isPublic: Boolean,
|
||||
val cachedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.omixlab.lckcontrol.app.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "cached_linked_accounts")
|
||||
data class LinkedAccountEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val serviceId: String,
|
||||
val displayName: String?,
|
||||
val accountId: String?,
|
||||
val avatarUrl: String?,
|
||||
val cachedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.omixlab.lckcontrol.app.data.remote
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
// ── Health ────────────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class HealthResponse(
|
||||
val status: String,
|
||||
val timestamp: String,
|
||||
val version: String,
|
||||
)
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PairingRedeemRequest(
|
||||
val code: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AuthTokensResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresIn: Int? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RefreshRequest(
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserProfileResponse(
|
||||
val id: String,
|
||||
val displayName: String?,
|
||||
val email: String?,
|
||||
val avatarUrl: String?,
|
||||
val bio: String?,
|
||||
val isPublic: Boolean,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateProfileRequest(
|
||||
val displayName: String? = null,
|
||||
val bio: String? = null,
|
||||
val isPublic: Boolean? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PairingCodeResponse(
|
||||
val code: String,
|
||||
val expiresAt: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PairingStatusResponse(
|
||||
val active: Boolean,
|
||||
val code: String?,
|
||||
val expiresAt: String?,
|
||||
)
|
||||
|
||||
// ── Providers ─────────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AuthUrlResponse(
|
||||
val url: String,
|
||||
val state: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ProviderCallbackRequest(
|
||||
val code: String,
|
||||
val state: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LinkedAccountResponse(
|
||||
val id: String,
|
||||
val serviceId: String,
|
||||
val displayName: String?,
|
||||
val accountId: String?,
|
||||
val avatarUrl: String?,
|
||||
val rtmpUrl: String?,
|
||||
val streamKey: String?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateCustomRtmpRequest(
|
||||
val displayName: String,
|
||||
val rtmpUrl: String,
|
||||
val streamKey: String,
|
||||
)
|
||||
|
||||
// ── Streams ───────────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateStreamPlanRequest(
|
||||
val name: String,
|
||||
val executionMode: String = "SIMULTANEOUS",
|
||||
val gameId: String? = null,
|
||||
val isPublic: Boolean = true,
|
||||
val destinations: List<CreateDestinationRequest> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateStreamPlanRequest(
|
||||
val name: String? = null,
|
||||
val executionMode: String? = null,
|
||||
val gameId: String? = null,
|
||||
val isPublic: Boolean? = null,
|
||||
val destinations: List<CreateDestinationRequest>? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateDestinationRequest(
|
||||
val linkedAccountId: String,
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val privacyStatus: String? = null,
|
||||
val gameId: String? = null,
|
||||
val tags: List<String>? = null,
|
||||
val rtmpUrl: String? = null,
|
||||
val streamKey: String? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StreamPlanResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val executionMode: String,
|
||||
val gameId: String?,
|
||||
val isPublic: Boolean,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
val destinations: List<StreamDestinationResponse> = emptyList(),
|
||||
val user: FeedUserResponse? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StreamDestinationResponse(
|
||||
val id: String,
|
||||
val serviceId: String,
|
||||
val linkedAccountId: String?,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val privacyStatus: String?,
|
||||
val gameId: String?,
|
||||
val tags: String? = null,
|
||||
val rtmpUrl: String?,
|
||||
val streamKey: String?,
|
||||
val broadcastId: String?,
|
||||
val status: String?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PrepareResponse(
|
||||
val planId: String,
|
||||
val destinations: List<PreparedDestination>,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PreparedDestination(
|
||||
val id: String,
|
||||
val serviceId: String,
|
||||
val rtmpUrl: String?,
|
||||
val streamKey: String?,
|
||||
val broadcastId: String?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SuccessResponse(
|
||||
val success: Boolean,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StatusResponse(
|
||||
val success: Boolean,
|
||||
val status: String?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ErrorResponse(
|
||||
val error: String,
|
||||
)
|
||||
|
||||
// ── Social / Feed ─────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FeedResponse(
|
||||
val items: List<FeedItemResponse>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FeedItemResponse(
|
||||
val type: String = "stream", // "stream" or "video"
|
||||
val plan: StreamPlanResponse? = null,
|
||||
val video: VideoFeedItem? = null,
|
||||
val previewUrl: String?,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
val isLiked: Boolean,
|
||||
val user: FeedUserResponse? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FeedUserResponse(
|
||||
val id: String,
|
||||
val displayName: String?,
|
||||
val avatarUrl: String?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class VideoFeedItem(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val duration: Int?,
|
||||
val videoUrl: String,
|
||||
val thumbnailUrl: String?,
|
||||
)
|
||||
|
||||
// ── Social Actions ────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FollowResponse(
|
||||
val following: Boolean,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LikeResponse(
|
||||
val liked: Boolean,
|
||||
val likeCount: Int,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FollowListResponse(
|
||||
val users: List<UserProfileResponse>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
// ── Devices ───────────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeviceResponse(
|
||||
val id: String,
|
||||
val deviceId: String,
|
||||
val deviceName: String?,
|
||||
val deviceType: String,
|
||||
val isOnline: Boolean,
|
||||
val lastSeen: String?,
|
||||
val batteryLevel: Int?,
|
||||
val storageAvailable: Long?,
|
||||
val runningGame: String?,
|
||||
val streamingState: String?,
|
||||
val cortexState: String?,
|
||||
)
|
||||
|
||||
// ── Content Videos ────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateVideoRequest(
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val duration: Int,
|
||||
val isPublic: Boolean = true,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class VideoResponse(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val duration: Int,
|
||||
val thumbnailUrl: String?,
|
||||
val videoUrl: String,
|
||||
val fileSize: Long?,
|
||||
val isPublic: Boolean,
|
||||
val createdAt: String,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.omixlab.lckcontrol.app.data.remote
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.omixlab.lckcontrol.app.data.local.TokenStore
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val tokenStore: TokenStore,
|
||||
) : Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthInterceptor"
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val path = original.url.encodedPath
|
||||
|
||||
// Don't add auth to pairing redeem or refresh endpoints
|
||||
if (path.contains("/auth/pairing/redeem") || path.contains("/auth/refresh") || path.contains("/health")) {
|
||||
return chain.proceed(original)
|
||||
}
|
||||
|
||||
val jwt = tokenStore.getJwt()
|
||||
val request = if (jwt != null) {
|
||||
original.newBuilder()
|
||||
.header("Authorization", "Bearer $jwt")
|
||||
.build()
|
||||
} else {
|
||||
original
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
|
||||
if (response.code == 401) {
|
||||
Log.w(TAG, "401 on ${original.method} $path — attempting token refresh")
|
||||
val refreshToken = tokenStore.getRefreshToken()
|
||||
if (refreshToken != null) {
|
||||
response.close()
|
||||
val newTokens = refreshTokenSync(chain, refreshToken)
|
||||
if (newTokens != null) {
|
||||
tokenStore.saveSession(newTokens.accessToken, newTokens.refreshToken)
|
||||
val retryRequest = original.newBuilder()
|
||||
.header("Authorization", "Bearer ${newTokens.accessToken}")
|
||||
.build()
|
||||
return chain.proceed(retryRequest)
|
||||
} else {
|
||||
Log.e(TAG, "Token refresh FAILED, clearing session")
|
||||
tokenStore.clearSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun refreshTokenSync(chain: Interceptor.Chain, refreshToken: String): AuthTokensResponse? {
|
||||
return try {
|
||||
val baseUrl = chain.request().url.newBuilder()
|
||||
.encodedPath("/auth/refresh")
|
||||
.build()
|
||||
|
||||
val json = """{"refreshToken":"$refreshToken"}"""
|
||||
val body = json.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
|
||||
val refreshRequest = okhttp3.Request.Builder()
|
||||
.url(baseUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val refreshResponse = chain.proceed(refreshRequest)
|
||||
if (refreshResponse.isSuccessful) {
|
||||
val responseBody = refreshResponse.body?.string() ?: return null
|
||||
val moshi = com.squareup.moshi.Moshi.Builder().build()
|
||||
val adapter = moshi.adapter(AuthTokensResponse::class.java)
|
||||
adapter.fromJson(responseBody)
|
||||
} else {
|
||||
refreshResponse.close()
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Token refresh exception", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.omixlab.lckcontrol.app.data.remote
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
// ── Incoming Events ──────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChatMessageEvent(
|
||||
val type: String,
|
||||
val planId: String,
|
||||
val service: String?,
|
||||
val destinationId: String?,
|
||||
val message: ChatMessage,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val authorName: String,
|
||||
val authorImageUrl: String?,
|
||||
val text: String,
|
||||
val timestamp: String,
|
||||
val isModerator: Boolean = false,
|
||||
val isBroadcaster: Boolean = false,
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChatStatusEvent(
|
||||
val type: String,
|
||||
val planId: String,
|
||||
val service: String?,
|
||||
val destinationId: String?,
|
||||
val connected: Boolean,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
// ── Portal Comments ──────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PortalCommentEvent(
|
||||
val type: String,
|
||||
val planId: String,
|
||||
val comment: PortalComment,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PortalComment(
|
||||
val id: String,
|
||||
val userId: String,
|
||||
val displayName: String?,
|
||||
val avatarUrl: String?,
|
||||
val text: String,
|
||||
val timestamp: String,
|
||||
)
|
||||
|
||||
// ── Outgoing Commands ────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SubscribePortalCommand(
|
||||
val type: String = "subscribe_portal",
|
||||
val planId: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SendPortalCommentCommand(
|
||||
val type: String = "send_portal_comment",
|
||||
val planId: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LikeCommand(
|
||||
val type: String = "like",
|
||||
val planId: String,
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.omixlab.lckcontrol.app.data.remote
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.http.*
|
||||
|
||||
interface LckApiService {
|
||||
|
||||
// ── Health ────────────────────────────────────────────
|
||||
|
||||
@GET("health")
|
||||
suspend fun healthCheck(): HealthResponse
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────
|
||||
|
||||
@POST("auth/pairing/redeem")
|
||||
suspend fun redeemPairingCode(@Body body: PairingRedeemRequest): AuthTokensResponse
|
||||
|
||||
@POST("auth/refresh")
|
||||
suspend fun refreshSession(@Body body: RefreshRequest): AuthTokensResponse
|
||||
|
||||
@GET("auth/me")
|
||||
suspend fun getMe(): UserProfileResponse
|
||||
|
||||
@POST("auth/logout")
|
||||
suspend fun logout(): SuccessResponse
|
||||
|
||||
@PATCH("auth/me")
|
||||
suspend fun updateProfile(@Body body: UpdateProfileRequest): UserProfileResponse
|
||||
|
||||
// ── Providers ────────────────────────────────────────
|
||||
|
||||
@GET("providers/accounts")
|
||||
suspend fun getLinkedAccounts(): List<LinkedAccountResponse>
|
||||
|
||||
@GET("providers/youtube/auth-url")
|
||||
suspend fun getYouTubeAuthUrl(): AuthUrlResponse
|
||||
|
||||
@POST("providers/youtube/callback")
|
||||
suspend fun youtubeCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
|
||||
|
||||
@GET("providers/twitch/auth-url")
|
||||
suspend fun getTwitchAuthUrl(): AuthUrlResponse
|
||||
|
||||
@POST("providers/twitch/callback")
|
||||
suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
|
||||
|
||||
@POST("providers/accounts/custom-rtmp")
|
||||
suspend fun createCustomRtmpAccount(@Body body: CreateCustomRtmpRequest): LinkedAccountResponse
|
||||
|
||||
@DELETE("providers/accounts/{id}")
|
||||
suspend fun unlinkAccount(@Path("id") id: String): SuccessResponse
|
||||
|
||||
// ── Streams ──────────────────────────────────────────
|
||||
|
||||
@GET("streams/plans")
|
||||
suspend fun getStreamPlans(): List<StreamPlanResponse>
|
||||
|
||||
@POST("streams/plans")
|
||||
suspend fun createStreamPlan(@Body body: CreateStreamPlanRequest): StreamPlanResponse
|
||||
|
||||
@GET("streams/plans/{id}")
|
||||
suspend fun getStreamPlan(@Path("id") id: String): StreamPlanResponse
|
||||
|
||||
@PUT("streams/plans/{id}")
|
||||
suspend fun updateStreamPlan(@Path("id") id: String, @Body body: UpdateStreamPlanRequest): StreamPlanResponse
|
||||
|
||||
@DELETE("streams/plans/{id}")
|
||||
suspend fun deleteStreamPlan(@Path("id") id: String): SuccessResponse
|
||||
|
||||
@POST("streams/plans/{id}/prepare")
|
||||
suspend fun prepareStreamPlan(@Path("id") id: String): PrepareResponse
|
||||
|
||||
@POST("streams/plans/{id}/start")
|
||||
suspend fun startStreamPlan(@Path("id") id: String): StatusResponse
|
||||
|
||||
@POST("streams/plans/{id}/end")
|
||||
suspend fun endStreamPlan(@Path("id") id: String): StatusResponse
|
||||
|
||||
@Multipart
|
||||
@POST("streams/plans/{id}/preview")
|
||||
suspend fun uploadPreview(
|
||||
@Path("id") planId: String,
|
||||
@Part preview: MultipartBody.Part,
|
||||
)
|
||||
|
||||
// ── Social ───────────────────────────────────────────
|
||||
|
||||
@GET("social/feed")
|
||||
suspend fun getFeed(
|
||||
@Query("filter") filter: String = "trending",
|
||||
@Query("cursor") cursor: String? = null,
|
||||
@Query("limit") limit: Int = 20,
|
||||
): FeedResponse
|
||||
|
||||
@POST("social/follow/{userId}")
|
||||
suspend fun followUser(@Path("userId") userId: String): FollowResponse
|
||||
|
||||
@DELETE("social/follow/{userId}")
|
||||
suspend fun unfollowUser(@Path("userId") userId: String): FollowResponse
|
||||
|
||||
@POST("social/like/{planId}")
|
||||
suspend fun likePlan(@Path("planId") planId: String): LikeResponse
|
||||
|
||||
@DELETE("social/like/{planId}")
|
||||
suspend fun unlikePlan(@Path("planId") planId: String): LikeResponse
|
||||
|
||||
@GET("social/user/{userId}")
|
||||
suspend fun getUserProfile(@Path("userId") userId: String): UserProfileResponse
|
||||
|
||||
@GET("social/user/{userId}/followers")
|
||||
suspend fun getFollowers(
|
||||
@Path("userId") userId: String,
|
||||
@Query("cursor") cursor: String? = null,
|
||||
@Query("limit") limit: Int = 20,
|
||||
): FollowListResponse
|
||||
|
||||
@GET("social/user/{userId}/following")
|
||||
suspend fun getFollowing(
|
||||
@Path("userId") userId: String,
|
||||
@Query("cursor") cursor: String? = null,
|
||||
@Query("limit") limit: Int = 20,
|
||||
): FollowListResponse
|
||||
|
||||
// ── Devices ──────────────────────────────────────────
|
||||
|
||||
@GET("devices")
|
||||
suspend fun getDevices(): List<DeviceResponse>
|
||||
|
||||
@GET("devices/{id}/status")
|
||||
suspend fun getDeviceStatus(@Path("id") id: String): DeviceResponse
|
||||
|
||||
@DELETE("devices/{id}")
|
||||
suspend fun removeDevice(@Path("id") id: String): SuccessResponse
|
||||
|
||||
// ── Content Videos ───────────────────────────────────
|
||||
|
||||
@GET("content/videos")
|
||||
suspend fun getVideos(
|
||||
@Query("cursor") cursor: String? = null,
|
||||
@Query("limit") limit: Int = 20,
|
||||
): List<VideoResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("content/videos")
|
||||
suspend fun uploadVideo(
|
||||
@Part video: MultipartBody.Part,
|
||||
@Part("title") title: String,
|
||||
@Part("description") description: String?,
|
||||
@Part("duration") duration: Int,
|
||||
@Part("isPublic") isPublic: Boolean,
|
||||
): VideoResponse
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.omixlab.lckcontrol.app.data.repository
|
||||
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.LinkedAccountDao
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.LinkedAccountEntity
|
||||
import com.omixlab.lckcontrol.app.data.remote.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccountRepository @Inject constructor(
|
||||
private val api: LckApiService,
|
||||
private val linkedAccountDao: LinkedAccountDao,
|
||||
) {
|
||||
suspend fun getLinkedAccounts(): List<LinkedAccountResponse> {
|
||||
val accounts = api.getLinkedAccounts()
|
||||
linkedAccountDao.deleteAll()
|
||||
linkedAccountDao.insertAll(accounts.map { it.toEntity() })
|
||||
return accounts
|
||||
}
|
||||
|
||||
suspend fun getCachedAccounts(): List<LinkedAccountEntity> = linkedAccountDao.getAll()
|
||||
|
||||
suspend fun getYouTubeAuthUrl(): AuthUrlResponse = api.getYouTubeAuthUrl()
|
||||
|
||||
suspend fun youtubeCallback(code: String, state: String): LinkedAccountResponse =
|
||||
api.youtubeCallback(ProviderCallbackRequest(code, state))
|
||||
|
||||
suspend fun getTwitchAuthUrl(): AuthUrlResponse = api.getTwitchAuthUrl()
|
||||
|
||||
suspend fun twitchCallback(code: String, state: String): LinkedAccountResponse =
|
||||
api.twitchCallback(ProviderCallbackRequest(code, state))
|
||||
|
||||
suspend fun createCustomRtmpAccount(request: CreateCustomRtmpRequest): LinkedAccountResponse =
|
||||
api.createCustomRtmpAccount(request)
|
||||
|
||||
suspend fun unlinkAccount(id: String) = api.unlinkAccount(id)
|
||||
|
||||
private fun LinkedAccountResponse.toEntity() = LinkedAccountEntity(
|
||||
id = id,
|
||||
serviceId = serviceId,
|
||||
displayName = displayName,
|
||||
accountId = accountId,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.omixlab.lckcontrol.app.data.repository
|
||||
|
||||
import com.omixlab.lckcontrol.app.data.local.TokenStore
|
||||
import com.omixlab.lckcontrol.app.data.remote.LckApiService
|
||||
import com.omixlab.lckcontrol.app.data.remote.PairingRedeemRequest
|
||||
import com.omixlab.lckcontrol.app.data.remote.UpdateProfileRequest
|
||||
import com.omixlab.lckcontrol.app.data.remote.UserProfileResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val api: LckApiService,
|
||||
private val tokenStore: TokenStore,
|
||||
) {
|
||||
suspend fun redeemPairingCode(code: String) {
|
||||
val response = api.redeemPairingCode(PairingRedeemRequest(code))
|
||||
tokenStore.saveSession(response.accessToken, response.refreshToken)
|
||||
}
|
||||
|
||||
suspend fun getMe(): UserProfileResponse = api.getMe()
|
||||
|
||||
suspend fun updateProfile(
|
||||
displayName: String? = null,
|
||||
bio: String? = null,
|
||||
isPublic: Boolean? = null,
|
||||
): UserProfileResponse = api.updateProfile(
|
||||
UpdateProfileRequest(displayName, bio, isPublic)
|
||||
)
|
||||
|
||||
suspend fun logout() {
|
||||
try {
|
||||
api.logout()
|
||||
} finally {
|
||||
tokenStore.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
fun isLoggedIn(): Boolean = tokenStore.isLoggedIn()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.omixlab.lckcontrol.app.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import com.omixlab.lckcontrol.app.data.local.TokenStore
|
||||
import com.omixlab.lckcontrol.app.data.remote.*
|
||||
import com.omixlab.lckcontrol.app.di.AppModule
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import okhttp3.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChatRepository @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val moshi: Moshi,
|
||||
private val tokenStore: TokenStore,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ChatRepository"
|
||||
}
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
fun connectToChat(planId: String): Flow<ChatEvent> = callbackFlow {
|
||||
val token = tokenStore.getJwt() ?: run {
|
||||
close(IllegalStateException("Not authenticated"))
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
val wsUrl = AppModule.BASE_URL
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://") + "chat/ws?token=$token"
|
||||
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
|
||||
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "WebSocket connected")
|
||||
val subscribeJson = moshi.adapter(SubscribePortalCommand::class.java)
|
||||
.toJson(SubscribePortalCommand(planId = planId))
|
||||
webSocket.send(subscribeJson)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val map = moshi.adapter(Map::class.java).fromJson(text) as? Map<*, *>
|
||||
when (map?.get("type")) {
|
||||
"portal_comment" -> {
|
||||
val event = moshi.adapter(PortalCommentEvent::class.java).fromJson(text)
|
||||
if (event != null) trySend(ChatEvent.Comment(event.comment))
|
||||
}
|
||||
"chat_message" -> {
|
||||
val event = moshi.adapter(ChatMessageEvent::class.java).fromJson(text)
|
||||
if (event != null) trySend(ChatEvent.Message(event.message))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse message", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "WebSocket failure", t)
|
||||
trySend(ChatEvent.Error(t.message ?: "Connection failed"))
|
||||
close(t)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closed: $code $reason")
|
||||
close()
|
||||
}
|
||||
})
|
||||
|
||||
awaitClose {
|
||||
webSocket?.close(1000, "User left")
|
||||
webSocket = null
|
||||
}
|
||||
}
|
||||
|
||||
fun sendComment(planId: String, text: String) {
|
||||
val command = SendPortalCommentCommand(planId = planId, text = text)
|
||||
val json = moshi.adapter(SendPortalCommentCommand::class.java).toJson(command)
|
||||
webSocket?.send(json)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
webSocket?.close(1000, "Disconnect")
|
||||
webSocket = null
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ChatEvent {
|
||||
data class Comment(val comment: PortalComment) : ChatEvent()
|
||||
data class Message(val message: ChatMessage) : ChatEvent()
|
||||
data class Error(val message: String) : ChatEvent()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.omixlab.lckcontrol.app.data.repository
|
||||
|
||||
import com.omixlab.lckcontrol.app.data.remote.DeviceResponse
|
||||
import com.omixlab.lckcontrol.app.data.remote.LckApiService
|
||||
import com.omixlab.lckcontrol.app.p2p.discovery.DiscoveredDevice
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceRepository @Inject constructor(
|
||||
private val api: LckApiService,
|
||||
) {
|
||||
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
|
||||
val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _discoveredDevices.asStateFlow()
|
||||
|
||||
private val _pairedDeviceId = MutableStateFlow<String?>(null)
|
||||
val pairedDeviceId: StateFlow<String?> = _pairedDeviceId.asStateFlow()
|
||||
|
||||
suspend fun getRegisteredDevices(): List<DeviceResponse> = api.getDevices()
|
||||
|
||||
suspend fun getDeviceStatus(id: String): DeviceResponse = api.getDeviceStatus(id)
|
||||
|
||||
suspend fun removeDevice(id: String) = api.removeDevice(id)
|
||||
|
||||
fun updateDiscoveredDevices(devices: List<DiscoveredDevice>) {
|
||||
_discoveredDevices.value = devices
|
||||
}
|
||||
|
||||
fun setPairedDevice(deviceId: String?) {
|
||||
_pairedDeviceId.value = deviceId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.omixlab.lckcontrol.app.data.repository
|
||||
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.FeedItemDao
|
||||
import com.omixlab.lckcontrol.app.data.local.entity.CachedFeedItemEntity
|
||||
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
|
||||
import com.omixlab.lckcontrol.app.data.remote.LckApiService
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FeedRepository @Inject constructor(
|
||||
private val api: LckApiService,
|
||||
private val feedItemDao: FeedItemDao,
|
||||
) {
|
||||
suspend fun getFeed(
|
||||
filter: String = "trending",
|
||||
cursor: String? = null,
|
||||
limit: Int = 20,
|
||||
): Pair<List<FeedItemResponse>, String?> {
|
||||
val response = api.getFeed(filter, cursor, limit)
|
||||
|
||||
// Cache first page
|
||||
if (cursor == null) {
|
||||
feedItemDao.deleteByFilter(filter)
|
||||
feedItemDao.insertAll(
|
||||
response.items.mapIndexed { index, item ->
|
||||
item.toCachedEntity(filter, index)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return response.items to response.nextCursor
|
||||
}
|
||||
|
||||
suspend fun getCachedFeed(filter: String): List<CachedFeedItemEntity> =
|
||||
feedItemDao.getByFilter(filter)
|
||||
|
||||
private fun FeedItemResponse.toCachedEntity(filter: String, sortOrder: Int) =
|
||||
CachedFeedItemEntity(
|
||||
id = plan?.id ?: video?.id ?: "",
|
||||
type = type,
|
||||
planId = plan?.id,
|
||||
videoId = video?.id,
|
||||
title = plan?.name ?: video?.title,
|
||||
previewUrl = previewUrl,
|
||||
likeCount = likeCount,
|
||||
commentCount = commentCount,
|
||||
isLiked = isLiked,
|
||||
userId = user?.id,
|
||||
userDisplayName = user?.displayName,
|
||||
userAvatarUrl = user?.avatarUrl,
|
||||
gameId = plan?.gameId,
|
||||
status = plan?.status,
|
||||
filter = filter,
|
||||
sortOrder = sortOrder,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.omixlab.lckcontrol.app.data.repository
|
||||
|
||||
import com.omixlab.lckcontrol.app.data.remote.FollowListResponse
|
||||
import com.omixlab.lckcontrol.app.data.remote.FollowResponse
|
||||
import com.omixlab.lckcontrol.app.data.remote.LckApiService
|
||||
import com.omixlab.lckcontrol.app.data.remote.LikeResponse
|
||||
import com.omixlab.lckcontrol.app.data.remote.UserProfileResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SocialRepository @Inject constructor(
|
||||
private val api: LckApiService,
|
||||
) {
|
||||
suspend fun followUser(userId: String): FollowResponse = api.followUser(userId)
|
||||
|
||||
suspend fun unfollowUser(userId: String): FollowResponse = api.unfollowUser(userId)
|
||||
|
||||
suspend fun likePlan(planId: String): LikeResponse = api.likePlan(planId)
|
||||
|
||||
suspend fun unlikePlan(planId: String): LikeResponse = api.unlikePlan(planId)
|
||||
|
||||
suspend fun getUserProfile(userId: String): UserProfileResponse = api.getUserProfile(userId)
|
||||
|
||||
suspend fun getFollowers(
|
||||
userId: String,
|
||||
cursor: String? = null,
|
||||
limit: Int = 20,
|
||||
): FollowListResponse = api.getFollowers(userId, cursor, limit)
|
||||
|
||||
suspend fun getFollowing(
|
||||
userId: String,
|
||||
cursor: String? = null,
|
||||
limit: Int = 20,
|
||||
): FollowListResponse = api.getFollowing(userId, cursor, limit)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.omixlab.lckcontrol.app.data.repository
|
||||
|
||||
import com.omixlab.lckcontrol.app.data.remote.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StreamRepository @Inject constructor(
|
||||
private val api: LckApiService,
|
||||
) {
|
||||
suspend fun getStreamPlans(): List<StreamPlanResponse> = api.getStreamPlans()
|
||||
|
||||
suspend fun getStreamPlan(id: String): StreamPlanResponse = api.getStreamPlan(id)
|
||||
|
||||
suspend fun createStreamPlan(request: CreateStreamPlanRequest): StreamPlanResponse =
|
||||
api.createStreamPlan(request)
|
||||
|
||||
suspend fun updateStreamPlan(id: String, request: UpdateStreamPlanRequest): StreamPlanResponse =
|
||||
api.updateStreamPlan(id, request)
|
||||
|
||||
suspend fun deleteStreamPlan(id: String) = api.deleteStreamPlan(id)
|
||||
|
||||
suspend fun prepareStreamPlan(id: String): PrepareResponse = api.prepareStreamPlan(id)
|
||||
|
||||
suspend fun startStreamPlan(id: String): StatusResponse = api.startStreamPlan(id)
|
||||
|
||||
suspend fun endStreamPlan(id: String): StatusResponse = api.endStreamPlan(id)
|
||||
}
|
||||
59
app/src/main/java/com/omixlab/lckcontrol/app/di/AppModule.kt
Normal file
59
app/src/main/java/com/omixlab/lckcontrol/app/di/AppModule.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.omixlab.lckcontrol.app.di
|
||||
|
||||
import com.omixlab.lckcontrol.app.data.remote.AuthInterceptor
|
||||
import com.omixlab.lckcontrol.app.data.remote.LckApiService
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
const val BASE_URL = "https://lck.omigame.dev/"
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi =
|
||||
Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit =
|
||||
Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLckApiService(retrofit: Retrofit): LckApiService =
|
||||
retrofit.create(LckApiService::class.java)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.omixlab.lckcontrol.app.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.omixlab.lckcontrol.app.data.local.LckPhoneDatabase
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.FeedItemDao
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.LinkedAccountDao
|
||||
import com.omixlab.lckcontrol.app.data.local.dao.UserProfileDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): LckPhoneDatabase =
|
||||
Room.databaseBuilder(
|
||||
context,
|
||||
LckPhoneDatabase::class.java,
|
||||
"lck_phone_cache.db",
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
fun provideFeedItemDao(db: LckPhoneDatabase): FeedItemDao = db.feedItemDao()
|
||||
|
||||
@Provides
|
||||
fun provideUserProfileDao(db: LckPhoneDatabase): UserProfileDao = db.userProfileDao()
|
||||
|
||||
@Provides
|
||||
fun provideLinkedAccountDao(db: LckPhoneDatabase): LinkedAccountDao = db.linkedAccountDao()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.omixlab.lckcontrol.app.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlayerModule {
|
||||
|
||||
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100 MB
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVideoCache(@ApplicationContext context: Context): SimpleCache {
|
||||
val cacheDir = File(context.cacheDir, "video_cache")
|
||||
val evictor = LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES)
|
||||
val databaseProvider = StandaloneDatabaseProvider(context)
|
||||
return SimpleCache(cacheDir, evictor, databaseProvider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.discovery
|
||||
|
||||
data class DiscoveredDevice(
|
||||
val name: String,
|
||||
val ip: String,
|
||||
val port: Int,
|
||||
val userId: String? = null,
|
||||
val deviceModel: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.discovery
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LanDiscoveryManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "LanDiscovery"
|
||||
private const val SERVICE_TYPE = "_lckcontrol._tcp."
|
||||
}
|
||||
|
||||
private val nsdManager: NsdManager =
|
||||
context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
|
||||
private val _devices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
|
||||
val devices: StateFlow<List<DiscoveredDevice>> = _devices.asStateFlow()
|
||||
|
||||
private val _isDiscovering = MutableStateFlow(false)
|
||||
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
|
||||
|
||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||
|
||||
fun startDiscovery() {
|
||||
if (_isDiscovering.value) return
|
||||
|
||||
_devices.value = emptyList()
|
||||
|
||||
discoveryListener = object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery start failed: $errorCode")
|
||||
_isDiscovering.value = false
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery stop failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStarted(serviceType: String?) {
|
||||
Log.d(TAG, "Discovery started")
|
||||
_isDiscovering.value = true
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String?) {
|
||||
Log.d(TAG, "Discovery stopped")
|
||||
_isDiscovering.value = false
|
||||
}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
|
||||
serviceInfo ?: return
|
||||
Log.d(TAG, "Service found: ${serviceInfo.serviceName}")
|
||||
resolveService(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
|
||||
serviceInfo ?: return
|
||||
Log.d(TAG, "Service lost: ${serviceInfo.serviceName}")
|
||||
_devices.value = _devices.value.filter { it.name != serviceInfo.serviceName }
|
||||
}
|
||||
}
|
||||
|
||||
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
}
|
||||
|
||||
private fun resolveService(serviceInfo: NsdServiceInfo) {
|
||||
nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(info: NsdServiceInfo?, errorCode: Int) {
|
||||
Log.e(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(info: NsdServiceInfo?) {
|
||||
info ?: return
|
||||
val device = DiscoveredDevice(
|
||||
name = info.serviceName,
|
||||
ip = info.host.hostAddress ?: return,
|
||||
port = info.port,
|
||||
userId = info.attributes["userId"]?.let { String(it) },
|
||||
deviceModel = info.attributes["model"]?.let { String(it) },
|
||||
)
|
||||
Log.d(TAG, "Resolved: $device")
|
||||
_devices.value = _devices.value + device
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun stopDiscovery() {
|
||||
discoveryListener?.let {
|
||||
try {
|
||||
nsdManager.stopServiceDiscovery(it)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
discoveryListener = null
|
||||
_isDiscovering.value = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.pairing
|
||||
|
||||
import android.util.Log
|
||||
import com.omixlab.lckcontrol.app.data.local.TokenStore
|
||||
import com.omixlab.lckcontrol.app.p2p.discovery.DiscoveredDevice
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PairRequest(val token: String)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PairResponse(val nonce: String, val deviceId: String, val deviceName: String)
|
||||
|
||||
@Singleton
|
||||
class DevicePairingManager @Inject constructor(
|
||||
private val tokenStore: TokenStore,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "DevicePairing"
|
||||
}
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
suspend fun pairWithDevice(device: DiscoveredDevice): PairedDevice? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val jwt = tokenStore.getJwt() ?: return@withContext null
|
||||
val body = moshi.adapter(PairRequest::class.java)
|
||||
.toJson(PairRequest(jwt))
|
||||
.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("http://${device.ip}:${device.port}/pair")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
if (response.isSuccessful) {
|
||||
val responseBody = response.body?.string() ?: return@withContext null
|
||||
val pairResponse = moshi.adapter(PairResponse::class.java).fromJson(responseBody)
|
||||
?: return@withContext null
|
||||
|
||||
PairedDevice(
|
||||
deviceId = pairResponse.deviceId,
|
||||
deviceName = pairResponse.deviceName,
|
||||
ip = device.ip,
|
||||
port = device.port,
|
||||
nonce = pairResponse.nonce,
|
||||
)
|
||||
} else {
|
||||
Log.e(TAG, "Pair failed: ${response.code}")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Pair exception", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.pairing
|
||||
|
||||
data class PairedDevice(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val ip: String,
|
||||
val port: Int,
|
||||
val nonce: String,
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.transfer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.WebRtcClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FileTransferManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val webRtcClient: WebRtcClient,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FileTransfer"
|
||||
private const val CHUNK_SIZE = 16 * 1024 // 16KB
|
||||
private const val ACK_INTERVAL = 32
|
||||
|
||||
// Binary message types
|
||||
private const val FILE_LIST_REQUEST: Byte = 0x01
|
||||
private const val FILE_LIST_RESPONSE: Byte = 0x02
|
||||
private const val FILE_REQUEST: Byte = 0x03
|
||||
private const val FILE_HEADER: Byte = 0x04
|
||||
private const val FILE_CHUNK: Byte = 0x05
|
||||
private const val FILE_COMPLETE: Byte = 0x06
|
||||
private const val FILE_ACK: Byte = 0x07
|
||||
}
|
||||
|
||||
private val _transfers = MutableStateFlow<Map<String, TransferProgress>>(emptyMap())
|
||||
val transfers: StateFlow<Map<String, TransferProgress>> = _transfers.asStateFlow()
|
||||
|
||||
private val _availableFiles = MutableStateFlow<List<RemoteFile>>(emptyList())
|
||||
val availableFiles: StateFlow<List<RemoteFile>> = _availableFiles.asStateFlow()
|
||||
|
||||
private val activeTransfers = mutableMapOf<String, FileOutputStream>()
|
||||
private var chunkCounter = 0
|
||||
|
||||
init {
|
||||
webRtcClient.onFileData = { data -> handleFileData(data) }
|
||||
}
|
||||
|
||||
fun requestFileList() {
|
||||
val buffer = ByteBuffer.allocate(1)
|
||||
buffer.put(FILE_LIST_REQUEST)
|
||||
webRtcClient.sendFileData(buffer.array())
|
||||
}
|
||||
|
||||
fun downloadFile(remotePath: String, fileName: String) {
|
||||
val transferId = UUID.randomUUID().toString()
|
||||
|
||||
_transfers.value = _transfers.value + (transferId to TransferProgress(
|
||||
transferId = transferId,
|
||||
fileName = fileName,
|
||||
totalBytes = 0,
|
||||
receivedBytes = 0,
|
||||
))
|
||||
|
||||
// Send file request
|
||||
val pathBytes = remotePath.toByteArray()
|
||||
val idBytes = transferId.toByteArray().take(16).toByteArray()
|
||||
val buffer = ByteBuffer.allocate(1 + 16 + pathBytes.size)
|
||||
buffer.put(FILE_REQUEST)
|
||||
buffer.put(idBytes)
|
||||
buffer.put(pathBytes)
|
||||
webRtcClient.sendFileData(buffer.array())
|
||||
}
|
||||
|
||||
private fun handleFileData(data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
val type = data[0]
|
||||
|
||||
when (type) {
|
||||
FILE_LIST_RESPONSE -> {
|
||||
val json = String(data, 1, data.size - 1)
|
||||
// Parse file list from JSON
|
||||
Log.d(TAG, "Received file list: $json")
|
||||
}
|
||||
FILE_HEADER -> {
|
||||
if (data.size < 17) return
|
||||
val transferId = String(data, 1, 16).trim()
|
||||
// Parse file size from remaining bytes
|
||||
val metaJson = String(data, 17, data.size - 17)
|
||||
Log.d(TAG, "File header for $transferId: $metaJson")
|
||||
|
||||
// Create output file
|
||||
val downloadsDir = File(context.getExternalFilesDir(null), "downloads")
|
||||
downloadsDir.mkdirs()
|
||||
val progress = _transfers.value[transferId] ?: return
|
||||
val outputFile = File(downloadsDir, progress.fileName)
|
||||
activeTransfers[transferId] = FileOutputStream(outputFile)
|
||||
chunkCounter = 0
|
||||
}
|
||||
FILE_CHUNK -> {
|
||||
if (data.size < 17) return
|
||||
val transferId = String(data, 1, 16).trim()
|
||||
val chunkData = data.copyOfRange(17, data.size)
|
||||
|
||||
activeTransfers[transferId]?.write(chunkData)
|
||||
chunkCounter++
|
||||
|
||||
// Update progress
|
||||
val current = _transfers.value[transferId] ?: return
|
||||
_transfers.value = _transfers.value + (transferId to current.copy(
|
||||
receivedBytes = current.receivedBytes + chunkData.size,
|
||||
))
|
||||
|
||||
// Send ACK every N chunks
|
||||
if (chunkCounter % ACK_INTERVAL == 0) {
|
||||
sendAck(transferId)
|
||||
}
|
||||
}
|
||||
FILE_COMPLETE -> {
|
||||
if (data.size < 17) return
|
||||
val transferId = String(data, 1, 16).trim()
|
||||
activeTransfers[transferId]?.close()
|
||||
activeTransfers.remove(transferId)
|
||||
|
||||
val current = _transfers.value[transferId] ?: return
|
||||
_transfers.value = _transfers.value + (transferId to current.copy(isComplete = true))
|
||||
Log.d(TAG, "Transfer complete: $transferId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAck(transferId: String) {
|
||||
val idBytes = transferId.toByteArray().take(16).toByteArray()
|
||||
val buffer = ByteBuffer.allocate(1 + 16)
|
||||
buffer.put(FILE_ACK)
|
||||
buffer.put(idBytes)
|
||||
webRtcClient.sendFileData(buffer.array())
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String) {
|
||||
activeTransfers[transferId]?.close()
|
||||
activeTransfers.remove(transferId)
|
||||
_transfers.value = _transfers.value - transferId
|
||||
}
|
||||
}
|
||||
|
||||
data class RemoteFile(
|
||||
val path: String,
|
||||
val name: String,
|
||||
val size: Long,
|
||||
val isDirectory: Boolean,
|
||||
val modifiedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.transfer
|
||||
|
||||
data class TransferProgress(
|
||||
val transferId: String,
|
||||
val fileName: String,
|
||||
val totalBytes: Long,
|
||||
val receivedBytes: Long,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val progress: Float
|
||||
get() = if (totalBytes > 0) receivedBytes.toFloat() / totalBytes else 0f
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.webrtc
|
||||
|
||||
import android.util.Log
|
||||
import org.webrtc.VideoTrack
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CameraViewSession @Inject constructor(
|
||||
private val webRtcClient: WebRtcClient,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "CameraViewSession"
|
||||
}
|
||||
|
||||
fun requestVideoStream() {
|
||||
val command = """{"id":"${System.currentTimeMillis()}","type":"request","method":"startVideoStream","payload":{}}"""
|
||||
webRtcClient.sendControlMessage(command)
|
||||
Log.d(TAG, "Requested video stream")
|
||||
}
|
||||
|
||||
fun stopVideoStream() {
|
||||
val command = """{"id":"${System.currentTimeMillis()}","type":"request","method":"stopVideoStream","payload":{}}"""
|
||||
webRtcClient.sendControlMessage(command)
|
||||
Log.d(TAG, "Stopped video stream")
|
||||
}
|
||||
|
||||
fun getVideoTrack(): VideoTrack? = webRtcClient.remoteVideoTrack.value
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.webrtc
|
||||
|
||||
import android.util.Log
|
||||
import com.omixlab.lckcontrol.app.p2p.pairing.PairedDevice
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.SessionDescription
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OfferRequest(
|
||||
val sdp: String,
|
||||
val type: String,
|
||||
val iceCandidates: List<IceCandidateDto>,
|
||||
val nonce: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OfferResponse(
|
||||
val sdp: String,
|
||||
val type: String,
|
||||
val iceCandidates: List<IceCandidateDto>,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class IceCandidateDto(
|
||||
val sdpMid: String,
|
||||
val sdpMLineIndex: Int,
|
||||
val sdp: String,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class LanSignalingClient @Inject constructor(
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "LanSignaling"
|
||||
}
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
suspend fun sendOffer(
|
||||
device: PairedDevice,
|
||||
sdp: SessionDescription,
|
||||
iceCandidates: List<IceCandidate>,
|
||||
): Pair<SessionDescription, List<IceCandidate>>? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val offerRequest = OfferRequest(
|
||||
sdp = sdp.description,
|
||||
type = sdp.type.canonicalForm(),
|
||||
iceCandidates = iceCandidates.map {
|
||||
IceCandidateDto(it.sdpMid, it.sdpMLineIndex, it.sdp)
|
||||
},
|
||||
nonce = device.nonce,
|
||||
)
|
||||
|
||||
val body = moshi.adapter(OfferRequest::class.java)
|
||||
.toJson(offerRequest)
|
||||
.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("http://${device.ip}:${device.port}/offer")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
if (response.isSuccessful) {
|
||||
val responseBody = response.body?.string() ?: return@withContext null
|
||||
val answerResponse = moshi.adapter(OfferResponse::class.java).fromJson(responseBody)
|
||||
?: return@withContext null
|
||||
|
||||
val answerSdp = SessionDescription(
|
||||
SessionDescription.Type.ANSWER,
|
||||
answerResponse.sdp,
|
||||
)
|
||||
val answerCandidates = answerResponse.iceCandidates.map {
|
||||
IceCandidate(it.sdpMid, it.sdpMLineIndex, it.sdp)
|
||||
}
|
||||
|
||||
answerSdp to answerCandidates
|
||||
} else {
|
||||
Log.e(TAG, "Offer failed: ${response.code}")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Offer exception", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.webrtc
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ControlMessage(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val type: String, // "request", "response", "event"
|
||||
val method: String? = null,
|
||||
val payload: Map<String, Any?>? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeviceStatus(
|
||||
val batteryLevel: Int? = null,
|
||||
val memoryUsed: Long? = null,
|
||||
val memoryTotal: Long? = null,
|
||||
val runningGame: String? = null,
|
||||
val streamingState: String? = null,
|
||||
val cortexRecording: Boolean? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StreamingStats(
|
||||
val bitrate: Long? = null,
|
||||
val fps: Int? = null,
|
||||
val droppedFrames: Int? = null,
|
||||
val duration: Long? = null,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class RemoteControlSession @Inject constructor(
|
||||
private val webRtcClient: WebRtcClient,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val _deviceStatus = MutableStateFlow<DeviceStatus?>(null)
|
||||
val deviceStatus: StateFlow<DeviceStatus?> = _deviceStatus.asStateFlow()
|
||||
|
||||
private val _streamingStats = MutableStateFlow<StreamingStats?>(null)
|
||||
val streamingStats: StateFlow<StreamingStats?> = _streamingStats.asStateFlow()
|
||||
|
||||
init {
|
||||
webRtcClient.onControlMessage = { json ->
|
||||
handleMessage(json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(json: String) {
|
||||
try {
|
||||
val msg = moshi.adapter(ControlMessage::class.java).fromJson(json)
|
||||
when {
|
||||
msg?.type == "response" && msg.method == "getDeviceStatus" -> {
|
||||
// Parse device status from payload
|
||||
}
|
||||
msg?.type == "event" && msg.method == "streamingStateChanged" -> {
|
||||
// Update streaming state
|
||||
}
|
||||
msg?.type == "event" && msg.method == "cortexSessionUpdate" -> {
|
||||
// Update cortex state
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun requestDeviceStatus() {
|
||||
sendRequest("getDeviceStatus")
|
||||
}
|
||||
|
||||
fun requestStreamingStats() {
|
||||
sendRequest("getStreamingStats")
|
||||
}
|
||||
|
||||
fun startStreamPlan(planId: String) {
|
||||
sendRequest("startStreamPlan", mapOf("planId" to planId))
|
||||
}
|
||||
|
||||
fun endStreamPlan(planId: String) {
|
||||
sendRequest("endStreamPlan", mapOf("planId" to planId))
|
||||
}
|
||||
|
||||
fun getCortexState() {
|
||||
sendRequest("getCortexState")
|
||||
}
|
||||
|
||||
private fun sendRequest(method: String, payload: Map<String, Any?>? = null) {
|
||||
val msg = ControlMessage(
|
||||
type = "request",
|
||||
method = method,
|
||||
payload = payload,
|
||||
)
|
||||
val json = moshi.adapter(ControlMessage::class.java).toJson(msg)
|
||||
webRtcClient.sendControlMessage(json)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.webrtc
|
||||
|
||||
import android.util.Log
|
||||
import com.omixlab.lckcontrol.app.data.local.TokenStore
|
||||
import com.omixlab.lckcontrol.app.di.AppModule
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import okhttp3.*
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.SessionDescription
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SignalingMessage(
|
||||
val type: String,
|
||||
val from: String? = null,
|
||||
val to: String? = null,
|
||||
val sdp: String? = null,
|
||||
val sdpType: String? = null,
|
||||
val candidate: IceCandidateDto? = null,
|
||||
val deviceId: String? = null,
|
||||
val devices: List<RemoteDevice>? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RemoteDevice(
|
||||
val deviceId: String,
|
||||
val deviceName: String?,
|
||||
val userId: String,
|
||||
val isOnline: Boolean,
|
||||
)
|
||||
|
||||
sealed class SignalingEvent {
|
||||
data class DeviceList(val devices: List<RemoteDevice>) : SignalingEvent()
|
||||
data class Offer(val from: String, val sdp: SessionDescription) : SignalingEvent()
|
||||
data class Answer(val from: String, val sdp: SessionDescription) : SignalingEvent()
|
||||
data class Ice(val from: String, val candidate: IceCandidate) : SignalingEvent()
|
||||
data class Error(val message: String) : SignalingEvent()
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class RemoteSignalingClient @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val moshi: Moshi,
|
||||
private val tokenStore: TokenStore,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "RemoteSignaling"
|
||||
}
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
fun connect(): Flow<SignalingEvent> = callbackFlow {
|
||||
val token = tokenStore.getJwt() ?: run {
|
||||
close(IllegalStateException("Not authenticated"))
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
val wsUrl = AppModule.BASE_URL
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://") + "signaling/ws?token=$token"
|
||||
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
|
||||
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "Signaling connected")
|
||||
// Request device list
|
||||
send(SignalingMessage(type = "list_devices"))
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val msg = moshi.adapter(SignalingMessage::class.java).fromJson(text)
|
||||
?: return
|
||||
when (msg.type) {
|
||||
"device_list" -> {
|
||||
trySend(SignalingEvent.DeviceList(msg.devices ?: emptyList()))
|
||||
}
|
||||
"offer" -> {
|
||||
val sdp = SessionDescription(
|
||||
SessionDescription.Type.OFFER,
|
||||
msg.sdp ?: return,
|
||||
)
|
||||
trySend(SignalingEvent.Offer(msg.from ?: return, sdp))
|
||||
}
|
||||
"answer" -> {
|
||||
val sdp = SessionDescription(
|
||||
SessionDescription.Type.ANSWER,
|
||||
msg.sdp ?: return,
|
||||
)
|
||||
trySend(SignalingEvent.Answer(msg.from ?: return, sdp))
|
||||
}
|
||||
"ice_candidate" -> {
|
||||
val c = msg.candidate ?: return
|
||||
trySend(SignalingEvent.Ice(
|
||||
msg.from ?: return,
|
||||
IceCandidate(c.sdpMid, c.sdpMLineIndex, c.sdp),
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Parse error", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
trySend(SignalingEvent.Error(t.message ?: "Connection failed"))
|
||||
close(t)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
close()
|
||||
}
|
||||
})
|
||||
|
||||
awaitClose {
|
||||
webSocket?.close(1000, "Disconnect")
|
||||
webSocket = null
|
||||
}
|
||||
}
|
||||
|
||||
fun sendOffer(toDeviceId: String, sdp: SessionDescription) {
|
||||
send(SignalingMessage(
|
||||
type = "offer",
|
||||
to = toDeviceId,
|
||||
sdp = sdp.description,
|
||||
sdpType = sdp.type.canonicalForm(),
|
||||
))
|
||||
}
|
||||
|
||||
fun sendAnswer(toDeviceId: String, sdp: SessionDescription) {
|
||||
send(SignalingMessage(
|
||||
type = "answer",
|
||||
to = toDeviceId,
|
||||
sdp = sdp.description,
|
||||
sdpType = sdp.type.canonicalForm(),
|
||||
))
|
||||
}
|
||||
|
||||
fun sendIceCandidate(toDeviceId: String, candidate: IceCandidate) {
|
||||
send(SignalingMessage(
|
||||
type = "ice_candidate",
|
||||
to = toDeviceId,
|
||||
candidate = IceCandidateDto(candidate.sdpMid, candidate.sdpMLineIndex, candidate.sdp),
|
||||
))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
webSocket?.close(1000, "Disconnect")
|
||||
webSocket = null
|
||||
}
|
||||
|
||||
private fun send(message: SignalingMessage) {
|
||||
val json = moshi.adapter(SignalingMessage::class.java).toJson(message)
|
||||
webSocket?.send(json)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.omixlab.lckcontrol.app.p2p.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.webrtc.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class ConnectionState {
|
||||
DISCONNECTED, CONNECTING, CONNECTED, FAILED
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class WebRtcClient @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "WebRtcClient"
|
||||
private val ICE_SERVERS = listOf(
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||
)
|
||||
}
|
||||
|
||||
private var peerConnectionFactory: PeerConnectionFactory? = null
|
||||
private var peerConnection: PeerConnection? = null
|
||||
private var controlChannel: DataChannel? = null
|
||||
private var fileChannel: DataChannel? = null
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val state: StateFlow<ConnectionState> = _state.asStateFlow()
|
||||
|
||||
private val _remoteVideoTrack = MutableStateFlow<VideoTrack?>(null)
|
||||
val remoteVideoTrack: StateFlow<VideoTrack?> = _remoteVideoTrack.asStateFlow()
|
||||
|
||||
var onControlMessage: ((String) -> Unit)? = null
|
||||
var onFileData: ((ByteArray) -> Unit)? = null
|
||||
var onIceCandidate: ((IceCandidate) -> Unit)? = null
|
||||
|
||||
fun initialize() {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||
.setEnableInternalTracer(false)
|
||||
.createInitializationOptions()
|
||||
)
|
||||
|
||||
peerConnectionFactory = PeerConnectionFactory.builder()
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
fun createPeerConnection(useIceServers: Boolean = true) {
|
||||
val config = PeerConnection.RTCConfiguration(
|
||||
if (useIceServers) ICE_SERVERS else emptyList()
|
||||
).apply {
|
||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||
}
|
||||
|
||||
peerConnection = peerConnectionFactory?.createPeerConnection(config, object : PeerConnection.Observer {
|
||||
override fun onSignalingChange(state: PeerConnection.SignalingState?) {}
|
||||
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
|
||||
Log.d(TAG, "ICE state: $state")
|
||||
when (state) {
|
||||
PeerConnection.IceConnectionState.CONNECTED -> _state.value = ConnectionState.CONNECTED
|
||||
PeerConnection.IceConnectionState.FAILED -> _state.value = ConnectionState.FAILED
|
||||
PeerConnection.IceConnectionState.DISCONNECTED -> _state.value = ConnectionState.DISCONNECTED
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
|
||||
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
candidate?.let { onIceCandidate?.invoke(it) }
|
||||
}
|
||||
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) {}
|
||||
override fun onAddStream(stream: MediaStream?) {
|
||||
stream?.videoTracks?.firstOrNull()?.let { track ->
|
||||
_remoteVideoTrack.value = track
|
||||
}
|
||||
}
|
||||
override fun onRemoveStream(stream: MediaStream?) {
|
||||
_remoteVideoTrack.value = null
|
||||
}
|
||||
override fun onDataChannel(dc: DataChannel?) {
|
||||
dc ?: return
|
||||
when (dc.label()) {
|
||||
"control" -> {
|
||||
controlChannel = dc
|
||||
dc.registerObserver(createControlObserver())
|
||||
}
|
||||
"files" -> {
|
||||
fileChannel = dc
|
||||
dc.registerObserver(createFileObserver())
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onRenegotiationNeeded() {}
|
||||
override fun onAddTrack(receiver: RtpReceiver?, streams: Array<out MediaStream>?) {
|
||||
streams?.firstOrNull()?.videoTracks?.firstOrNull()?.let { track ->
|
||||
_remoteVideoTrack.value = track
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create data channels (offer side creates them)
|
||||
val controlInit = DataChannel.Init().apply {
|
||||
ordered = true
|
||||
}
|
||||
controlChannel = peerConnection?.createDataChannel("control", controlInit)
|
||||
controlChannel?.registerObserver(createControlObserver())
|
||||
|
||||
val fileInit = DataChannel.Init().apply {
|
||||
ordered = true
|
||||
}
|
||||
fileChannel = peerConnection?.createDataChannel("files", fileInit)
|
||||
fileChannel?.registerObserver(createFileObserver())
|
||||
|
||||
_state.value = ConnectionState.CONNECTING
|
||||
}
|
||||
|
||||
fun createOffer(callback: (SessionDescription) -> Unit) {
|
||||
peerConnection?.createOffer(object : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription?) {
|
||||
sdp?.let { offer ->
|
||||
peerConnection?.setLocalDescription(object : SdpObserver {
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onSetSuccess() { callback(offer) }
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
}, offer)
|
||||
}
|
||||
}
|
||||
override fun onSetSuccess() {}
|
||||
override fun onCreateFailure(error: String?) { Log.e(TAG, "Create offer failed: $error") }
|
||||
override fun onSetFailure(error: String?) {}
|
||||
}, MediaConstraints())
|
||||
}
|
||||
|
||||
fun setRemoteAnswer(sdp: SessionDescription) {
|
||||
peerConnection?.setRemoteDescription(object : SdpObserver {
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onSetSuccess() { Log.d(TAG, "Remote answer set") }
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(error: String?) { Log.e(TAG, "Set answer failed: $error") }
|
||||
}, sdp)
|
||||
}
|
||||
|
||||
fun addIceCandidate(candidate: IceCandidate) {
|
||||
peerConnection?.addIceCandidate(candidate)
|
||||
}
|
||||
|
||||
fun sendControlMessage(json: String) {
|
||||
controlChannel?.send(DataChannel.Buffer(
|
||||
java.nio.ByteBuffer.wrap(json.toByteArray()),
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
fun sendFileData(data: ByteArray) {
|
||||
fileChannel?.send(DataChannel.Buffer(
|
||||
java.nio.ByteBuffer.wrap(data),
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
controlChannel?.close()
|
||||
fileChannel?.close()
|
||||
peerConnection?.close()
|
||||
peerConnection = null
|
||||
controlChannel = null
|
||||
fileChannel = null
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
_remoteVideoTrack.value = null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
disconnect()
|
||||
peerConnectionFactory?.dispose()
|
||||
peerConnectionFactory = null
|
||||
}
|
||||
|
||||
private fun createControlObserver() = object : DataChannel.Observer {
|
||||
override fun onBufferedAmountChange(previous: Long) {}
|
||||
override fun onStateChange() {}
|
||||
override fun onMessage(buffer: DataChannel.Buffer?) {
|
||||
buffer?.let {
|
||||
val bytes = ByteArray(it.data.remaining())
|
||||
it.data.get(bytes)
|
||||
onControlMessage?.invoke(String(bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFileObserver() = object : DataChannel.Observer {
|
||||
override fun onBufferedAmountChange(previous: Long) {}
|
||||
override fun onStateChange() {}
|
||||
override fun onMessage(buffer: DataChannel.Buffer?) {
|
||||
buffer?.let {
|
||||
val bytes = ByteArray(it.data.remaining())
|
||||
it.data.get(bytes)
|
||||
onFileData?.invoke(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.omixlab.lckcontrol.app.player
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PreloadManager @Inject constructor(
|
||||
private val playerPool: VideoPlayerPool,
|
||||
) {
|
||||
private var currentPage = -1
|
||||
|
||||
fun onPageChanged(pageIndex: Int, urls: List<String>) {
|
||||
if (pageIndex == currentPage) return
|
||||
currentPage = pageIndex
|
||||
|
||||
// Prepare current page and play
|
||||
if (pageIndex in urls.indices) {
|
||||
val currentPlayer = playerPool.getPlayerForPage(pageIndex)
|
||||
if (currentPlayer.mediaItemCount == 0) {
|
||||
playerPool.preparePlayer(pageIndex, urls[pageIndex])
|
||||
}
|
||||
currentPlayer.playWhenReady = true
|
||||
}
|
||||
|
||||
// Preload next
|
||||
val nextIndex = pageIndex + 1
|
||||
if (nextIndex in urls.indices) {
|
||||
val nextPlayer = playerPool.getPlayerForPage(nextIndex)
|
||||
if (nextPlayer.mediaItemCount == 0) {
|
||||
playerPool.preparePlayer(nextIndex, urls[nextIndex])
|
||||
}
|
||||
nextPlayer.playWhenReady = false
|
||||
}
|
||||
|
||||
// Preload previous
|
||||
val prevIndex = pageIndex - 1
|
||||
if (prevIndex in urls.indices) {
|
||||
val prevPlayer = playerPool.getPlayerForPage(prevIndex)
|
||||
if (prevPlayer.mediaItemCount == 0) {
|
||||
playerPool.preparePlayer(prevIndex, urls[prevIndex])
|
||||
}
|
||||
prevPlayer.playWhenReady = false
|
||||
}
|
||||
|
||||
// Release far-away pages
|
||||
val keepRange = (pageIndex - 1)..(pageIndex + 1)
|
||||
val toRelease = playerPool.let {
|
||||
// We'll handle this through the pool's eviction logic
|
||||
emptyList<Int>()
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseAll() {
|
||||
// Handled by pool - pause current player
|
||||
}
|
||||
|
||||
fun release() {
|
||||
playerPool.releaseAll()
|
||||
currentPage = -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.omixlab.lckcontrol.app.player
|
||||
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class VideoCacheFactory @Inject constructor(
|
||||
private val cache: SimpleCache,
|
||||
) {
|
||||
fun createDataSourceFactory(): DataSource.Factory {
|
||||
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
.setConnectTimeoutMs(15_000)
|
||||
.setReadTimeoutMs(15_000)
|
||||
|
||||
return CacheDataSource.Factory()
|
||||
.setCache(cache)
|
||||
.setUpstreamDataSourceFactory(httpDataSourceFactory)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.omixlab.lckcontrol.app.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class VideoPlayerPool @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val videoCacheFactory: VideoCacheFactory,
|
||||
) {
|
||||
companion object {
|
||||
private const val POOL_SIZE = 3
|
||||
}
|
||||
|
||||
private val players = mutableListOf<ExoPlayer>()
|
||||
private val assignments = mutableMapOf<Int, ExoPlayer>() // pageIndex -> player
|
||||
|
||||
private fun createPlayer(): ExoPlayer {
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
5_000, // min buffer
|
||||
30_000, // max buffer
|
||||
1_000, // playback start buffer
|
||||
2_000, // rebuffer
|
||||
)
|
||||
.build()
|
||||
|
||||
return ExoPlayer.Builder(context)
|
||||
.setLoadControl(loadControl)
|
||||
.setMediaSourceFactory(
|
||||
androidx.media3.exoplayer.source.DefaultMediaSourceFactory(
|
||||
videoCacheFactory.createDataSourceFactory()
|
||||
)
|
||||
)
|
||||
.build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ONE
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
}
|
||||
}
|
||||
|
||||
fun getPlayerForPage(pageIndex: Int): ExoPlayer {
|
||||
// Return existing assignment
|
||||
assignments[pageIndex]?.let { return it }
|
||||
|
||||
// Try to reuse an unassigned player
|
||||
val unassigned = players.firstOrNull { player ->
|
||||
player !in assignments.values
|
||||
}
|
||||
|
||||
val player = unassigned ?: if (players.size < POOL_SIZE) {
|
||||
createPlayer().also { players.add(it) }
|
||||
} else {
|
||||
// Evict the farthest assigned player
|
||||
val farthestPage = assignments.keys
|
||||
.minByOrNull { kotlin.math.abs(it - pageIndex) * -1 }
|
||||
?: return createPlayer().also { players.add(it) }
|
||||
val evicted = assignments.remove(farthestPage)!!
|
||||
evicted.stop()
|
||||
evicted.clearMediaItems()
|
||||
evicted
|
||||
}
|
||||
|
||||
assignments[pageIndex] = player
|
||||
return player
|
||||
}
|
||||
|
||||
fun preparePlayer(pageIndex: Int, url: String) {
|
||||
val player = getPlayerForPage(pageIndex)
|
||||
player.setMediaItem(MediaItem.fromUri(url))
|
||||
player.prepare()
|
||||
}
|
||||
|
||||
fun releasePlayer(pageIndex: Int) {
|
||||
assignments.remove(pageIndex)
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
assignments.clear()
|
||||
players.forEach { it.release() }
|
||||
players.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.omixlab.lckcontrol.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
Button(onClick = onRetry) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.omixlab.lckcontrol.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun GameBadge(gameId: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = gameId,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
|
||||
RoundedCornerShape(4.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.omixlab.lckcontrol.app.ui.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LckTopBar(
|
||||
title: String,
|
||||
onNavigateBack: (() -> Unit)? = null,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
if (onNavigateBack != null) {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.omixlab.lckcontrol.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.omixlab.lckcontrol.app.ui.theme.LckRed
|
||||
|
||||
@Composable
|
||||
fun LiveBadge(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "LIVE",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier
|
||||
.background(LckRed, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.omixlab.lckcontrol.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun LoadingIndicator(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.omixlab.lckcontrol.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PullToRefreshLayout(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = modifier,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.omixlab.lckcontrol.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun UserAvatar(
|
||||
avatarUrl: String?,
|
||||
displayName: String?,
|
||||
size: Dp = 40.dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (avatarUrl != null) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = displayName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = displayName?.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.SurfaceViewRenderer
|
||||
|
||||
@Composable
|
||||
fun CameraViewScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: CameraViewViewModel = hiltViewModel(),
|
||||
) {
|
||||
val videoTrack by viewModel.remoteVideoTrack.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.startStream()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
if (videoTrack != null) {
|
||||
val eglBase = remember { EglBase.create() }
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
SurfaceViewRenderer(ctx).apply {
|
||||
init(eglBase.eglBaseContext, null)
|
||||
setMirror(false)
|
||||
setScalingType(org.webrtc.RendererCommon.ScalingType.SCALE_ASPECT_FIT)
|
||||
videoTrack?.addSink(this)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { renderer ->
|
||||
videoTrack?.addSink(renderer)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Waiting for video stream...",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
// Close button
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.stopStream()
|
||||
onNavigateBack()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.CameraViewSession
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.WebRtcClient
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.webrtc.VideoTrack
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CameraViewViewModel @Inject constructor(
|
||||
private val cameraViewSession: CameraViewSession,
|
||||
private val webRtcClient: WebRtcClient,
|
||||
) : ViewModel() {
|
||||
|
||||
val remoteVideoTrack: StateFlow<VideoTrack?> = webRtcClient.remoteVideoTrack
|
||||
|
||||
fun startStream() {
|
||||
cameraViewSession.requestVideoStream()
|
||||
}
|
||||
|
||||
fun stopStream() {
|
||||
cameraViewSession.stopVideoStream()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopStream()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.ConnectionState
|
||||
import com.omixlab.lckcontrol.app.ui.theme.LckGreen
|
||||
import com.omixlab.lckcontrol.app.ui.theme.LckRed
|
||||
|
||||
@Composable
|
||||
fun DeviceScreen(
|
||||
onNavigateToCamera: () -> Unit,
|
||||
onNavigateToFiles: () -> Unit,
|
||||
viewModel: DeviceViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Device",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// Connection status card
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhonelinkSetup,
|
||||
contentDescription = null,
|
||||
tint = when (uiState.connectionState) {
|
||||
ConnectionState.CONNECTED -> LckGreen
|
||||
ConnectionState.CONNECTING -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = uiState.pairedDevice?.deviceName ?: "No Device Connected",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = when (uiState.connectionState) {
|
||||
ConnectionState.CONNECTED -> "Connected"
|
||||
ConnectionState.CONNECTING -> "Connecting..."
|
||||
ConnectionState.FAILED -> "Connection failed"
|
||||
ConnectionState.DISCONNECTED -> "Disconnected"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (uiState.connectionState) {
|
||||
ConnectionState.CONNECTED -> LckGreen
|
||||
ConnectionState.FAILED -> LckRed
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (uiState.pairedDevice != null) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.disconnect() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.startDiscovery() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Find Quest Device")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Actions (only when connected)
|
||||
if (uiState.connectionState == ConnectionState.CONNECTED) {
|
||||
Card(
|
||||
onClick = onNavigateToCamera,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Camera View") },
|
||||
supportingContent = { Text("View Quest camera feed") },
|
||||
leadingContent = { Icon(Icons.Default.Videocam, null) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
onClick = onNavigateToFiles,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("File Transfer") },
|
||||
supportingContent = { Text("Browse and download Quest files") },
|
||||
leadingContent = { Icon(Icons.Default.Folder, null) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
RemoteControlPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery bottom sheet
|
||||
if (uiState.showDiscoverySheet) {
|
||||
DiscoverySheet(
|
||||
devices = uiState.discoveredDevices,
|
||||
isDiscovering = uiState.isDiscovering,
|
||||
onDeviceClick = { viewModel.pairWithDevice(it) },
|
||||
onDismiss = { viewModel.stopDiscovery() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.p2p.discovery.DiscoveredDevice
|
||||
import com.omixlab.lckcontrol.app.p2p.discovery.LanDiscoveryManager
|
||||
import com.omixlab.lckcontrol.app.p2p.pairing.DevicePairingManager
|
||||
import com.omixlab.lckcontrol.app.p2p.pairing.PairedDevice
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.ConnectionState
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.RemoteControlSession
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.WebRtcClient
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class DeviceUiState(
|
||||
val discoveredDevices: List<DiscoveredDevice> = emptyList(),
|
||||
val pairedDevice: PairedDevice? = null,
|
||||
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
|
||||
val isDiscovering: Boolean = false,
|
||||
val showDiscoverySheet: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DeviceViewModel @Inject constructor(
|
||||
private val discoveryManager: LanDiscoveryManager,
|
||||
private val pairingManager: DevicePairingManager,
|
||||
private val webRtcClient: WebRtcClient,
|
||||
private val remoteControlSession: RemoteControlSession,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DeviceUiState())
|
||||
val uiState: StateFlow<DeviceUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
discoveryManager.devices.collect { devices ->
|
||||
_uiState.value = _uiState.value.copy(discoveredDevices = devices)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
discoveryManager.isDiscovering.collect { discovering ->
|
||||
_uiState.value = _uiState.value.copy(isDiscovering = discovering)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
webRtcClient.state.collect { state ->
|
||||
_uiState.value = _uiState.value.copy(connectionState = state)
|
||||
if (state == ConnectionState.CONNECTED) {
|
||||
remoteControlSession.requestDeviceStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startDiscovery() {
|
||||
_uiState.value = _uiState.value.copy(showDiscoverySheet = true)
|
||||
discoveryManager.startDiscovery()
|
||||
}
|
||||
|
||||
fun stopDiscovery() {
|
||||
discoveryManager.stopDiscovery()
|
||||
_uiState.value = _uiState.value.copy(showDiscoverySheet = false)
|
||||
}
|
||||
|
||||
fun pairWithDevice(device: DiscoveredDevice) {
|
||||
viewModelScope.launch {
|
||||
val paired = pairingManager.pairWithDevice(device)
|
||||
if (paired != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
pairedDevice = paired,
|
||||
showDiscoverySheet = false,
|
||||
)
|
||||
discoveryManager.stopDiscovery()
|
||||
// Initialize WebRTC connection
|
||||
connectToDevice(paired)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(error = "Pairing failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectToDevice(device: PairedDevice) {
|
||||
webRtcClient.initialize()
|
||||
webRtcClient.createPeerConnection(useIceServers = false)
|
||||
// LAN signaling will handle offer/answer exchange
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
webRtcClient.disconnect()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
pairedDevice = null,
|
||||
connectionState = ConnectionState.DISCONNECTED,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
discoveryManager.stopDiscovery()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.omixlab.lckcontrol.app.p2p.discovery.DiscoveredDevice
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DiscoverySheet(
|
||||
devices: List<DiscoveredDevice>,
|
||||
isDiscovering: Boolean,
|
||||
onDeviceClick: (DiscoveredDevice) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.heightIn(min = 200.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Nearby Quest Devices",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
if (isDiscovering && devices.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
"Searching for devices...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(devices) { device ->
|
||||
ListItem(
|
||||
headlineContent = { Text(device.name) },
|
||||
supportingContent = {
|
||||
Text("${device.ip}:${device.port}" +
|
||||
(device.deviceModel?.let { " - $it" } ?: ""))
|
||||
},
|
||||
leadingContent = { Icon(Icons.Default.Devices, null) },
|
||||
modifier = Modifier.clickable { onDeviceClick(device) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.p2p.transfer.RemoteFile
|
||||
import com.omixlab.lckcontrol.app.ui.components.LckTopBar
|
||||
import com.omixlab.lckcontrol.app.ui.theme.LckGreen
|
||||
|
||||
@Composable
|
||||
fun FileTransferScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: FileTransferViewModel = hiltViewModel(),
|
||||
) {
|
||||
val files by viewModel.availableFiles.collectAsStateWithLifecycle()
|
||||
val transfers by viewModel.transfers.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.refreshFiles()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LckTopBar(title = "File Transfer", onNavigateBack = onNavigateBack)
|
||||
|
||||
// Active transfers
|
||||
if (transfers.isNotEmpty()) {
|
||||
Text(
|
||||
"Active Transfers",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
transfers.values.forEach { transfer ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(transfer.fileName, style = MaterialTheme.typography.bodyMedium)
|
||||
if (transfer.isComplete) {
|
||||
Icon(Icons.Default.CheckCircle, null, tint = LckGreen)
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = { viewModel.cancelTransfer(transfer.transferId) },
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Close, "Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!transfer.isComplete) {
|
||||
LinearProgressIndicator(
|
||||
progress = { transfer.progress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// File list
|
||||
Text(
|
||||
"Quest Files",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
if (files.isEmpty()) {
|
||||
Text(
|
||||
"No files found. Make sure you're connected to a Quest device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(files) { file ->
|
||||
FileListItem(
|
||||
file = file,
|
||||
onDownload = { viewModel.downloadFile(file) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileListItem(
|
||||
file: RemoteFile,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(file.name) },
|
||||
supportingContent = {
|
||||
Text(formatFileSize(file.size))
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
if (file.isDirectory) Icons.Default.Folder else Icons.Default.VideoFile,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (!file.isDirectory) {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(Icons.Default.Download, contentDescription = "Download")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Long): String = when {
|
||||
bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0)
|
||||
bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0)
|
||||
bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.omixlab.lckcontrol.app.p2p.transfer.FileTransferManager
|
||||
import com.omixlab.lckcontrol.app.p2p.transfer.RemoteFile
|
||||
import com.omixlab.lckcontrol.app.p2p.transfer.TransferProgress
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class FileTransferViewModel @Inject constructor(
|
||||
private val fileTransferManager: FileTransferManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val availableFiles: StateFlow<List<RemoteFile>> = fileTransferManager.availableFiles
|
||||
val transfers: StateFlow<Map<String, TransferProgress>> = fileTransferManager.transfers
|
||||
|
||||
fun refreshFiles() {
|
||||
fileTransferManager.requestFileList()
|
||||
}
|
||||
|
||||
fun downloadFile(file: RemoteFile) {
|
||||
fileTransferManager.downloadFile(file.path, file.name)
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String) {
|
||||
fileTransferManager.cancelTransfer(transferId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.omixlab.lckcontrol.app.ui.device
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.p2p.webrtc.RemoteControlSession
|
||||
|
||||
@Composable
|
||||
fun RemoteControlPanel(
|
||||
viewModel: DeviceViewModel = hiltViewModel(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Remote Control",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO: start stream via DataChannel */ },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Start Stream")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO: end stream via DataChannel */ },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("End Stream")
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO: toggle cortex recording */ },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Toggle Cortex")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.omixlab.lckcontrol.app.ui.feed
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.omixlab.lckcontrol.app.data.remote.PortalComment
|
||||
import com.omixlab.lckcontrol.app.data.repository.ChatEvent
|
||||
import com.omixlab.lckcontrol.app.data.repository.ChatRepository
|
||||
import com.omixlab.lckcontrol.app.ui.components.UserAvatar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CommentSheet(
|
||||
planId: String,
|
||||
chatRepository: ChatRepository,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val comments = remember { mutableStateListOf<PortalComment>() }
|
||||
var messageText by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(planId) {
|
||||
chatRepository.connectToChat(planId).collect { event ->
|
||||
when (event) {
|
||||
is ChatEvent.Comment -> comments.add(event.comment)
|
||||
is ChatEvent.Message -> {} // Platform chat messages, skip in portal view
|
||||
is ChatEvent.Error -> {} // Handle error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(planId) {
|
||||
onDispose { chatRepository.disconnect() }
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 300.dp, max = 500.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Comments",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(comments) { comment ->
|
||||
CommentItem(comment = comment)
|
||||
}
|
||||
}
|
||||
|
||||
// Input row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = messageText,
|
||||
onValueChange = { messageText = it },
|
||||
placeholder = { Text("Add a comment...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (messageText.isNotBlank()) {
|
||||
chatRepository.sendComment(planId, messageText)
|
||||
messageText = ""
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentItem(comment: PortalComment) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
UserAvatar(
|
||||
avatarUrl = comment.avatarUrl,
|
||||
displayName = comment.displayName,
|
||||
size = 32.dp,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = comment.displayName ?: "User",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = comment.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.omixlab.lckcontrol.app.ui.feed
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
|
||||
import com.omixlab.lckcontrol.app.ui.components.UserAvatar
|
||||
import com.omixlab.lckcontrol.app.ui.theme.LckRed
|
||||
|
||||
@Composable
|
||||
fun FeedActionBar(
|
||||
item: FeedItemResponse,
|
||||
onLikeClick: () -> Unit,
|
||||
onFollowClick: (String) -> Unit,
|
||||
onCommentClick: () -> Unit,
|
||||
onUserClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
// Avatar + follow button
|
||||
val resolvedUser = item.user ?: item.plan?.user
|
||||
resolvedUser?.let { user ->
|
||||
Box(
|
||||
modifier = Modifier.clickable { onUserClick(user.id) },
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
UserAvatar(
|
||||
avatarUrl = user.avatarUrl,
|
||||
displayName = user.displayName,
|
||||
size = 48.dp,
|
||||
)
|
||||
// Small follow badge
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddCircle,
|
||||
contentDescription = "Follow",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.offset(y = 8.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { onFollowClick(user.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Like
|
||||
ActionButton(
|
||||
icon = if (item.isLiked) Icons.Default.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
count = formatCount(item.likeCount),
|
||||
tint = if (item.isLiked) LckRed else Color.White,
|
||||
onClick = onLikeClick,
|
||||
)
|
||||
|
||||
// Comment
|
||||
ActionButton(
|
||||
icon = Icons.Outlined.ChatBubbleOutline,
|
||||
count = formatCount(item.commentCount),
|
||||
tint = Color.White,
|
||||
onClick = onCommentClick,
|
||||
)
|
||||
|
||||
// Share
|
||||
ActionButton(
|
||||
icon = Icons.Outlined.Share,
|
||||
count = null,
|
||||
tint = Color.White,
|
||||
onClick = { /* TODO: share intent */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
count: String?,
|
||||
tint: Color,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
count?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCount(count: Int): String = when {
|
||||
count >= 1_000_000 -> "${count / 1_000_000}M"
|
||||
count >= 1_000 -> "${count / 1_000}K"
|
||||
else -> count.toString()
|
||||
}
|
||||
103
app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedCard.kt
Normal file
103
app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedCard.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.omixlab.lckcontrol.app.ui.feed
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
|
||||
|
||||
@Composable
|
||||
fun FeedCard(
|
||||
item: FeedItemResponse,
|
||||
pageIndex: Int,
|
||||
onLikeClick: () -> Unit,
|
||||
onFollowClick: (String) -> Unit,
|
||||
onUserClick: (String) -> Unit,
|
||||
onCommentClick: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val baseUrl = "https://lck.omigame.dev"
|
||||
val rawUrl = item.previewUrl ?: item.video?.videoUrl
|
||||
val videoUrl = rawUrl?.let { if (it.startsWith("/")) "$baseUrl$it" else it }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
// Video player
|
||||
if (videoUrl != null) {
|
||||
var player by remember { mutableStateOf<ExoPlayer?>(null) }
|
||||
|
||||
DisposableEffect(videoUrl) {
|
||||
val exoPlayer = ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(videoUrl))
|
||||
repeatMode = Player.REPEAT_MODE_ONE
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
}
|
||||
player = exoPlayer
|
||||
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
player = null
|
||||
}
|
||||
}
|
||||
|
||||
player?.let { exo ->
|
||||
AndroidView(
|
||||
factory = {
|
||||
PlayerView(it).apply {
|
||||
this.player = exo
|
||||
useController = false
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Overlays — resolve user from top-level or from plan.user
|
||||
val resolvedUser = item.user ?: item.plan?.user
|
||||
FeedUserInfo(
|
||||
user = resolvedUser,
|
||||
title = item.plan?.name ?: item.video?.title,
|
||||
gameId = item.plan?.gameId,
|
||||
status = item.plan?.status,
|
||||
onUserClick = onUserClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, bottom = 16.dp, end = 80.dp)
|
||||
.navigationBarsPadding(),
|
||||
)
|
||||
|
||||
FeedActionBar(
|
||||
item = item,
|
||||
onLikeClick = onLikeClick,
|
||||
onFollowClick = onFollowClick,
|
||||
onCommentClick = onCommentClick,
|
||||
onUserClick = onUserClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 12.dp, bottom = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.omixlab.lckcontrol.app.ui.feed
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.ui.components.ErrorState
|
||||
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FeedScreen(
|
||||
isLoggedIn: Boolean,
|
||||
onNavigateToUser: (String) -> Unit,
|
||||
onLoginRequired: () -> Unit,
|
||||
viewModel: FeedViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val filters = listOf("trending", "following", "recent")
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
uiState.isLoading && uiState.items.isEmpty() -> {
|
||||
LoadingIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
uiState.error != null && uiState.items.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = uiState.error!!,
|
||||
onRetry = { viewModel.loadFeed() },
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
uiState.items.isNotEmpty() -> {
|
||||
val pagerState = rememberPagerState(pageCount = { uiState.items.size })
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
viewModel.onPageChanged(pagerState.currentPage)
|
||||
}
|
||||
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
beyondViewportPageCount = 1,
|
||||
) { page ->
|
||||
FeedCard(
|
||||
item = uiState.items[page],
|
||||
pageIndex = page,
|
||||
onLikeClick = {
|
||||
if (isLoggedIn) viewModel.toggleLike(page) else onLoginRequired()
|
||||
},
|
||||
onFollowClick = { userId ->
|
||||
if (isLoggedIn) viewModel.toggleFollow(userId) else onLoginRequired()
|
||||
},
|
||||
onUserClick = onNavigateToUser,
|
||||
onCommentClick = {
|
||||
if (isLoggedIn) { /* TODO: open comment sheet */ } else onLoginRequired()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = "No content yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter chips at top
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
filters.forEach { filter ->
|
||||
// "following" filter requires auth
|
||||
val requiresAuth = filter == "following"
|
||||
FilterChip(
|
||||
selected = uiState.filter == filter,
|
||||
onClick = {
|
||||
if (requiresAuth && !isLoggedIn) onLoginRequired()
|
||||
else viewModel.loadFeed(filter)
|
||||
},
|
||||
label = {
|
||||
Text(filter.replaceFirstChar { it.uppercase() })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.omixlab.lckcontrol.app.ui.feed
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.omixlab.lckcontrol.app.data.remote.FeedUserResponse
|
||||
import com.omixlab.lckcontrol.app.ui.components.GameBadge
|
||||
import com.omixlab.lckcontrol.app.ui.components.LiveBadge
|
||||
import com.omixlab.lckcontrol.app.ui.components.UserAvatar
|
||||
|
||||
@Composable
|
||||
fun FeedUserInfo(
|
||||
user: FeedUserResponse?,
|
||||
title: String?,
|
||||
gameId: String?,
|
||||
status: String?,
|
||||
onUserClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Username row
|
||||
user?.let { u ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.clickable { onUserClick(u.id) },
|
||||
) {
|
||||
UserAvatar(
|
||||
avatarUrl = u.avatarUrl,
|
||||
displayName = u.displayName,
|
||||
size = 32.dp,
|
||||
)
|
||||
Text(
|
||||
text = u.displayName ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
)
|
||||
if (status == "LIVE") {
|
||||
LiveBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// Game badge
|
||||
gameId?.let {
|
||||
GameBadge(gameId = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.omixlab.lckcontrol.app.ui.feed
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
|
||||
import com.omixlab.lckcontrol.app.data.repository.FeedRepository
|
||||
import com.omixlab.lckcontrol.app.data.repository.SocialRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class FeedUiState(
|
||||
val items: List<FeedItemResponse> = emptyList(),
|
||||
val filter: String = "trending",
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val error: String? = null,
|
||||
val nextCursor: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FeedViewModel @Inject constructor(
|
||||
private val feedRepository: FeedRepository,
|
||||
private val socialRepository: SocialRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(FeedUiState())
|
||||
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadFeed()
|
||||
}
|
||||
|
||||
fun loadFeed(filter: String = _uiState.value.filter) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, filter = filter)
|
||||
try {
|
||||
val (items, cursor) = feedRepository.getFeed(filter)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
items = items,
|
||||
nextCursor = cursor,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load feed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val cursor = _uiState.value.nextCursor ?: return
|
||||
if (_uiState.value.isLoadingMore) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoadingMore = true)
|
||||
try {
|
||||
val (items, nextCursor) = feedRepository.getFeed(_uiState.value.filter, cursor)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
items = _uiState.value.items + items,
|
||||
nextCursor = nextCursor,
|
||||
isLoadingMore = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isLoadingMore = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onPageChanged(pageIndex: Int) {
|
||||
val items = _uiState.value.items
|
||||
if (pageIndex >= items.size - 3) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLike(itemIndex: Int) {
|
||||
val items = _uiState.value.items.toMutableList()
|
||||
val item = items[itemIndex]
|
||||
val planId = item.plan?.id ?: return
|
||||
|
||||
// Optimistic update
|
||||
val newIsLiked = !item.isLiked
|
||||
val newLikeCount = item.likeCount + if (newIsLiked) 1 else -1
|
||||
items[itemIndex] = item.copy(isLiked = newIsLiked, likeCount = newLikeCount)
|
||||
_uiState.value = _uiState.value.copy(items = items)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (newIsLiked) socialRepository.likePlan(planId)
|
||||
else socialRepository.unlikePlan(planId)
|
||||
} catch (e: Exception) {
|
||||
// Revert on failure
|
||||
val revertItems = _uiState.value.items.toMutableList()
|
||||
revertItems[itemIndex] = item
|
||||
_uiState.value = _uiState.value.copy(items = revertItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFollow(userId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
socialRepository.followUser(userId)
|
||||
} catch (_: Exception) {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.omixlab.lckcontrol.app.ui.login
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(uiState.loginSuccess) {
|
||||
if (uiState.loginSuccess) onLoginSuccess()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "LCK Control",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the pairing code shown on your Quest headset",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.code,
|
||||
onValueChange = viewModel::onCodeChanged,
|
||||
label = { Text("Pairing Code") },
|
||||
placeholder = { Text("000000") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.headlineMedium.copy(
|
||||
textAlign = TextAlign.Center,
|
||||
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.3,
|
||||
),
|
||||
modifier = Modifier.width(240.dp),
|
||||
enabled = !uiState.isLoading,
|
||||
isError = uiState.error != null,
|
||||
supportingText = uiState.error?.let { error ->
|
||||
{ Text(error, color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "Open your Quest app and go to Settings > Pair Phone to get a code",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.omixlab.lckcontrol.app.ui.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class LoginUiState(
|
||||
val code: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val loginSuccess: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LoginUiState())
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun onCodeChanged(newCode: String) {
|
||||
if (newCode.length <= 6 && newCode.all { it.isDigit() }) {
|
||||
_uiState.value = _uiState.value.copy(code = newCode, error = null)
|
||||
if (newCode.length == 6) {
|
||||
submitCode(newCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitCode(code: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
authRepository.redeemPairingCode(code)
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, loginSuccess = true)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Invalid pairing code. Please try again.",
|
||||
code = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.omixlab.lckcontrol.app.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.omixlab.lckcontrol.app.ui.device.CameraViewScreen
|
||||
import com.omixlab.lckcontrol.app.ui.device.DeviceScreen
|
||||
import com.omixlab.lckcontrol.app.ui.device.FileTransferScreen
|
||||
import com.omixlab.lckcontrol.app.ui.feed.FeedScreen
|
||||
import com.omixlab.lckcontrol.app.ui.login.LoginScreen
|
||||
import com.omixlab.lckcontrol.app.ui.profile.AccountsScreen
|
||||
import com.omixlab.lckcontrol.app.ui.profile.FollowListScreen
|
||||
import com.omixlab.lckcontrol.app.ui.profile.ProfileScreen
|
||||
import com.omixlab.lckcontrol.app.ui.profile.UserProfileScreen
|
||||
import com.omixlab.lckcontrol.app.ui.streams.StreamDetailScreen
|
||||
import com.omixlab.lckcontrol.app.ui.streams.StreamsScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(navViewModel: NavViewModel = hiltViewModel()) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
val bottomNavRoutes = BottomNavItem.entries.map { it.route }
|
||||
val showBottomBar = currentRoute in bottomNavRoutes
|
||||
|
||||
// Always start on Feed — no auth required to browse
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar {
|
||||
BottomNavItem.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
selected = currentRoute == item.route,
|
||||
onClick = {
|
||||
// Auth-gated tabs: redirect to login if not paired
|
||||
val requiresAuth = item != BottomNavItem.Feed
|
||||
if (requiresAuth && !navViewModel.isLoggedIn) {
|
||||
navController.navigate(Screen.Login.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
return@NavigationBarItem
|
||||
}
|
||||
|
||||
navController.navigate(item.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Feed.route,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
) {
|
||||
composable(Screen.Login.route) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Feed.route) {
|
||||
FeedScreen(
|
||||
isLoggedIn = navViewModel.isLoggedIn,
|
||||
onNavigateToUser = { userId ->
|
||||
navController.navigate(Screen.UserProfile.createRoute(userId))
|
||||
},
|
||||
onLoginRequired = {
|
||||
navController.navigate(Screen.Login.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Streams.route) {
|
||||
StreamsScreen(
|
||||
onNavigateToDetail = { planId ->
|
||||
navController.navigate(Screen.StreamDetail.createRoute(planId))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.StreamDetail.route,
|
||||
arguments = listOf(navArgument("planId") { type = NavType.StringType }),
|
||||
) {
|
||||
StreamDetailScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(Screen.Device.route) {
|
||||
DeviceScreen(
|
||||
onNavigateToCamera = { navController.navigate(Screen.CameraView.route) },
|
||||
onNavigateToFiles = { navController.navigate(Screen.FileTransfer.route) },
|
||||
)
|
||||
}
|
||||
composable(Screen.CameraView.route) {
|
||||
CameraViewScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(Screen.FileTransfer.route) {
|
||||
FileTransferScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(Screen.Profile.route) {
|
||||
ProfileScreen(
|
||||
onNavigateToAccounts = { navController.navigate(Screen.Accounts.route) },
|
||||
onNavigateToFollows = { userId, tab ->
|
||||
navController.navigate(Screen.FollowList.createRoute(userId, tab))
|
||||
},
|
||||
onLogout = {
|
||||
navController.navigate(Screen.Feed.route) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Accounts.route) {
|
||||
AccountsScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(
|
||||
Screen.UserProfile.route,
|
||||
arguments = listOf(navArgument("userId") { type = NavType.StringType }),
|
||||
) {
|
||||
UserProfileScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToFollows = { userId, tab ->
|
||||
navController.navigate(Screen.FollowList.createRoute(userId, tab))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.FollowList.route,
|
||||
arguments = listOf(
|
||||
navArgument("userId") { type = NavType.StringType },
|
||||
navArgument("tab") { type = NavType.StringType; defaultValue = "followers" },
|
||||
),
|
||||
) {
|
||||
FollowListScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.omixlab.lckcontrol.app.ui.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LiveTv
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PhonelinkSetup
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
enum class BottomNavItem(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Feed(Screen.Feed.route, "Feed", Icons.Default.VideoLibrary),
|
||||
Streams(Screen.Streams.route, "Streams", Icons.Default.LiveTv),
|
||||
Device(Screen.Device.route, "Device", Icons.Default.PhonelinkSetup),
|
||||
Profile(Screen.Profile.route, "Profile", Icons.Default.Person),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.omixlab.lckcontrol.app.ui.navigation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.omixlab.lckcontrol.app.data.local.TokenStore
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NavViewModel @Inject constructor(
|
||||
val tokenStore: TokenStore,
|
||||
) : ViewModel() {
|
||||
val isLoggedIn: Boolean get() = tokenStore.isLoggedIn()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.omixlab.lckcontrol.app.ui.navigation
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
data object Login : Screen("login")
|
||||
data object Feed : Screen("feed")
|
||||
data object Streams : Screen("streams")
|
||||
data object StreamDetail : Screen("streams/{planId}") {
|
||||
fun createRoute(planId: String) = "streams/$planId"
|
||||
}
|
||||
data object Device : Screen("device")
|
||||
data object CameraView : Screen("device/camera")
|
||||
data object FileTransfer : Screen("device/files")
|
||||
data object Profile : Screen("profile")
|
||||
data object Accounts : Screen("profile/accounts")
|
||||
data object UserProfile : Screen("user/{userId}") {
|
||||
fun createRoute(userId: String) = "user/$userId"
|
||||
}
|
||||
data object FollowList : Screen("user/{userId}/follows?tab={tab}") {
|
||||
fun createRoute(userId: String, tab: String = "followers") =
|
||||
"user/$userId/follows?tab=$tab"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.ui.components.LckTopBar
|
||||
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AccountsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: AccountsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LckTopBar(title = "Linked Accounts", onNavigateBack = onNavigateBack)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(uiState.accounts) { account ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text(account.displayName ?: account.serviceId) },
|
||||
supportingContent = { Text(account.serviceId) },
|
||||
trailingContent = {
|
||||
IconButton(onClick = { viewModel.unlinkAccount(account.id) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Unlink")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Link buttons
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val authUrl = viewModel.getYouTubeAuthUrl()
|
||||
openCustomTab(context, authUrl.url)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Link YouTube")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val authUrl = viewModel.getTwitchAuthUrl()
|
||||
openCustomTab(context, authUrl.url)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Link Twitch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCustomTab(context: Context, url: String) {
|
||||
val customTabsIntent = CustomTabsIntent.Builder().build()
|
||||
customTabsIntent.launchUrl(context, Uri.parse(url))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.remote.AuthUrlResponse
|
||||
import com.omixlab.lckcontrol.app.data.remote.LinkedAccountResponse
|
||||
import com.omixlab.lckcontrol.app.data.repository.AccountRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AccountsUiState(
|
||||
val accounts: List<LinkedAccountResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AccountsViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AccountsUiState())
|
||||
val uiState: StateFlow<AccountsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
fun loadAccounts() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val accounts = accountRepository.getLinkedAccounts()
|
||||
_uiState.value = _uiState.value.copy(accounts = accounts, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load accounts",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getYouTubeAuthUrl(): AuthUrlResponse = accountRepository.getYouTubeAuthUrl()
|
||||
|
||||
suspend fun getTwitchAuthUrl(): AuthUrlResponse = accountRepository.getTwitchAuthUrl()
|
||||
|
||||
fun handleYouTubeCallback(code: String, state: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
accountRepository.youtubeCallback(code, state)
|
||||
loadAccounts()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleTwitchCallback(code: String, state: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
accountRepository.twitchCallback(code, state)
|
||||
loadAccounts()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun unlinkAccount(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
accountRepository.unlinkAccount(id)
|
||||
loadAccounts()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.ui.components.LckTopBar
|
||||
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
|
||||
import com.omixlab.lckcontrol.app.ui.components.UserAvatar
|
||||
|
||||
@Composable
|
||||
fun FollowListScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: FollowListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LckTopBar(title = "Follows", onNavigateBack = onNavigateBack)
|
||||
|
||||
// Tab row
|
||||
TabRow(
|
||||
selectedTabIndex = if (uiState.tab == "followers") 0 else 1,
|
||||
) {
|
||||
Tab(
|
||||
selected = uiState.tab == "followers",
|
||||
onClick = { viewModel.switchTab("followers") },
|
||||
text = { Text("Followers") },
|
||||
)
|
||||
Tab(
|
||||
selected = uiState.tab == "following",
|
||||
onClick = { viewModel.switchTab("following") },
|
||||
text = { Text("Following") },
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isLoading && uiState.users.isEmpty()) {
|
||||
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
items(uiState.users) { user ->
|
||||
ListItem(
|
||||
headlineContent = { Text(user.displayName ?: "User") },
|
||||
supportingContent = user.bio?.let { { Text(it, maxLines = 1) } },
|
||||
leadingContent = {
|
||||
UserAvatar(
|
||||
avatarUrl = user.avatarUrl,
|
||||
displayName = user.displayName,
|
||||
size = 40.dp,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { /* navigate to user */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.remote.UserProfileResponse
|
||||
import com.omixlab.lckcontrol.app.data.repository.SocialRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class FollowListUiState(
|
||||
val users: List<UserProfileResponse> = emptyList(),
|
||||
val tab: String = "followers",
|
||||
val isLoading: Boolean = false,
|
||||
val nextCursor: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FollowListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val socialRepository: SocialRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val userId: String = savedStateHandle["userId"] ?: ""
|
||||
private val initialTab: String = savedStateHandle["tab"] ?: "followers"
|
||||
|
||||
private val _uiState = MutableStateFlow(FollowListUiState(tab = initialTab))
|
||||
val uiState: StateFlow<FollowListUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun switchTab(tab: String) {
|
||||
_uiState.value = _uiState.value.copy(tab = tab, users = emptyList(), nextCursor = null)
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun loadList() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
try {
|
||||
val response = if (_uiState.value.tab == "followers") {
|
||||
socialRepository.getFollowers(userId)
|
||||
} else {
|
||||
socialRepository.getFollowing(userId)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
users = response.users,
|
||||
nextCursor = response.nextCursor,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val cursor = _uiState.value.nextCursor ?: return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = if (_uiState.value.tab == "followers") {
|
||||
socialRepository.getFollowers(userId, cursor)
|
||||
} else {
|
||||
socialRepository.getFollowing(userId, cursor)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
users = _uiState.value.users + response.users,
|
||||
nextCursor = response.nextCursor,
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.ui.components.ErrorState
|
||||
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
|
||||
import com.omixlab.lckcontrol.app.ui.components.UserAvatar
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onNavigateToAccounts: () -> Unit,
|
||||
onNavigateToFollows: (String, String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
viewModel: ProfileViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showEditBio by remember { mutableStateOf(false) }
|
||||
var editBioText by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading && uiState.profile == null -> {
|
||||
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
uiState.error != null && uiState.profile == null -> {
|
||||
ErrorState(
|
||||
message = uiState.error!!,
|
||||
onRetry = { viewModel.loadProfile() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
uiState.profile != null -> {
|
||||
val profile = uiState.profile!!
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Avatar and name
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
UserAvatar(
|
||||
avatarUrl = profile.avatarUrl,
|
||||
displayName = profile.displayName,
|
||||
size = 80.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = profile.displayName ?: "User",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
profile.bio?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Action buttons
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
editBioText = profile.bio ?: ""
|
||||
showEditBio = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Edit Profile")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Menu items
|
||||
ListItem(
|
||||
headlineContent = { Text("Followers & Following") },
|
||||
leadingContent = { Icon(Icons.Default.People, null) },
|
||||
modifier = Modifier.let { mod ->
|
||||
mod
|
||||
},
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("Linked Accounts") },
|
||||
leadingContent = { Icon(Icons.Default.Link, null) },
|
||||
modifier = Modifier,
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("Settings") },
|
||||
leadingContent = { Icon(Icons.Default.Settings, null) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Logout
|
||||
TextButton(
|
||||
onClick = { viewModel.logout(onLogout) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Log Out")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit bio dialog
|
||||
if (showEditBio) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEditBio = false },
|
||||
title = { Text("Edit Bio") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = editBioText,
|
||||
onValueChange = { editBioText = it },
|
||||
label = { Text("Bio") },
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.updateProfile(bio = editBioText)
|
||||
showEditBio = false
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEditBio = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.remote.UserProfileResponse
|
||||
import com.omixlab.lckcontrol.app.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ProfileUiState(
|
||||
val profile: UserProfileResponse? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ProfileViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ProfileUiState())
|
||||
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadProfile()
|
||||
}
|
||||
|
||||
fun loadProfile() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val profile = authRepository.getMe()
|
||||
_uiState.value = _uiState.value.copy(profile = profile, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load profile",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProfile(displayName: String? = null, bio: String? = null, isPublic: Boolean? = null) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val updated = authRepository.updateProfile(displayName, bio, isPublic)
|
||||
_uiState.value = _uiState.value.copy(profile = updated)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(onComplete: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.ui.components.ErrorState
|
||||
import com.omixlab.lckcontrol.app.ui.components.LckTopBar
|
||||
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
|
||||
import com.omixlab.lckcontrol.app.ui.components.UserAvatar
|
||||
|
||||
@Composable
|
||||
fun UserProfileScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToFollows: (String, String) -> Unit,
|
||||
viewModel: UserProfileViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LckTopBar(
|
||||
title = uiState.profile?.displayName ?: "Profile",
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
uiState.error != null -> {
|
||||
ErrorState(
|
||||
message = uiState.error!!,
|
||||
onRetry = { viewModel.loadProfile() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
uiState.profile != null -> {
|
||||
val profile = uiState.profile!!
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
UserAvatar(
|
||||
avatarUrl = profile.avatarUrl,
|
||||
displayName = profile.displayName,
|
||||
size = 80.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = profile.displayName ?: "User",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
profile.bio?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Follow button
|
||||
Button(
|
||||
onClick = { viewModel.toggleFollow() },
|
||||
colors = if (uiState.isFollowing) {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors()
|
||||
},
|
||||
) {
|
||||
Text(if (uiState.isFollowing) "Following" else "Follow")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Followers/Following row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
TextButton(onClick = { onNavigateToFollows(profile.id, "followers") }) {
|
||||
Text("Followers")
|
||||
}
|
||||
TextButton(onClick = { onNavigateToFollows(profile.id, "following") }) {
|
||||
Text("Following")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.omixlab.lckcontrol.app.ui.profile
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.remote.UserProfileResponse
|
||||
import com.omixlab.lckcontrol.app.data.repository.SocialRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class UserProfileUiState(
|
||||
val profile: UserProfileResponse? = null,
|
||||
val isFollowing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class UserProfileViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val socialRepository: SocialRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val userId: String = savedStateHandle["userId"] ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(UserProfileUiState())
|
||||
val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadProfile()
|
||||
}
|
||||
|
||||
fun loadProfile() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val profile = socialRepository.getUserProfile(userId)
|
||||
_uiState.value = _uiState.value.copy(profile = profile, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load profile",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFollow() {
|
||||
viewModelScope.launch {
|
||||
val wasFollowing = _uiState.value.isFollowing
|
||||
_uiState.value = _uiState.value.copy(isFollowing = !wasFollowing)
|
||||
try {
|
||||
if (wasFollowing) socialRepository.unfollowUser(userId)
|
||||
else socialRepository.followUser(userId)
|
||||
} catch (_: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isFollowing = wasFollowing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.omixlab.lckcontrol.app.ui.streams
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.ui.components.ErrorState
|
||||
import com.omixlab.lckcontrol.app.ui.components.LckTopBar
|
||||
import com.omixlab.lckcontrol.app.ui.components.LiveBadge
|
||||
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
|
||||
|
||||
@Composable
|
||||
fun StreamDetailScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: StreamDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LckTopBar(
|
||||
title = uiState.plan?.name ?: "Stream Plan",
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
uiState.error != null -> {
|
||||
ErrorState(
|
||||
message = uiState.error!!,
|
||||
onRetry = { viewModel.loadPlan() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
uiState.plan != null -> {
|
||||
val plan = uiState.plan!!
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Status card
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Status:", style = MaterialTheme.typography.labelLarge)
|
||||
if (plan.status == "LIVE") {
|
||||
LiveBadge()
|
||||
} else {
|
||||
Text(plan.status, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Mode: ${plan.executionMode}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
plan.gameId?.let {
|
||||
Text(
|
||||
"Game: $it",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Destinations
|
||||
item {
|
||||
Text(
|
||||
"Destinations",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
items(plan.destinations) { dest ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text(dest.title ?: dest.serviceId) },
|
||||
supportingContent = {
|
||||
Text(dest.serviceId + (dest.status?.let { " - $it" } ?: ""))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
when (plan.status) {
|
||||
"DRAFT" -> {
|
||||
Button(
|
||||
onClick = { viewModel.preparePlan() },
|
||||
enabled = !uiState.actionInProgress,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Prepare")
|
||||
}
|
||||
}
|
||||
"READY", "PREPARED" -> {
|
||||
Button(
|
||||
onClick = { viewModel.startPlan() },
|
||||
enabled = !uiState.actionInProgress,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Go Live")
|
||||
}
|
||||
}
|
||||
"LIVE" -> {
|
||||
Button(
|
||||
onClick = { viewModel.endPlan() },
|
||||
enabled = !uiState.actionInProgress,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("End Stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.omixlab.lckcontrol.app.ui.streams
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.remote.StreamPlanResponse
|
||||
import com.omixlab.lckcontrol.app.data.repository.StreamRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class StreamDetailUiState(
|
||||
val plan: StreamPlanResponse? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val actionInProgress: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class StreamDetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val streamRepository: StreamRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val planId: String = savedStateHandle["planId"] ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(StreamDetailUiState())
|
||||
val uiState: StateFlow<StreamDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPlan()
|
||||
}
|
||||
|
||||
fun loadPlan() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val plan = streamRepository.getStreamPlan(planId)
|
||||
_uiState.value = _uiState.value.copy(plan = plan, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load plan",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun preparePlan() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(actionInProgress = true)
|
||||
try {
|
||||
streamRepository.prepareStreamPlan(planId)
|
||||
loadPlan()
|
||||
} catch (_: Exception) {}
|
||||
_uiState.value = _uiState.value.copy(actionInProgress = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun startPlan() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(actionInProgress = true)
|
||||
try {
|
||||
streamRepository.startStreamPlan(planId)
|
||||
loadPlan()
|
||||
} catch (_: Exception) {}
|
||||
_uiState.value = _uiState.value.copy(actionInProgress = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun endPlan() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(actionInProgress = true)
|
||||
try {
|
||||
streamRepository.endStreamPlan(planId)
|
||||
loadPlan()
|
||||
} catch (_: Exception) {}
|
||||
_uiState.value = _uiState.value.copy(actionInProgress = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.omixlab.lckcontrol.app.ui.streams
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.omixlab.lckcontrol.app.ui.components.ErrorState
|
||||
import com.omixlab.lckcontrol.app.ui.components.LiveBadge
|
||||
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
|
||||
import com.omixlab.lckcontrol.app.ui.components.PullToRefreshLayout
|
||||
|
||||
@Composable
|
||||
fun StreamsScreen(
|
||||
onNavigateToDetail: (String) -> Unit,
|
||||
viewModel: StreamsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { /* TODO: create stream plan */ }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New Stream Plan")
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
PullToRefreshLayout(
|
||||
isRefreshing = uiState.isLoading,
|
||||
onRefresh = { viewModel.loadPlans() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading && uiState.plans.isEmpty() -> {
|
||||
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
uiState.error != null && uiState.plans.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = uiState.error!!,
|
||||
onRetry = { viewModel.loadPlans() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(uiState.plans) { plan ->
|
||||
Card(
|
||||
onClick = { onNavigateToDetail(plan.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(plan.name) },
|
||||
supportingContent = {
|
||||
Text("${plan.destinations.size} destination(s)")
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
if (plan.status == "LIVE") {
|
||||
LiveBadge()
|
||||
} else {
|
||||
Text(
|
||||
text = plan.status,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.deletePlan(plan.id) },
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.omixlab.lckcontrol.app.ui.streams
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.omixlab.lckcontrol.app.data.remote.StreamPlanResponse
|
||||
import com.omixlab.lckcontrol.app.data.repository.StreamRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class StreamsUiState(
|
||||
val plans: List<StreamPlanResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class StreamsViewModel @Inject constructor(
|
||||
private val streamRepository: StreamRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(StreamsUiState())
|
||||
val uiState: StateFlow<StreamsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPlans()
|
||||
}
|
||||
|
||||
fun loadPlans() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val plans = streamRepository.getStreamPlans()
|
||||
_uiState.value = _uiState.value.copy(plans = plans, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load stream plans",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePlan(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
streamRepository.deleteStreamPlan(id)
|
||||
loadPlans()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.omixlab.lckcontrol.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val LckBlue80 = Color(0xFF9ECAFF)
|
||||
val LckBlue60 = Color(0xFF5BA3FF)
|
||||
val LckBlue40 = Color(0xFF2979FF)
|
||||
|
||||
val LckRed = Color(0xFFFF4444)
|
||||
val LckGreen = Color(0xFF4CAF50)
|
||||
|
||||
val DarkSurface = Color(0xFF0F0F0F)
|
||||
val DarkSurfaceVariant = Color(0xFF1A1A1A)
|
||||
val DarkBackground = Color(0xFF000000)
|
||||
val DarkOnSurface = Color(0xFFE1E1E1)
|
||||
val DarkOnSurfaceVariant = Color(0xFF9E9E9E)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.omixlab.lckcontrol.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = LckBlue80,
|
||||
secondary = LckBlue60,
|
||||
tertiary = LckBlue40,
|
||||
background = DarkBackground,
|
||||
surface = DarkSurface,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onBackground = DarkOnSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
error = LckRed,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LCKControlAppTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DarkColorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.omixlab.lckcontrol.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
)
|
||||
17
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
17
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M54,30 L54,78 M38,42 L54,30 L70,42 M38,66 L54,78 L70,66"
|
||||
android:strokeWidth="4"
|
||||
android:strokeColor="#9ECAFF"
|
||||
android:fillColor="@android:color/transparent"/>
|
||||
<path
|
||||
android:pathData="M34,54 a20,20 0 1,1 40,0 a20,20 0 1,1 -40,0"
|
||||
android:strokeWidth="3"
|
||||
android:strokeColor="#9ECAFF"
|
||||
android:fillColor="@android:color/transparent"/>
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0F0F0F</color>
|
||||
</resources>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">LCK Control</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.LCKControlApp" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
8
app/src/main/res/xml/network_security_config.xml
Normal file
8
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
||||
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
||||
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
7
build.gradle.kts
Normal file
7
build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.parcelize) apply false
|
||||
alias(libs.plugins.hilt) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
android.disallowKotlinSourceSets=false
|
||||
90
gradle/libs.versions.toml
Normal file
90
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,90 @@
|
||||
[versions]
|
||||
agp = "9.0.1"
|
||||
kotlin = "2.2.10"
|
||||
ksp = "2.2.10-2.0.2"
|
||||
coreKtx = "1.13.1"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
espressoCore = "3.6.1"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
composeBom = "2024.09.00"
|
||||
hilt = "2.59.2"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
room = "2.8.4"
|
||||
navigationCompose = "2.8.4"
|
||||
retrofit = "2.11.0"
|
||||
moshi = "1.15.1"
|
||||
okhttp = "4.12.0"
|
||||
securityCrypto = "1.0.0"
|
||||
browser = "1.8.0"
|
||||
coroutines = "1.9.0"
|
||||
media3 = "1.5.1"
|
||||
coil = "2.7.0"
|
||||
webrtc = "137.7151.05"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||
|
||||
# Hilt
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||
|
||||
# Room
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# Networking
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
|
||||
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
|
||||
# Security
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
|
||||
# Browser (Custom Tabs)
|
||||
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||
|
||||
# Coroutines
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
|
||||
# Media3 (ExoPlayer)
|
||||
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||
media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
|
||||
media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
||||
|
||||
# Image Loading
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
# WebRTC
|
||||
webrtc = { group = "io.github.webrtc-sdk", name = "android", version.ref = "webrtc" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
gradlew
vendored
Normal file
251
gradlew
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user