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:
2026-03-04 12:04:21 +01:00
commit 6f02d33b97
103 changed files with 6262 additions and 0 deletions

12
.gitignore vendored Normal file
View 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
View 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
View 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.** { *; }

View 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>

View File

@@ -0,0 +1,7 @@
package com.omixlab.lckcontrol.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class LckControlPhoneApp : Application()

View 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()
}
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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(),
)

View File

@@ -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,
)

View File

@@ -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
}
}
}

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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,
)
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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)
}

View 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)
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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),
)
}

View File

@@ -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")
}
}
},
)
}

View File

@@ -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),
)
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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() },
)
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}
}

View File

@@ -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"
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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()
}

View 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(),
)
}
}

View File

@@ -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() })
},
)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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)
}
}

View File

@@ -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() })
}
}
}
}

View File

@@ -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),
}

View File

@@ -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()
}

View File

@@ -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"
}
}

View File

@@ -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))
}

View File

@@ -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) {}
}
}
}

View File

@@ -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 */ },
)
}
}
}
}
}

View File

@@ -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) {}
}
}
}

View File

@@ -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")
}
},
)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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")
}
}
}
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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")
}
}
}
}
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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")
}
}
},
)
}
}
}
}
}
}
}
}

View File

@@ -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) {}
}
}
}

View File

@@ -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)

View File

@@ -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,
)
}

View File

@@ -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,
),
)

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0F0F0F</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">LCK Control</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LCKControlApp" parent="android:Theme.Material.NoActionBar" />
</resources>

View 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
View 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
View 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
View 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

Binary file not shown.

View 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
View 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