Skip to content

Instantly share code, notes, and snippets.

@flushpot1125
Created May 24, 2025 08:08
Show Gist options
  • Select an option

  • Save flushpot1125/43902ce008f1a0fa17e6191aae2e32c5 to your computer and use it in GitHub Desktop.

Select an option

Save flushpot1125/43902ce008f1a0fa17e6191aae2e32c5 to your computer and use it in GitHub Desktop.
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