Created
May 24, 2025 08:08
-
-
Save flushpot1125/43902ce008f1a0fa17e6191aae2e32c5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.example.videostream | |
| import android.Manifest | |
| import android.content.pm.PackageManager | |
| import android.os.Bundle | |
| import android.util.Log | |
| import androidx.activity.ComponentActivity | |
| import androidx.activity.compose.setContent | |
| import androidx.activity.enableEdgeToEdge | |
| import androidx.activity.result.contract.ActivityResultContracts | |
| import androidx.compose.foundation.layout.* | |
| import androidx.compose.material3.* | |
| import androidx.compose.runtime.* | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.platform.LocalConfiguration | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.text.style.TextAlign | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import androidx.core.content.ContextCompat | |
| import androidx.lifecycle.lifecycleScope | |
| import io.ktor.http.ContentType | |
| import io.ktor.http.HttpStatusCode | |
| import io.ktor.server.application.* | |
| import io.ktor.server.engine.* | |
| import io.ktor.server.routing.* | |
| import io.ktor.server.websocket.* | |
| import io.ktor.server.response.respond | |
| import io.ktor.server.response.respondText | |
| import io.ktor.websocket.WebSocketSession | |
| import kotlinx.coroutines.Dispatchers | |
| import kotlinx.coroutines.launch | |
| import java.net.Inet4Address | |
| import java.net.NetworkInterface | |
| import io.ktor.server.cio.* | |
| import java.nio.ByteBuffer | |
| import java.util.Collections | |
| import kotlin.collections.LinkedHashSet | |
| import io.ktor.websocket.Frame | |
| import androidx.compose.ui.tooling.preview.Preview | |
| class MainActivity : ComponentActivity() { | |
| private var server: ApplicationEngine? = null | |
| private var isServerRunning by mutableStateOf(false) | |
| private var isStreamingVideo by mutableStateOf(false) | |
| private var connectedClients by mutableStateOf(0) | |
| private var serverUrl by mutableStateOf("http://...") | |
| // 接続中のWebSocketセッションを保持するセット | |
| private val activeConnections = Collections.synchronizedSet<WebSocketSession>(LinkedHashSet()) | |
| // カメラキャプチャのインスタンス(1つだけ作成) | |
| private var cameraCapture: CameraCapture? = null | |
| // カメラ権限のリクエスト | |
| private val requestPermissionLauncher = registerForActivityResult( | |
| ActivityResultContracts.RequestPermission() | |
| ) { isGranted -> | |
| if (isGranted) { | |
| Log.d("Camera", "Permission granted") | |
| } else { | |
| Log.d("Camera", "Permission denied") | |
| } | |
| } | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| // カメラ権限を確認 | |
| if (ContextCompat.checkSelfPermission( | |
| this, | |
| Manifest.permission.CAMERA | |
| ) != PackageManager.PERMISSION_GRANTED) { | |
| requestPermissionLauncher.launch(Manifest.permission.CAMERA) | |
| } | |
| // IPアドレスを初期化 | |
| updateServerUrl() | |
| enableEdgeToEdge() | |
| setContent { | |
| MimamoriCameraTheme { | |
| MimamoriCameraScreen( | |
| isStreaming = isStreamingVideo, | |
| connectedClients = connectedClients, | |
| serverUrl = serverUrl, | |
| onStreamingChanged = { isEnabled -> | |
| if (isEnabled) { | |
| startServer() | |
| } else { | |
| stopServer() | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| fun MimamoriCameraTheme(content: @Composable () -> Unit) { | |
| MaterialTheme( | |
| colorScheme = lightColorScheme(), | |
| content = content | |
| ) | |
| } | |
| @Composable | |
| fun MimamoriCameraScreen( | |
| isStreaming: Boolean, | |
| connectedClients: Int, | |
| serverUrl: String, | |
| onStreamingChanged: (Boolean) -> Unit | |
| ) { | |
| // 画面の向きを取得 | |
| val configuration = LocalConfiguration.current | |
| val screenWidth = configuration.screenWidthDp.dp | |
| val screenHeight = configuration.screenHeightDp.dp | |
| val isLandscape = screenWidth > screenHeight | |
| Surface( | |
| modifier = Modifier.fillMaxSize(), | |
| color = MaterialTheme.colorScheme.background | |
| ) { | |
| if (isLandscape) { | |
| // 横向きレイアウト | |
| Row( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(24.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| // 左側にタイトル | |
| Column( | |
| modifier = Modifier | |
| .weight(1f) | |
| .fillMaxHeight(), | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.Center | |
| ) { | |
| Text( | |
| text = "みまもりカメラ", | |
| fontSize = 28.sp, | |
| fontWeight = FontWeight.Bold, | |
| textAlign = TextAlign.Center | |
| ) | |
| } | |
| // 右側に操作UI | |
| Column( | |
| modifier = Modifier | |
| .weight(1.2f) | |
| .fillMaxHeight(), | |
| horizontalAlignment = Alignment.Start, | |
| verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically) | |
| ) { | |
| // 映像送信スイッチ | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth(), | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.SpaceBetween | |
| ) { | |
| Text( | |
| text = "映像を送る", | |
| fontSize = 22.sp | |
| ) | |
| Switch( | |
| checked = isStreaming, | |
| onCheckedChange = onStreamingChanged, | |
| modifier = Modifier.size(width = 60.dp, height = 40.dp), | |
| colors = SwitchDefaults.colors( | |
| checkedThumbColor = MaterialTheme.colorScheme.primary, | |
| checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, | |
| ) | |
| ) | |
| } | |
| // カメラのIPアドレス | |
| Text( | |
| text = "カメラのIPアドレス: $serverUrl", | |
| fontSize = 18.sp | |
| ) | |
| // 接続デバイス数 | |
| Text( | |
| text = "接続しているデバイスの数: $connectedClients", | |
| fontSize = 18.sp | |
| ) | |
| } | |
| } | |
| } else { | |
| // 縦向きレイアウト - 添付画像のレイアウト | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(24.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.spacedBy(40.dp) | |
| ) { | |
| // タイトル | |
| Text( | |
| text = "みまもりカメラ", | |
| fontSize = 32.sp, | |
| fontWeight = FontWeight.Bold, | |
| textAlign = TextAlign.Center, | |
| modifier = Modifier.padding(top = 40.dp) | |
| ) | |
| Spacer(modifier = Modifier.height(20.dp)) | |
| // 映像送信スイッチ | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 16.dp), | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.SpaceBetween | |
| ) { | |
| Text( | |
| text = "映像を送る", | |
| fontSize = 24.sp | |
| ) | |
| Switch( | |
| checked = isStreaming, | |
| onCheckedChange = onStreamingChanged, | |
| modifier = Modifier.size(width = 60.dp, height = 40.dp), | |
| colors = SwitchDefaults.colors( | |
| checkedThumbColor = MaterialTheme.colorScheme.primary, | |
| checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, | |
| ) | |
| ) | |
| } | |
| // カメラのIPアドレス - 添付画像に合わせて追加 | |
| Text( | |
| text = "カメラのIPアドレス: $serverUrl", | |
| fontSize = 20.sp | |
| ) | |
| // 接続デバイス数 | |
| Text( | |
| text = "接続しているデバイスの数: $connectedClients", | |
| fontSize = 20.sp | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| // IPアドレスを更新するメソッド | |
| private fun updateServerUrl() { | |
| lifecycleScope.launch(Dispatchers.IO) { | |
| val ipAddress = getLocalIpAddress() | |
| lifecycleScope.launch(Dispatchers.Main) { | |
| serverUrl = "http://$ipAddress:8080" | |
| } | |
| } | |
| } | |
| // プレビュー関数(縦向き) | |
| @Preview(showBackground = true, widthDp = 360, heightDp = 640) | |
| @Composable | |
| fun PortraitPreview() { | |
| MimamoriCameraTheme { | |
| MimamoriCameraScreen( | |
| isStreaming = true, | |
| connectedClients = 2, | |
| serverUrl = "http://192.168.1.100:8080", | |
| onStreamingChanged = {} | |
| ) | |
| } | |
| } | |
| // プレビュー関数(横向き) | |
| @Preview(showBackground = true, widthDp = 640, heightDp = 360) | |
| @Composable | |
| fun LandscapePreview() { | |
| MimamoriCameraTheme { | |
| MimamoriCameraScreen( | |
| isStreaming = true, | |
| connectedClients = 2, | |
| serverUrl = "http://192.168.1.100:8080", | |
| onStreamingChanged = {} | |
| ) | |
| } | |
| } | |
| // サーバー関連のコード | |
| private fun startServer() { | |
| lifecycleScope.launch(Dispatchers.IO) { | |
| try { | |
| // IPアドレスを更新 | |
| updateServerUrl() | |
| // カメラキャプチャのインスタンスを作成 | |
| cameraCapture = CameraCapture(this@MainActivity) | |
| // Androidで動作するように設定を調整 | |
| val environment = applicationEngineEnvironment { | |
| module { | |
| install(WebSockets) | |
| routing { | |
| // index.htmlのルーティング | |
| get("/") { | |
| val indexHtml = try { | |
| assets.open("static/index.html").bufferedReader().use { it.readText() } | |
| } catch (e: Exception) { | |
| Log.e("KtorServer", "Error loading index.html", e) | |
| "HTML file not found" | |
| } | |
| call.respondText(indexHtml, ContentType.Text.Html) | |
| } | |
| // client.jsのルーティング | |
| get("/client.js") { | |
| try { | |
| val jsContent = assets.open("static/client.js").bufferedReader().use { it.readText() } | |
| call.respondText(jsContent, ContentType.Text.JavaScript) | |
| } catch (e: Exception) { | |
| Log.e("KtorServer", "Error loading client.js", e) | |
| call.respond(HttpStatusCode.NotFound) | |
| } | |
| } | |
| // 複数クライアント対応のWebSocketエンドポイント | |
| webSocket("/video") { | |
| try { | |
| // 接続を追加 | |
| activeConnections.add(this) | |
| updateConnectedClientsCount() | |
| Log.d("WebSocket", "クライアント接続: 現在 ${activeConnections.size} 台") | |
| // セッションが閉じるまで待機 | |
| for (frame in incoming) { | |
| // 必要に応じてクライアントからのメッセージを処理 | |
| } | |
| } catch (e: Exception) { | |
| Log.e("WebSocket", "エラーが発生しました", e) | |
| } finally { | |
| // 接続が閉じられたら削除 | |
| activeConnections.remove(this) | |
| updateConnectedClientsCount() | |
| Log.d("WebSocket", "クライアント切断: 残り ${activeConnections.size} 台") | |
| } | |
| } | |
| } | |
| } | |
| // サーバーの設定 | |
| connector { | |
| host = "0.0.0.0" | |
| port = 8080 | |
| } | |
| } | |
| server = embeddedServer(CIO, environment).start(wait = false) | |
| isServerRunning = true | |
| isStreamingVideo = true | |
| // カメラをスタート(単一インスタンス) | |
| startCameraCapture() | |
| Log.d("KtorServer", "Server started on port 8080") | |
| } catch (e: Exception) { | |
| Log.e("KtorServer", "Error starting server", e) | |
| e.printStackTrace() | |
| } | |
| } | |
| } | |
| private fun updateConnectedClientsCount() { | |
| lifecycleScope.launch(Dispatchers.Main) { | |
| connectedClients = activeConnections.size | |
| } | |
| } | |
| private fun startCameraCapture() { | |
| lifecycleScope.launch(Dispatchers.IO) { | |
| try { | |
| cameraCapture?.startCamera { frameData -> | |
| // すべてのクライアントにフレームを送信 | |
| lifecycleScope.launch { | |
| sendFrameToAllClients(frameData) | |
| } | |
| } | |
| } catch (e: Exception) { | |
| Log.e("Camera", "カメラ起動エラー", e) | |
| } | |
| } | |
| } | |
| // すべてのクライアントにフレームを送信 | |
| private suspend fun sendFrameToAllClients(frameData: ByteArray) { | |
| val deadSessions = mutableListOf<WebSocketSession>() | |
| activeConnections.forEach { session -> | |
| try { | |
| // 送信を試み、例外が発生したらセッションを削除リストに追加 | |
| session.send(Frame.Binary(true, ByteBuffer.wrap(frameData))) | |
| } catch (e: Exception) { | |
| Log.e("WebSocket", "フレーム送信エラー: ${e.message}", e) | |
| deadSessions.add(session) | |
| } | |
| } | |
| // 切断されたセッションを削除 | |
| if (deadSessions.isNotEmpty()) { | |
| activeConnections.removeAll(deadSessions) | |
| updateConnectedClientsCount() | |
| } | |
| } | |
| private fun stopServer() { | |
| lifecycleScope.launch(Dispatchers.IO) { | |
| isStreamingVideo = false | |
| // カメラを停止 | |
| cameraCapture?.stopCamera() | |
| cameraCapture = null | |
| // すべてのWebSocket接続をクローズ | |
| activeConnections.clear() | |
| updateConnectedClientsCount() | |
| // サーバーを停止 | |
| server?.stop(1000, 2000) | |
| server = null | |
| isServerRunning = false | |
| Log.d("KtorServer", "Server stopped") | |
| } | |
| } | |
| private fun getLocalIpAddress(): String { | |
| try { | |
| val en = NetworkInterface.getNetworkInterfaces() | |
| while (en.hasMoreElements()) { | |
| val intf = en.nextElement() | |
| val enumIpAddr = intf.inetAddresses | |
| while (enumIpAddr.hasMoreElements()) { | |
| val inetAddress = enumIpAddr.nextElement() | |
| if (!inetAddress.isLoopbackAddress && inetAddress is Inet4Address) { | |
| return inetAddress.hostAddress.toString() | |
| } | |
| } | |
| } | |
| } catch (e: Exception) { | |
| Log.e("KtorServer", "Error getting IP address", e) | |
| } | |
| return "Unknown IP" | |
| } | |
| override fun onDestroy() { | |
| stopServer() | |
| super.onDestroy() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment