From 6f02d33b9740004fe373a36866797ba054c308e0 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 4 Mar 2026 12:04:21 +0100 Subject: [PATCH] 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. --- .gitignore | 12 + app/build.gradle.kts | 132 ++++++++ app/proguard-rules.pro | 10 + app/src/main/AndroidManifest.xml | 65 ++++ .../lckcontrol/app/LckControlPhoneApp.kt | 7 + .../omixlab/lckcontrol/app/MainActivity.kt | 27 ++ .../app/auth/TwitchAuthRedirectActivity.kt | 16 + .../app/auth/YouTubeAuthRedirectActivity.kt | 17 ++ .../app/data/local/AppPreferences.kt | 29 ++ .../app/data/local/LckPhoneDatabase.kt | 25 ++ .../lckcontrol/app/data/local/TokenStore.kt | 60 ++++ .../app/data/local/dao/FeedItemDao.kt | 23 ++ .../app/data/local/dao/LinkedAccountDao.kt | 20 ++ .../app/data/local/dao/UserProfileDao.kt | 23 ++ .../data/local/entity/CachedFeedItemEntity.kt | 25 ++ .../local/entity/CachedUserProfileEntity.kt | 15 + .../data/local/entity/LinkedAccountEntity.kt | 14 + .../lckcontrol/app/data/remote/ApiModels.kt | 282 ++++++++++++++++++ .../app/data/remote/AuthInterceptor.kt | 94 ++++++ .../lckcontrol/app/data/remote/ChatModels.kt | 76 +++++ .../app/data/remote/LckApiService.kt | 152 ++++++++++ .../app/data/repository/AccountRepository.kt | 45 +++ .../app/data/repository/AuthRepository.kt | 40 +++ .../app/data/repository/ChatRepository.kt | 99 ++++++ .../app/data/repository/DeviceRepository.kt | 35 +++ .../app/data/repository/FeedRepository.kt | 57 ++++ .../app/data/repository/SocialRepository.kt | 36 +++ .../app/data/repository/StreamRepository.kt | 28 ++ .../omixlab/lckcontrol/app/di/AppModule.kt | 59 ++++ .../lckcontrol/app/di/DatabaseModule.kt | 39 +++ .../omixlab/lckcontrol/app/di/PlayerModule.kt | 29 ++ .../app/p2p/discovery/DiscoveredDevice.kt | 9 + .../app/p2p/discovery/LanDiscoveryManager.kt | 105 +++++++ .../app/p2p/pairing/DevicePairingManager.kt | 72 +++++ .../app/p2p/pairing/PairedDevice.kt | 9 + .../app/p2p/transfer/FileTransferManager.kt | 154 ++++++++++ .../app/p2p/transfer/TransferProgress.kt | 13 + .../app/p2p/webrtc/CameraViewSession.kt | 29 ++ .../app/p2p/webrtc/LanSignalingClient.kt | 101 +++++++ .../app/p2p/webrtc/RemoteControlSession.kt | 102 +++++++ .../app/p2p/webrtc/RemoteSignalingClient.kt | 162 ++++++++++ .../lckcontrol/app/p2p/webrtc/WebRtcClient.kt | 209 +++++++++++++ .../lckcontrol/app/player/PreloadManager.kt | 61 ++++ .../app/player/VideoCacheFactory.kt | 24 ++ .../lckcontrol/app/player/VideoPlayerPool.kt | 89 ++++++ .../app/ui/components/ErrorState.kt | 32 ++ .../lckcontrol/app/ui/components/GameBadge.kt | 25 ++ .../lckcontrol/app/ui/components/LckTopBar.kt | 24 ++ .../lckcontrol/app/ui/components/LiveBadge.kt | 26 ++ .../app/ui/components/LoadingIndicator.kt | 14 + .../app/ui/components/PullToRefresh.kt | 24 ++ .../app/ui/components/UserAvatar.kt | 49 +++ .../app/ui/device/CameraViewScreen.kt | 78 +++++ .../app/ui/device/CameraViewViewModel.kt | 31 ++ .../lckcontrol/app/ui/device/DeviceScreen.kt | 142 +++++++++ .../app/ui/device/DeviceViewModel.kt | 105 +++++++ .../app/ui/device/DiscoverySheet.kt | 73 +++++ .../app/ui/device/FileTransferScreen.kt | 138 +++++++++ .../app/ui/device/FileTransferViewModel.kt | 30 ++ .../app/ui/device/RemoteControlPanel.kt | 50 ++++ .../lckcontrol/app/ui/feed/CommentSheet.kt | 118 ++++++++ .../lckcontrol/app/ui/feed/FeedActionBar.kt | 121 ++++++++ .../lckcontrol/app/ui/feed/FeedCard.kt | 103 +++++++ .../lckcontrol/app/ui/feed/FeedScreen.kt | 102 +++++++ .../lckcontrol/app/ui/feed/FeedUserInfo.kt | 72 +++++ .../lckcontrol/app/ui/feed/FeedViewModel.kt | 115 +++++++ .../lckcontrol/app/ui/login/LoginScreen.kt | 86 ++++++ .../lckcontrol/app/ui/login/LoginViewModel.kt | 56 ++++ .../app/ui/navigation/AppNavigation.kt | 163 ++++++++++ .../app/ui/navigation/BottomNavItem.kt | 19 ++ .../app/ui/navigation/NavViewModel.kt | 13 + .../lckcontrol/app/ui/navigation/Screen.kt | 22 ++ .../app/ui/profile/AccountsScreen.kt | 92 ++++++ .../app/ui/profile/AccountsViewModel.kt | 78 +++++ .../app/ui/profile/FollowListScreen.kt | 67 +++++ .../app/ui/profile/FollowListViewModel.kt | 79 +++++ .../app/ui/profile/ProfileScreen.kt | 161 ++++++++++ .../app/ui/profile/ProfileViewModel.kt | 62 ++++ .../app/ui/profile/UserProfileScreen.kt | 100 +++++++ .../app/ui/profile/UserProfileViewModel.kt | 64 ++++ .../app/ui/streams/StreamDetailScreen.kt | 142 +++++++++ .../app/ui/streams/StreamDetailViewModel.kt | 84 ++++++ .../app/ui/streams/StreamsScreen.kt | 94 ++++++ .../app/ui/streams/StreamsViewModel.kt | 55 ++++ .../omixlab/lckcontrol/app/ui/theme/Color.kt | 16 + .../omixlab/lckcontrol/app/ui/theme/Theme.kt | 27 ++ .../omixlab/lckcontrol/app/ui/theme/Type.kt | 54 ++++ .../res/drawable/ic_launcher_foreground.xml | 17 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 4 + .../main/res/xml/network_security_config.xml | 8 + build.gradle.kts | 7 + gradle.properties | 5 + gradle/libs.versions.toml | 90 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++ gradlew.bat | 94 ++++++ lck-control-app.keystore | Bin 0 -> 2778 bytes settings.gradle.kts | 26 ++ 103 files changed, 6262 insertions(+) create mode 100644 .gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/LckControlPhoneApp.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/MainActivity.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/auth/TwitchAuthRedirectActivity.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/auth/YouTubeAuthRedirectActivity.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/AppPreferences.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/LckPhoneDatabase.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/TokenStore.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/FeedItemDao.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/LinkedAccountDao.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/UserProfileDao.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedFeedItemEntity.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedUserProfileEntity.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/LinkedAccountEntity.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ApiModels.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/remote/AuthInterceptor.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ChatModels.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/remote/LckApiService.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AccountRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/repository/ChatRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/repository/DeviceRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/repository/FeedRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/repository/SocialRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/data/repository/StreamRepository.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/di/AppModule.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/di/DatabaseModule.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/di/PlayerModule.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/DiscoveredDevice.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/LanDiscoveryManager.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/DevicePairingManager.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/PairedDevice.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/FileTransferManager.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/TransferProgress.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/CameraViewSession.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/LanSignalingClient.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteControlSession.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteSignalingClient.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/WebRtcClient.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/player/PreloadManager.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/player/VideoCacheFactory.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/player/VideoPlayerPool.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/components/ErrorState.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/components/GameBadge.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LckTopBar.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LiveBadge.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LoadingIndicator.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/components/PullToRefresh.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/components/UserAvatar.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DiscoverySheet.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/device/RemoteControlPanel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/CommentSheet.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedActionBar.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedCard.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedUserInfo.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/AppNavigation.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/BottomNavItem.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/NavViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/Screen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsScreen.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsViewModel.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Color.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 lck-control-app.keystore create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73ec89f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build +*.hprof diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..b14ebe6 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..4c3ab2a --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..196d992 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/LckControlPhoneApp.kt b/app/src/main/java/com/omixlab/lckcontrol/app/LckControlPhoneApp.kt new file mode 100644 index 0000000..a6e4429 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/LckControlPhoneApp.kt @@ -0,0 +1,7 @@ +package com.omixlab.lckcontrol.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class LckControlPhoneApp : Application() diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/MainActivity.kt b/app/src/main/java/com/omixlab/lckcontrol/app/MainActivity.kt new file mode 100644 index 0000000..7820c48 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/MainActivity.kt @@ -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() + } + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/auth/TwitchAuthRedirectActivity.kt b/app/src/main/java/com/omixlab/lckcontrol/app/auth/TwitchAuthRedirectActivity.kt new file mode 100644 index 0000000..baf8f1d --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/auth/TwitchAuthRedirectActivity.kt @@ -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() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/auth/YouTubeAuthRedirectActivity.kt b/app/src/main/java/com/omixlab/lckcontrol/app/auth/YouTubeAuthRedirectActivity.kt new file mode 100644 index 0000000..a707ea5 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/auth/YouTubeAuthRedirectActivity.kt @@ -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() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/AppPreferences.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/AppPreferences.kt new file mode 100644 index 0000000..30a0614 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/AppPreferences.kt @@ -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" + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/LckPhoneDatabase.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/LckPhoneDatabase.kt new file mode 100644 index 0000000..f89392c --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/LckPhoneDatabase.kt @@ -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 +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/TokenStore.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/TokenStore.kt new file mode 100644 index 0000000..163f2f1 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/TokenStore.kt @@ -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" + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/FeedItemDao.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/FeedItemDao.kt new file mode 100644 index 0000000..13ef378 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/FeedItemDao.kt @@ -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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(items: List) + + @Query("DELETE FROM cached_feed_items WHERE filter = :filter") + suspend fun deleteByFilter(filter: String) + + @Query("DELETE FROM cached_feed_items") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/LinkedAccountDao.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/LinkedAccountDao.kt new file mode 100644 index 0000000..12c3e74 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/LinkedAccountDao.kt @@ -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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(accounts: List) + + @Query("DELETE FROM cached_linked_accounts") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/UserProfileDao.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/UserProfileDao.kt new file mode 100644 index 0000000..f9290ad --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/dao/UserProfileDao.kt @@ -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() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedFeedItemEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedFeedItemEntity.kt new file mode 100644 index 0000000..3126b5c --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedFeedItemEntity.kt @@ -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(), +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedUserProfileEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedUserProfileEntity.kt new file mode 100644 index 0000000..4a4cb41 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/CachedUserProfileEntity.kt @@ -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(), +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/LinkedAccountEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/LinkedAccountEntity.kt new file mode 100644 index 0000000..2128234 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/local/entity/LinkedAccountEntity.kt @@ -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(), +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ApiModels.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ApiModels.kt new file mode 100644 index 0000000..451099d --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ApiModels.kt @@ -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 = 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? = 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? = 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 = 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, +) + +@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, + 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, + 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, +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/AuthInterceptor.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..aeb2fed --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/AuthInterceptor.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ChatModels.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ChatModels.kt new file mode 100644 index 0000000..b8c701b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/ChatModels.kt @@ -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, +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/LckApiService.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/LckApiService.kt new file mode 100644 index 0000000..aa627f8 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/remote/LckApiService.kt @@ -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 + + @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 + + @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 + + @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 + + @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 +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AccountRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AccountRepository.kt new file mode 100644 index 0000000..98978d4 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AccountRepository.kt @@ -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 { + val accounts = api.getLinkedAccounts() + linkedAccountDao.deleteAll() + linkedAccountDao.insertAll(accounts.map { it.toEntity() }) + return accounts + } + + suspend fun getCachedAccounts(): List = 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, + ) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AuthRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AuthRepository.kt new file mode 100644 index 0000000..6b68390 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/AuthRepository.kt @@ -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() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/ChatRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/ChatRepository.kt new file mode 100644 index 0000000..c0c51bb --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/ChatRepository.kt @@ -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 = 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() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/DeviceRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/DeviceRepository.kt new file mode 100644 index 0000000..ec1bb0b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/DeviceRepository.kt @@ -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>(emptyList()) + val discoveredDevices: StateFlow> = _discoveredDevices.asStateFlow() + + private val _pairedDeviceId = MutableStateFlow(null) + val pairedDeviceId: StateFlow = _pairedDeviceId.asStateFlow() + + suspend fun getRegisteredDevices(): List = api.getDevices() + + suspend fun getDeviceStatus(id: String): DeviceResponse = api.getDeviceStatus(id) + + suspend fun removeDevice(id: String) = api.removeDevice(id) + + fun updateDiscoveredDevices(devices: List) { + _discoveredDevices.value = devices + } + + fun setPairedDevice(deviceId: String?) { + _pairedDeviceId.value = deviceId + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/FeedRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/FeedRepository.kt new file mode 100644 index 0000000..3a4c534 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/FeedRepository.kt @@ -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, 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 = + 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, + ) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/SocialRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/SocialRepository.kt new file mode 100644 index 0000000..061923f --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/SocialRepository.kt @@ -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) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/StreamRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/StreamRepository.kt new file mode 100644 index 0000000..7b7d4f8 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/data/repository/StreamRepository.kt @@ -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 = 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) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/di/AppModule.kt b/app/src/main/java/com/omixlab/lckcontrol/app/di/AppModule.kt new file mode 100644 index 0000000..527b0bf --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/di/AppModule.kt @@ -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) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/di/DatabaseModule.kt b/app/src/main/java/com/omixlab/lckcontrol/app/di/DatabaseModule.kt new file mode 100644 index 0000000..06204f8 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/di/DatabaseModule.kt @@ -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() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/di/PlayerModule.kt b/app/src/main/java/com/omixlab/lckcontrol/app/di/PlayerModule.kt new file mode 100644 index 0000000..ef0a08f --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/di/PlayerModule.kt @@ -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) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/DiscoveredDevice.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/DiscoveredDevice.kt new file mode 100644 index 0000000..1d99071 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/DiscoveredDevice.kt @@ -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, +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/LanDiscoveryManager.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/LanDiscoveryManager.kt new file mode 100644 index 0000000..c5aaaf4 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/discovery/LanDiscoveryManager.kt @@ -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>(emptyList()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _isDiscovering = MutableStateFlow(false) + val isDiscovering: StateFlow = _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 + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/DevicePairingManager.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/DevicePairingManager.kt new file mode 100644 index 0000000..dfc5182 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/DevicePairingManager.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/PairedDevice.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/PairedDevice.kt new file mode 100644 index 0000000..eac591f --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/pairing/PairedDevice.kt @@ -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, +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/FileTransferManager.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/FileTransferManager.kt new file mode 100644 index 0000000..1c93a5a --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/FileTransferManager.kt @@ -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>(emptyMap()) + val transfers: StateFlow> = _transfers.asStateFlow() + + private val _availableFiles = MutableStateFlow>(emptyList()) + val availableFiles: StateFlow> = _availableFiles.asStateFlow() + + private val activeTransfers = mutableMapOf() + 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, +) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/TransferProgress.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/TransferProgress.kt new file mode 100644 index 0000000..b6ee31e --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/transfer/TransferProgress.kt @@ -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 +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/CameraViewSession.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/CameraViewSession.kt new file mode 100644 index 0000000..f2f8307 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/CameraViewSession.kt @@ -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 +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/LanSignalingClient.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/LanSignalingClient.kt new file mode 100644 index 0000000..bbd8290 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/LanSignalingClient.kt @@ -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, + val nonce: String, +) + +@JsonClass(generateAdapter = true) +data class OfferResponse( + val sdp: String, + val type: String, + val iceCandidates: List, +) + +@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, + ): Pair>? = 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 + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteControlSession.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteControlSession.kt new file mode 100644 index 0000000..8687b3b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteControlSession.kt @@ -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? = 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(null) + val deviceStatus: StateFlow = _deviceStatus.asStateFlow() + + private val _streamingStats = MutableStateFlow(null) + val streamingStats: StateFlow = _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? = null) { + val msg = ControlMessage( + type = "request", + method = method, + payload = payload, + ) + val json = moshi.adapter(ControlMessage::class.java).toJson(msg) + webRtcClient.sendControlMessage(json) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteSignalingClient.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteSignalingClient.kt new file mode 100644 index 0000000..9ebb3f6 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/RemoteSignalingClient.kt @@ -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? = 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) : 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 = 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) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/WebRtcClient.kt b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/WebRtcClient.kt new file mode 100644 index 0000000..30530bd --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/p2p/webrtc/WebRtcClient.kt @@ -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 = _state.asStateFlow() + + private val _remoteVideoTrack = MutableStateFlow(null) + val remoteVideoTrack: StateFlow = _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?) {} + 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?) { + 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) + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/player/PreloadManager.kt b/app/src/main/java/com/omixlab/lckcontrol/app/player/PreloadManager.kt new file mode 100644 index 0000000..a649192 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/player/PreloadManager.kt @@ -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) { + 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() + } + } + + fun pauseAll() { + // Handled by pool - pause current player + } + + fun release() { + playerPool.releaseAll() + currentPage = -1 + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/player/VideoCacheFactory.kt b/app/src/main/java/com/omixlab/lckcontrol/app/player/VideoCacheFactory.kt new file mode 100644 index 0000000..2d291cd --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/player/VideoCacheFactory.kt @@ -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) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/player/VideoPlayerPool.kt b/app/src/main/java/com/omixlab/lckcontrol/app/player/VideoPlayerPool.kt new file mode 100644 index 0000000..f45c9b9 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/player/VideoPlayerPool.kt @@ -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() + private val assignments = mutableMapOf() // 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() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/ErrorState.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/ErrorState.kt new file mode 100644 index 0000000..582a994 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/ErrorState.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/GameBadge.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/GameBadge.kt new file mode 100644 index 0000000..a62c247 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/GameBadge.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LckTopBar.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LckTopBar.kt new file mode 100644 index 0000000..7bf8438 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LckTopBar.kt @@ -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") + } + } + }, + ) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LiveBadge.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LiveBadge.kt new file mode 100644 index 0000000..97dbc78 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LiveBadge.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LoadingIndicator.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LoadingIndicator.kt new file mode 100644 index 0000000..0d629f0 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/LoadingIndicator.kt @@ -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() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/PullToRefresh.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/PullToRefresh.kt new file mode 100644 index 0000000..a7488d7 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/PullToRefresh.kt @@ -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() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/UserAvatar.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/UserAvatar.kt new file mode 100644 index 0000000..2b56ca9 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/components/UserAvatar.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewScreen.kt new file mode 100644 index 0000000..56e718b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewScreen.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewViewModel.kt new file mode 100644 index 0000000..92fad6b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/CameraViewViewModel.kt @@ -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 = webRtcClient.remoteVideoTrack + + fun startStream() { + cameraViewSession.requestVideoStream() + } + + fun stopStream() { + cameraViewSession.stopVideoStream() + } + + override fun onCleared() { + super.onCleared() + stopStream() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceScreen.kt new file mode 100644 index 0000000..eb72fa4 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceScreen.kt @@ -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() }, + ) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceViewModel.kt new file mode 100644 index 0000000..98352a7 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DeviceViewModel.kt @@ -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 = 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 = _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() + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DiscoverySheet.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DiscoverySheet.kt new file mode 100644 index 0000000..2593b08 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/DiscoverySheet.kt @@ -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, + 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()) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferScreen.kt new file mode 100644 index 0000000..d1c26c6 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferScreen.kt @@ -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" +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferViewModel.kt new file mode 100644 index 0000000..21b7cff --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/FileTransferViewModel.kt @@ -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> = fileTransferManager.availableFiles + val transfers: StateFlow> = fileTransferManager.transfers + + fun refreshFiles() { + fileTransferManager.requestFileList() + } + + fun downloadFile(file: RemoteFile) { + fileTransferManager.downloadFile(file.path, file.name) + } + + fun cancelTransfer(transferId: String) { + fileTransferManager.cancelTransfer(transferId) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/RemoteControlPanel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/RemoteControlPanel.kt new file mode 100644 index 0000000..ce0ccaa --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/device/RemoteControlPanel.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/CommentSheet.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/CommentSheet.kt new file mode 100644 index 0000000..0709b87 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/CommentSheet.kt @@ -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() } + 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, + ) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedActionBar.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedActionBar.kt new file mode 100644 index 0000000..fb8cd53 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedActionBar.kt @@ -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() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedCard.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedCard.kt new file mode 100644 index 0000000..a5d4e8e --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedCard.kt @@ -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(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(), + ) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedScreen.kt new file mode 100644 index 0000000..8bb6928 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedScreen.kt @@ -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() }) + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedUserInfo.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedUserInfo.kt new file mode 100644 index 0000000..d3632a7 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedUserInfo.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedViewModel.kt new file mode 100644 index 0000000..fdf10e1 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/feed/FeedViewModel.kt @@ -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 = 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 = _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 + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt new file mode 100644 index 0000000..75e89a0 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..bcb387e --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/login/LoginViewModel.kt @@ -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 = _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) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/AppNavigation.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..2096fad --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/AppNavigation.kt @@ -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() }) + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/BottomNavItem.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/BottomNavItem.kt new file mode 100644 index 0000000..b8b406b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/BottomNavItem.kt @@ -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), +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/NavViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/NavViewModel.kt new file mode 100644 index 0000000..27d91d0 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/NavViewModel.kt @@ -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() +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/Screen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/Screen.kt new file mode 100644 index 0000000..ef96026 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/navigation/Screen.kt @@ -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" + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsScreen.kt new file mode 100644 index 0000000..df6ab9b --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsScreen.kt @@ -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)) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsViewModel.kt new file mode 100644 index 0000000..c67655e --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/AccountsViewModel.kt @@ -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 = 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 = _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) {} + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListScreen.kt new file mode 100644 index 0000000..41a2db1 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListScreen.kt @@ -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 */ }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListViewModel.kt new file mode 100644 index 0000000..626a0d2 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/FollowListViewModel.kt @@ -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 = 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 = _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) {} + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..2ab69af --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileScreen.kt @@ -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") + } + }, + ) + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..3e3d71d --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/ProfileViewModel.kt @@ -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 = _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() + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileScreen.kt new file mode 100644 index 0000000..34ce4c5 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileScreen.kt @@ -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") + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileViewModel.kt new file mode 100644 index 0000000..26664ef --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/profile/UserProfileViewModel.kt @@ -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 = _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) + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailScreen.kt new file mode 100644 index 0000000..cb65088 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailScreen.kt @@ -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") + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailViewModel.kt new file mode 100644 index 0000000..75e0881 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamDetailViewModel.kt @@ -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 = _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) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsScreen.kt new file mode 100644 index 0000000..3131ff0 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsScreen.kt @@ -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") + } + } + }, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsViewModel.kt new file mode 100644 index 0000000..ff96514 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/streams/StreamsViewModel.kt @@ -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 = 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 = _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) {} + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Color.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Color.kt new file mode 100644 index 0000000..f9afe0c --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Theme.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Theme.kt new file mode 100644 index 0000000..564ecf5 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Type.kt b/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Type.kt new file mode 100644 index 0000000..e8e0fc4 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/app/ui/theme/Type.kt @@ -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, + ), +) diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..6de4b6b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..78fce50 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #0F0F0F + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9b4b2c4 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + LCK Control + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..546abc4 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +