This commit is contained in:
2025-12-03 19:39:42 +09:00
commit 2bc018a4f7
68 changed files with 5663 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

84
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,84 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.camcontrol"
compileSdk = 34
defaultConfig {
applicationId = "com.example.camcontrol"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
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)
// Camera & MediaCodec
implementation("androidx.camera:camera-core:1.3.0")
implementation("androidx.camera:camera-camera2:1.3.0")
implementation("androidx.camera:camera-lifecycle:1.3.0")
implementation("androidx.camera:camera-view:1.3.0")
// WebSocket
implementation("com.squareup.okhttp3:okhttp:4.11.0")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
// Permissions
implementation("com.google.accompanist:accompanist-permissions:0.33.1-alpha")
// Icons
implementation("androidx.compose.material:material-icons-extended:1.5.4")
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)
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package com.example.camcontrol
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.camcontrol", appContext.packageName)
}
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Hardware features -->
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CamControl">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.CamControl">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,110 @@
package com.example.camcontrol
import android.content.Context
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.common.util.concurrent.ListenableFuture
import java.util.concurrent.Executors
class CameraManager(private val context: Context) {
private var cameraProvider: ProcessCameraProvider? = null
private var imageCapture: ImageCapture? = null
private val cameraExecutor = Executors.newSingleThreadExecutor()
fun startCamera(
lifecycleOwner: LifecycleOwner,
previewSurfaceProvider: (Preview.SurfaceProvider) -> Unit,
onError: (String) -> Unit
) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener(
{
try {
cameraProvider = cameraProviderFuture.get()
// Create preview
val preview = Preview.Builder()
.build()
.apply {
setSurfaceProvider { surfaceProvider ->
previewSurfaceProvider(surfaceProvider)
}
}
// Create image capture
imageCapture = ImageCapture.Builder()
.setTargetRotation(android.view.Surface.ROTATION_0)
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
// Select back camera
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
// Unbind all use cases
cameraProvider?.unbindAll()
// Bind use cases to camera
cameraProvider?.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
Log.d("CameraManager", "Camera started successfully")
} catch (exc: Exception) {
Log.e("CameraManager", "Use case binding failed", exc)
onError("Failed to start camera: ${exc.message}")
}
},
ContextCompat.getMainExecutor(context)
)
}
fun captureFrame(onFrameCaptured: (ByteArray) -> Unit, onError: (String) -> Unit) {
val imageCapture = imageCapture ?: return
val outputOptions = ImageCapture.OutputFileOptions.Builder(
context.contentResolver,
android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
android.content.ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, System.currentTimeMillis())
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
}
).build()
imageCapture.takePicture(
outputOptions,
cameraExecutor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
Log.d("CameraManager", "Image captured successfully")
}
override fun onError(exception: ImageCaptureException) {
Log.e("CameraManager", "Image capture failed: ${exception.message}")
onError("Failed to capture image: ${exception.message}")
}
}
)
}
fun stopCamera() {
try {
cameraProvider?.unbindAll()
cameraExecutor.shutdown()
Log.d("CameraManager", "Camera stopped")
} catch (exc: Exception) {
Log.e("CameraManager", "Error stopping camera", exc)
}
}
}

View File

@@ -0,0 +1,418 @@
package com.example.camcontrol
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.camcontrol.ui.theme.CamControlTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Request camera permission
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.CAMERA),
CAMERA_PERMISSION_REQUEST_CODE
)
}
setContent {
CamControlTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
StreamingApp(
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
companion object {
private const val CAMERA_PERMISSION_REQUEST_CODE = 101
}
}
@Composable
fun StreamingApp(modifier: Modifier = Modifier) {
val viewModel: StreamViewModel = viewModel()
val connectionState by viewModel.connectionState.collectAsState()
val statusMessage by viewModel.statusMessage.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState()
val isCameraRunning by viewModel.isCameraRunning.collectAsState()
var serverHost by remember { mutableStateOf("192.168.1.100") }
var serverPort by remember { mutableStateOf("8000") }
var roomId by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showConnectionForm by remember { mutableStateOf(true) }
val context = LocalContext.current
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Top
) {
// Header
Text(
text = "🎥 CamControl - Video Streaming",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
if (showConnectionForm) {
ConnectionForm(
serverHost = serverHost,
serverPort = serverPort,
roomId = roomId,
password = password,
isConnecting = connectionState is ConnectionState.Connecting,
onServerHostChange = { serverHost = it },
onServerPortChange = { serverPort = it },
onRoomIdChange = { roomId = it },
onPasswordChange = { password = it },
onConnect = {
showConnectionForm = false
viewModel.initializeConnection(
serverHost = serverHost,
serverPort = serverPort.toIntOrNull() ?: 8000,
roomId = roomId,
password = password
)
}
)
} else {
StreamingScreen(
connectionState = connectionState,
statusMessage = statusMessage,
isStreaming = isStreaming,
isCameraRunning = isCameraRunning,
viewModel = viewModel,
onDisconnect = {
showConnectionForm = true
viewModel.disconnect()
}
)
}
}
}
}
@Composable
fun ConnectionForm(
serverHost: String,
serverPort: String,
roomId: String,
password: String,
isConnecting: Boolean,
onServerHostChange: (String) -> Unit,
onServerPortChange: (String) -> Unit,
onRoomIdChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onConnect: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Подключение к серверу",
style = MaterialTheme.typography.titleMedium
)
TextField(
value = serverHost,
onValueChange = onServerHostChange,
label = { Text("IP адрес сервера") },
modifier = Modifier.fillMaxWidth(),
enabled = !isConnecting,
placeholder = { Text("192.168.1.100") }
)
TextField(
value = serverPort,
onValueChange = onServerPortChange,
label = { Text("Порт сервера") },
modifier = Modifier.fillMaxWidth(),
enabled = !isConnecting,
placeholder = { Text("8000") }
)
TextField(
value = roomId,
onValueChange = onRoomIdChange,
label = { Text("ID комнаты") },
modifier = Modifier.fillMaxWidth(),
enabled = !isConnecting,
placeholder = { Text("Введите ID комнаты") }
)
TextField(
value = password,
onValueChange = onPasswordChange,
label = { Text("Пароль комнаты") },
modifier = Modifier.fillMaxWidth(),
enabled = !isConnecting,
visualTransformation = PasswordVisualTransformation(),
placeholder = { Text("Введите пароль") }
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onConnect,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = !isConnecting && serverHost.isNotEmpty() && serverPort.isNotEmpty() && roomId.isNotEmpty() && password.isNotEmpty(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
if (isConnecting) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier
.padding(end = 8.dp)
.height(24.dp),
strokeWidth = 2.dp
)
}
Text("Подключиться")
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Примечание: Убедитесь, что сервер запущен и доступен по указанному адресу",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
@Composable
fun StreamingScreen(
connectionState: ConnectionState,
statusMessage: String,
isStreaming: Boolean,
isCameraRunning: Boolean,
viewModel: StreamViewModel,
onDisconnect: () -> Unit
) {
val context = LocalContext.current
val cameraManager = remember { CameraManager(context) }
LaunchedEffect(isCameraRunning) {
if (isCameraRunning && ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
// Start camera preview when connected
// In a real app, would bind to lifecycle and PreviewView
}
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween
) {
// Camera preview placeholder
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color.Black),
contentAlignment = Alignment.Center
) {
if (isCameraRunning) {
// Camera preview would be rendered here
// Using AndroidView with PreviewView in a real implementation
Text(
text = "🎥 Camera Preview",
color = Color.White,
style = MaterialTheme.typography.headlineSmall
)
} else {
Text(
text = "Camera Inactive",
color = Color.Gray,
style = MaterialTheme.typography.bodyMedium
)
}
}
// Status and controls
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Connection status
Box(
modifier = Modifier
.fillMaxWidth()
.background(
when (connectionState) {
is ConnectionState.Connected -> Color(0xFF10b981).copy(alpha = 0.2f)
is ConnectionState.Error -> Color(0xFFef4444).copy(alpha = 0.2f)
is ConnectionState.Connecting -> Color(0xFFf59e0b).copy(alpha = 0.2f)
else -> Color.Gray.copy(alpha = 0.2f)
}
)
.padding(12.dp)
) {
Column {
Text(
text = "Статус: ${getConnectionStatusText(connectionState)}",
style = MaterialTheme.typography.bodyMedium,
color = when (connectionState) {
is ConnectionState.Connected -> Color(0xFF10b981)
is ConnectionState.Error -> Color(0xFFef4444)
is ConnectionState.Connecting -> Color(0xFFf59e0b)
else -> Color.Gray
}
)
if (statusMessage.isNotEmpty()) {
Text(
text = statusMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
// Video controls
if (isStreaming) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { viewModel.sendCommand(VideoCommands.rotate(90)) },
modifier = Modifier.weight(1f)
) {
Text("Rotate 90°")
}
Button(
onClick = { viewModel.sendCommand(VideoCommands.flip(0)) },
modifier = Modifier.weight(1f)
) {
Text("Flip H")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { viewModel.sendCommand(VideoCommands.grayscale()) },
modifier = Modifier.weight(1f)
) {
Text("Grayscale")
}
Button(
onClick = { viewModel.sendCommand(VideoCommands.reset()) },
modifier = Modifier.weight(1f)
) {
Text("Reset")
}
}
}
// Disconnect button
Button(
onClick = onDisconnect,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
androidx.compose.material.icons.Icon(
imageVector = Icons.Filled.CallEnd,
contentDescription = "Disconnect",
modifier = Modifier.padding(end = 8.dp)
)
Text("Отключиться")
}
}
}
}
private fun getConnectionStatusText(state: ConnectionState): String {
return when (state) {
ConnectionState.Idle -> "Ожидание"
ConnectionState.Connecting -> "Подключение..."
ConnectionState.Connected -> "Подключено ✓"
ConnectionState.Disconnected -> "Отключено"
is ConnectionState.Error -> "Ошибка"
}
}

View File

@@ -0,0 +1,44 @@
package com.example.camcontrol
import com.google.gson.Gson
data class ServerConnectionConfig(
val serverHost: String,
val serverPort: Int,
val roomId: String,
val password: String
) {
fun getWebSocketUrl(): String {
return "ws://$serverHost:$serverPort/ws/client/$roomId/$password"
}
}
data class ConnectionResponse(
val success: Boolean,
val client_id: String? = null,
val room_id: String? = null,
val error: String? = null
)
data class VideoCommand(
val type: String,
val angle: Int? = null,
val direction: Int? = null,
val value: Any? = null,
val quality: Int? = null
) {
fun toJson(): String {
return Gson().toJson(this)
}
}
object VideoCommands {
fun rotate(angle: Int) = VideoCommand(type = "rotate", angle = angle)
fun flip(direction: Int) = VideoCommand(type = "flip", direction = direction)
fun brightness(value: Int) = VideoCommand(type = "brightness", value = value)
fun contrast(value: Double) = VideoCommand(type = "contrast", value = value)
fun grayscale() = VideoCommand(type = "grayscale")
fun adjustQuality(quality: Int) = VideoCommand(type = "adjust_quality", quality = quality)
fun reset() = VideoCommand(type = "reset")
}

View File

@@ -0,0 +1,176 @@
package com.example.camcontrol
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import android.util.Log
class StreamViewModel : ViewModel() {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Idle)
val connectionState: StateFlow<ConnectionState> = _connectionState
private val _statusMessage = MutableStateFlow("")
val statusMessage: StateFlow<String> = _statusMessage
private val _isStreaming = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = _isStreaming
private val _isCameraRunning = MutableStateFlow(false)
val isCameraRunning: StateFlow<Boolean> = _isCameraRunning
private val _fps = MutableStateFlow(0)
val fps: StateFlow<Int> = _fps
private val _bytesTransferred = MutableStateFlow(0L)
val bytesTransferred: StateFlow<Long> = _bytesTransferred
private var wsManager: WebSocketManager? = null
private var config: ServerConnectionConfig? = null
private var frameCount = 0
private var lastFpsTime = System.currentTimeMillis()
private var totalBytesTransferred = 0L
fun initializeConnection(
serverHost: String,
serverPort: Int,
roomId: String,
password: String
) {
config = ServerConnectionConfig(serverHost, serverPort, roomId, password)
wsManager = WebSocketManager(
onConnected = { onConnected() },
onDisconnected = { onDisconnected() },
onError = { error -> onError(error) },
onMessage = { message -> onMessage(message) }
)
connect()
}
private fun connect() {
viewModelScope.launch {
try {
_connectionState.value = ConnectionState.Connecting
updateStatus("Подключение к серверу...")
val config = config ?: return@launch
wsManager?.connect(config.getWebSocketUrl())
} catch (e: Exception) {
Log.e("StreamViewModel", "Connection error: ${e.message}")
_connectionState.value = ConnectionState.Error(e.message ?: "Unknown error")
updateStatus("Ошибка подключения: ${e.message}")
}
}
}
private fun onConnected() {
viewModelScope.launch {
_connectionState.value = ConnectionState.Connected
_isStreaming.value = true
_isCameraRunning.value = true
updateStatus("Подключено к серверу ✓")
Log.d("StreamViewModel", "Connected to server")
}
}
private fun onDisconnected() {
viewModelScope.launch {
_connectionState.value = ConnectionState.Disconnected
_isStreaming.value = false
_isCameraRunning.value = false
updateStatus("Отключено от сервера")
Log.d("StreamViewModel", "Disconnected from server")
}
}
private fun onError(error: String) {
viewModelScope.launch {
_connectionState.value = ConnectionState.Error(error)
updateStatus("Ошибка: $error")
Log.e("StreamViewModel", "Error: $error")
}
}
private fun onMessage(message: String) {
Log.d("StreamViewModel", "Message received: $message")
viewModelScope.launch {
if (!message.contains("ping")) {
updateStatus("Получено: $message")
}
}
}
fun sendVideoFrame(frameData: ByteArray) {
try {
wsManager?.sendBinary(frameData)
// Update statistics
frameCount++
totalBytesTransferred += frameData.size
_bytesTransferred.value = totalBytesTransferred
// Update FPS every second
val currentTime = System.currentTimeMillis()
if (currentTime - lastFpsTime >= 1000) {
_fps.value = frameCount
frameCount = 0
lastFpsTime = currentTime
}
} catch (e: Exception) {
Log.e("StreamViewModel", "Failed to send frame: ${e.message}")
}
}
fun sendCommand(command: VideoCommand) {
try {
wsManager?.sendMessage(command.toJson())
updateStatus("Команда отправлена: ${command.type}")
Log.d("StreamViewModel", "Command sent: ${command.type}")
} catch (e: Exception) {
Log.e("StreamViewModel", "Failed to send command: ${e.message}")
updateStatus("Ошибка отправки команды: ${e.message}")
}
}
fun disconnect() {
viewModelScope.launch {
_isStreaming.value = false
_isCameraRunning.value = false
wsManager?.disconnect()
_connectionState.value = ConnectionState.Idle
updateStatus("Отключение...")
resetStatistics()
}
}
private fun resetStatistics() {
frameCount = 0
totalBytesTransferred = 0L
_fps.value = 0
_bytesTransferred.value = 0L
lastFpsTime = System.currentTimeMillis()
}
private fun updateStatus(message: String) {
_statusMessage.value = message
Log.d("StreamViewModel", "Status: $message")
}
override fun onCleared() {
super.onCleared()
wsManager?.disconnect()
}
}
sealed class ConnectionState {
object Idle : ConnectionState()
object Connecting : ConnectionState()
object Connected : ConnectionState()
object Disconnected : ConnectionState()
data class Error(val message: String) : ConnectionState()
}

View File

@@ -0,0 +1,118 @@
package com.example.camcontrol
import android.content.Context
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.Executors
class VideoStreamingManager(
private val context: Context,
private val onFrameAvailable: (ByteArray) -> Unit,
private val onError: (String) -> Unit
) {
private var cameraProvider: ProcessCameraProvider? = null
private val analysisExecutor = Executors.newSingleThreadExecutor()
private var frameCount = 0
private var lastLogTime = System.currentTimeMillis()
fun startStreaming(
lifecycleOwner: LifecycleOwner,
previewSurfaceProvider: (Preview.SurfaceProvider) -> Unit
) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener(
{
try {
cameraProvider = cameraProviderFuture.get()
// Create preview
val preview = Preview.Builder()
.build()
.apply {
setSurfaceProvider { surfaceProvider ->
previewSurfaceProvider(surfaceProvider)
}
}
// Create image analysis for frame processing
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
.build()
.apply {
setAnalyzer(analysisExecutor) { imageProxy ->
processFrame(imageProxy)
}
}
// Select back camera
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
// Unbind all use cases
cameraProvider?.unbindAll()
// Bind use cases to camera
cameraProvider?.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageAnalysis
)
Log.d("VideoStreamingManager", "Streaming started")
} catch (exc: Exception) {
Log.e("VideoStreamingManager", "Error starting stream", exc)
onError("Failed to start streaming: ${exc.message}")
}
},
ContextCompat.getMainExecutor(context)
)
}
private fun processFrame(imageProxy: ImageProxy) {
try {
frameCount++
val currentTime = System.currentTimeMillis()
// Log every 5 seconds
if (currentTime - lastLogTime > 5000) {
Log.d("VideoStreamingManager", "Processing $frameCount frames/5s")
frameCount = 0
lastLogTime = currentTime
}
// Convert image to byte array
val buffer = imageProxy.planes[0].buffer
buffer.rewind()
val frameData = ByteArray(buffer.remaining())
buffer.get(frameData)
// Send frame to callback
onFrameAvailable(frameData)
imageProxy.close()
} catch (e: Exception) {
Log.e("VideoStreamingManager", "Error processing frame: ${e.message}")
imageProxy.close()
}
}
fun stopStreaming() {
try {
cameraProvider?.unbindAll()
analysisExecutor.shutdown()
Log.d("VideoStreamingManager", "Streaming stopped")
} catch (e: Exception) {
Log.e("VideoStreamingManager", "Error stopping stream", e)
}
}
}

View File

@@ -0,0 +1,95 @@
package com.example.camcontrol
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.Response
import java.util.concurrent.TimeUnit
class WebSocketManager(
private val onConnected: () -> Unit = {},
private val onDisconnected: () -> Unit = {},
private val onError: (String) -> Unit = {},
private val onMessage: (String) -> Unit = {}
) : WebSocketListener() {
private var webSocket: WebSocket? = null
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
fun connect(url: String) {
try {
val request = Request.Builder()
.url(url)
.build()
webSocket = client.newWebSocket(request, this)
Log.d("WebSocket", "Connecting to: $url")
} catch (e: Exception) {
Log.e("WebSocket", "Connection error: ${e.message}")
onError(e.message ?: "Unknown error")
}
}
fun sendMessage(message: String) {
try {
webSocket?.send(message)
Log.d("WebSocket", "Message sent: $message")
} catch (e: Exception) {
Log.e("WebSocket", "Send error: ${e.message}")
onError(e.message ?: "Failed to send message")
}
}
fun sendBinary(data: ByteArray) {
try {
val byteString = okhttp3.ByteString.of(*data)
webSocket?.send(byteString)
Log.d("WebSocket", "Binary data sent: ${data.size} bytes")
} catch (e: Exception) {
Log.e("WebSocket", "Binary send error: ${e.message}")
}
}
fun disconnect() {
try {
webSocket?.close(1000, "Client disconnecting")
webSocket = null
Log.d("WebSocket", "Disconnected")
} catch (e: Exception) {
Log.e("WebSocket", "Disconnect error: ${e.message}")
}
}
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d("WebSocket", "Connected!")
onConnected()
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d("WebSocket", "Message received: $text")
onMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
Log.d("WebSocket", "Closing: $code $reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d("WebSocket", "Closed: $code $reason")
onDisconnected()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e("WebSocket", "Failure: ${t.message}")
onError(t.message ?: "Connection failed")
onDisconnected()
}
}

View File

@@ -0,0 +1,11 @@
package com.example.camcontrol.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,58 @@
package com.example.camcontrol.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun CamControlTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,34 @@
package com.example.camcontrol.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package com.example.camcontrol
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}