はじめに
ケアプラン作成は、いまだにエクセル使用している施設ケアマネジャーです。
ネットワークカメラや VLCメディアプレーヤ からの映像ストリームを LibVLC で受信し SurfaceView に表示しています。SurfaceView の表示フレームを PixelCopy で定期的にキャプチャして ImageView にビットマップとして表示する機能を実装しました。
動作イメージ
RTSP/RTP の映像を SurfaceView に表示し、PixelCopy で取得したビットマップを下側の ImageView に表示しています。

環境
windows 11 Home
Androidstudio Narwhal 3 Feature Drop | 2025.1.3
Amazon KFONWI (Amazon Fire HD8, Android9, FireOS7)
Sony SO-41B (Xperia, Android13)
ネットワークカメラやVLCメディアプレーヤーからのストリームをRTSPやRTPで受信する
VideoLANのlibVLCライブラリ を利用して RTSP ストリームや RTP ストリームを扱っています。ネットワークカメラがない環境でもテストできるように、RTP でのストリーム受信にも対応しています。
ネットワークカメラは、HeimLink 202A を使用しています。
以下のリンクで、「VLCメディアプレイヤーで再生する」で再生できるネットワークカメラならこのプログラムで受信できると思います。この機種はユーザ名とパスワードは必要ありません。
LibVLCの初期化
LibVLC(context, options) でライブラリを初期化し、options で映像の遅延低減やフレームスキップなどの設定を行っています。
val options = arrayListOf(
"--drop-late-frames",
"--skip-frames",
"--network-caching=1000",
"--live-caching=1000",
"--clock-jitter=500",
"--clock-synchro=0"
)
libVLC = LibVLC(context, options)
LibVLC はリソースを多く消費するため、アプリ全体で一つのインスタンスを再利用することが推奨されています。
option について
ここでは、遅延を多めにとって受信状況が安定する方向に設定しています。
| オプション | 説明 |
|---|---|
| --drop-late-frames | 再生時刻に間に合わなかったフレームを破棄し、古いフレームを表示し続けることを防ぎます。実際のフレームが破棄されるため、瞬間的な画質劣化やコマ落ちが発生します。 |
| --skip-frames | フレームのデコードや描画をスキップ(コマ落ち)することで、再生を継続的に維持します。 |
| --live-caching | ライブストリーム(RTSP/RTPなど)のキャッシュ時間(ミリ秒)。値を小さくすると低遅延化しますが、ネットワークが不安定だと途切れやすくなります。 |
| --network-caching | ネットワークストリームのキャッシュ時間(ミリ秒)。ライブストリームでは--live-cachingが優先されることが多いです |
| --clock-jitter | ジッターに対する許容範囲(補償できる最大のジッター量)を設定します。単位はミリ秒(ms)です。 |
| --clock-synchro | 再生のタイミング(クロック)をどのように同期させるかを制御するための設定。再生を滑らかにするために、内部クロックとメディアの到着タイミングを調整します。 |
ストリームURLの設定
ネットワークカメラ(RTSP)用の URL と VLC (RTP)用の URL を設定します。RTSP / RTP の URL を切り替えることで、同じ再生処理(MediaPlayer + PixelCopy)で異なるストリームに対応できます。
ここで、rtsp://192.168.11.14 は、ネットワークカメラのIPです。 rtp://@:5004 で受信できますが、受信機のIPを指定して受信もできます。
val streamUrl = if (isNetworkCamera) {
"rtsp://192.168.11.14"
} else {
"rtp://@:5004"
//"rtp://192.168.11.6:5004"
}
| 種類 | URL例 | 備考 |
|---|---|---|
| RTSP(ネットワークカメラ) | rtsp://192.168.11.14 | 双方向通信、低遅延 |
| RTP(VLC) | rtp://192.168.11.6:5004 | マルチキャスト/ユニキャスト |
VLC 3.0.20 Vetinari の場合
RTP送信(VLCメディアプレーヤーの送信設定)
1.「メディア」→「ストリーム」をクリック
2.再生したい動画ファイルを追加し、「ストリーム再生」をクリック
3.入力元(ストリーミングするメディアリソースを設定)
「次へ」をクリック
4.新しい出力先から「RTP / MPEG Transport Stream」を選択し、「追加」をクリック
5.出力先の設定(ストリーミングする宛先の選択)
アドレス:受信する機器のIP(例:192.168.11.6)
ベースポート:5004のまま
ストリーム名:指定しなくてもOK
設定したら、「次へ」をクリック
6.トランスコーディングオプション(トランスコーディングオプションの選択)
プロファイル:Video - H.264 + MP3(TS)
を設定し「次へ」をクリック
7.オプション設定(ストリーミングする追加オプションの設定)
「ストリーム」をクリックするとストリーミング配信される
LibVLC を使ってストリームを再生する準備と開始
val media = Media(libVLC, streamUrl.toUri())
media.setHWDecoderEnabled(true, false)
mediaPlayer.media = media
media.release()
mediaPlayer.play()
SurfaceView に表示する
映像のレンダリングには SurfaceView を使用します。
VLCVoutへのSurfaceViewの関連付け
libVLC の mediaPlayer から取得した vlcVout に対してSurfaceView を設定します。これにより、mediaPlayer.play() で開始された映像が surfaceView にレンダリングされます。
val vout = mediaPlayer.vlcVout
vout.setVideoView(surfaceView)
vout.attachViews()
アスペクト比を保ちながら映像を SurfaceView の中央に表示
SurfaceViewの幅と高さを取得し libVLC の映像出力エンジンに通知しています。
val currentWidth = surfaceView.width
val currentHeight = surfaceView.height
if (currentWidth != 0 && currentHeight != 0) {
mediaPlayer.vlcVout.setWindowSize(currentWidth, currentHeight)
}
SurfaceView からビットマップを取得して、ImageView に表示する
PixelCopy を利用して SurfaceViewに表示されているフレームを Bitmap として取得します。
private suspend fun usePixelCopy(videoView: SurfaceView): Bitmap {
val bitmap: Bitmap = Bitmap.createBitmap(videoView.width, videoView.height, Bitmap.Config.ARGB_8888)
return suspendCancellableCoroutine { continuation ->
try {
PixelCopy.request(
videoView,
bitmap,
{ copyResult ->
if (copyResult == PixelCopy.SUCCESS) {
continuation.resume(bitmap) { bitmap.recycle() }
} else {
continuation.resumeWithException(RuntimeException("PixelCopy failed with result: $copyResult"))
}
},
android.os.Handler(pixelCopyHandlerThread.looper)
)
} catch (e: IllegalArgumentException) {
continuation.resumeWithException(e)
}
}
}
PixelCopy
PixelCopy を使用し、SurfaceView の内容を指定の Bitmap にコピーします。PixelCopyは非同期処理で完了通知をコールバックで受け取ります。 spendCancellableCoroutine を利用して、この非同期コールバック処理を Kotlin Coroutine の一時停止・再開(サスペンド・レジューム)に変換しています。PixelCopy が完了するまでコルーチンは一時停止し、完了時に Bitmap を返して再開しています。UI はブロックされず PixelCopy が終わるまで「コルーチンだけ」が止まって待ちます。
非同期処理について
ストリーム受信処理 (receiveNetWorkCameraStreamAsync)
withContext(Dispatchers.IO) を利用し、ネットワーク通信や libVLC の初期化といった 時間のかかる処理 を IOスレッド で実行します。次に UIスレッドに処理を戻し mediaPlayer.play() で再生開始しています。
定期的なキャプチャ処理 (startBitmapCapturing)
lifecycleScope.launch(Dispatchers.IO) で IOスレッド 上で repeatingJob を開始します。while (true) ループと delay(DELAY_MILLIS) で、500ミリ秒 ごとにキャプチャ処理を繰り返します。キャプチャ処理 (usePixelCopy) はIOスレッドで実行されますが、結果を ImageView に表示する部分は runOnUiThread を使用し、UIスレッド に戻しています。
PixelCopy専用のハンドラスレッド
PixelCopy.request の実行には、結果を受け取るための Handler が必要です。 HandlerThread("PixelCopier") を作成し、キャプチャ専用のバックグラウンドスレッドで処理を行っています。
usePixelCopy 関数内で、この専用スレッドが利用されています。
private lateinit var pixelCopyHandlerThread: HandlerThread
// onCreate内で
pixelCopyHandlerThread = HandlerThread("PixelCopier").apply { start() }
private suspend fun usePixelCopy(videoView: SurfaceView): Bitmap {
// ... (中略)
PixelCopy.request(
videoView,
bitmap,
{ copyResult ->
// ... (PixelCopyの完了コールバック処理)
},
android.os.Handler(pixelCopyHandlerThread.looper) // ここで利用(PixelCopyの第4引数)
)
// ... (中略)
}
コード等
build.gradle.kts(.app)
build.gradle.kts(.app) に、追加
dependencies {
implementation ("org.videolan.android:libvlc-all:3.6.5")
// ... (中略)
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<application
// ... (中略)
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="5dp"
tools:context=".MainActivity">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/horizontal_half_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.5" />
<SurfaceView
android:id="@+id/surface_view"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/horizontal_half_guideline" />
<ImageView
android:id="@+id/imageView"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#DDDDDD"
android:contentDescription="No image"
android:scaleType="fitCenter"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/horizontal_half_guideline"
app:layout_constraintBottom_toBottomOf="parent" />
<Button
android:id="@+id/btn_start_capture"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="startCapture"
app:layout_constraintBottom_toTopOf="@+id/txt_capture"
app:layout_constraintStart_toStartOf="@+id/imageView" />
<Button
android:id="@+id/btn_stop_capture"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
android:text="stopCapture"
app:layout_constraintBottom_toTopOf="@+id/txt_capture"
app:layout_constraintEnd_toEndOf="@+id/imageView" />
<Button
android:id="@+id/btn_network_camera"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="ネットワークカメラ (RTSP)"
app:layout_constraintBottom_toTopOf="@+id/horizontal_half_guideline"
app:layout_constraintStart_toStartOf="@+id/imageView" />
<Button
android:id="@+id/btn_vlc_stream"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:text="VLCストリーム (RTP)"
app:layout_constraintBottom_toTopOf="@+id/horizontal_half_guideline"
app:layout_constraintEnd_toEndOf="@+id/imageView" />
<TextView
android:id="@+id/txt_stream"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#80000000"
android:gravity="center"
android:padding="8dp"
android:text="STREAM"
android:textColor="#FFFFFF"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="@+id/imageView"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/surface_view" />
<TextView
android:id="@+id/txt_capture"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#80000000"
android:gravity="center"
android:padding="8dp"
android:text="Screen capture status"
android:textColor="#FFFFFF"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt
package yourpackageName
import android.content.Context
import android.graphics.Bitmap
import android.os.Bundle
import android.os.HandlerThread
import android.util.Log
import android.view.PixelCopy
import android.view.SurfaceView
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.LibVLC
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resumeWithException
import androidx.core.graphics.createBitmap
const val DELAY_MILLIS = 500L
class MainActivity : AppCompatActivity() {
private lateinit var libVLC: LibVLC
private lateinit var mediaPlayer: MediaPlayer
private lateinit var surfaceView: SurfaceView
private var repeatingJob: Job? = null
private lateinit var pixelCopyHandlerThread: HandlerThread
private var streamJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
surfaceView = findViewById<SurfaceView>(R.id.surface_view)
val options = arrayListOf(
"--drop-late-frames",
"--skip-frames",
"--network-caching=1000",
"--live-caching=1000",
"--clock-jitter=500",
"--clock-synchro=0"
)
libVLC = LibVLC(this@MainActivity, options)
pixelCopyHandlerThread = HandlerThread("PixelCopier").apply { start() }
val btnStartCapture = findViewById<Button>(R.id.btn_start_capture)
btnStartCapture.setOnClickListener {
repeatingJob?.cancel()
if (::mediaPlayer.isInitialized && mediaPlayer.isPlaying) {
startBitmapCapturing()
} else {
val txtCapture = findViewById<TextView>(R.id.txt_capture)
txtCapture.setText("Error: Failed to start stream.")
}
}
val btnStopCapture = findViewById<Button>(R.id.btn_stop_capture)
btnStopCapture.setOnClickListener {
repeatingJob?.cancel()
val txtCapture = findViewById<TextView>(R.id.txt_capture)
txtCapture.setText("Screen capture stopped")
}
val btnNetworkCamera = findViewById<Button>(R.id.btn_network_camera)
val btnVlcStream = findViewById<Button>(R.id.btn_vlc_stream)
btnNetworkCamera.setOnClickListener {
repeatingJob?.cancel()
val imageView = findViewById<ImageView>(R.id.imageView)
imageView.setImageBitmap(null)
streamJob?.cancel()
stopCurrentStream()
val txtStream = findViewById<TextView>(R.id.txt_stream)
txtStream.setText("RTSP_STREAM")
val txtCapture = findViewById<TextView>(R.id.txt_capture)
txtCapture.setText("Screen capture stopped")
streamJob = lifecycleScope.launch {
try {
receiveNetWorkCameraStreamAsync(this@MainActivity, isNetworkCamera = true)
} catch (e: Exception) {
Log.e("RTSP_STREAM", "Stream failed: ${e.message}")
}
}
}
btnVlcStream.setOnClickListener {
repeatingJob?.cancel()
val imageView = findViewById<ImageView>(R.id.imageView)
imageView.setImageBitmap(null)
streamJob?.cancel()
stopCurrentStream()
val txtStream = findViewById<TextView>(R.id.txt_stream)
txtStream.setText("VLC_STREAM")
val txtCapture = findViewById<TextView>(R.id.txt_capture)
txtCapture.setText("Screen capture stopped")
streamJob = lifecycleScope.launch {
try {
receiveNetWorkCameraStreamAsync(this@MainActivity, isNetworkCamera = false)
} catch (e: Exception) {
Log.e("VLC_STREAM", "Stream failed: ${e.message}")
}
}
}
}
override fun onDestroy() {
super.onDestroy()
repeatingJob?.cancel()
if (::mediaPlayer.isInitialized) {
mediaPlayer.release()
}
if (::libVLC.isInitialized) {
libVLC.release()
}
pixelCopyHandlerThread.quitSafely()
}
private fun stopCurrentStream() {
if (::mediaPlayer.isInitialized && mediaPlayer.isPlaying) {
mediaPlayer.stop()
mediaPlayer.release()
}
}
private suspend fun receiveNetWorkCameraStreamAsync(context: Context, isNetworkCamera: Boolean) {
withContext(Dispatchers.IO) {
mediaPlayer = MediaPlayer(libVLC)
val surfaceHolder = surfaceView.holder
surfaceHolder.setKeepScreenOn(true)
val vout = mediaPlayer.vlcVout
vout.setVideoView(surfaceView)
vout.attachViews()
val streamUrl: String = if (isNetworkCamera) {
"rtsp://192.168.11.14"
} else {
"rtp://192.168.11.6:5004"
}
val media = Media(libVLC, streamUrl.toUri())
media.setHWDecoderEnabled(true, false)
mediaPlayer.media = media
media.release()
withContext(Dispatchers.Main) {
val currentWidth = surfaceView.width
val currentHeight = surfaceView.height
if (currentWidth != 0 && currentHeight != 0) {
mediaPlayer.vlcVout.setWindowSize(currentWidth, currentHeight)
}
mediaPlayer.play()
}
}
}
private fun startBitmapCapturing() {
repeatingJob = lifecycleScope.launch(Dispatchers.IO) {
while (true) {
try {
val bitmap = usePixelCopy(surfaceView)
runOnUiThread {
val imageView = findViewById<ImageView>(R.id.imageView)
imageView.setImageBitmap(bitmap)
val txtCapture = findViewById<TextView>(R.id.txt_capture)
txtCapture.setText("Capturing the screen")
}
} catch (e: Exception) {
runOnUiThread {
val txtCapture = findViewById<TextView>(R.id.txt_capture)
txtCapture.setText(e.message)
}
}
delay(DELAY_MILLIS)
}
}
}
private suspend fun usePixelCopy(videoView: SurfaceView): Bitmap {
val bitmap: Bitmap = createBitmap(videoView.width, videoView.height)
return suspendCancellableCoroutine { continuation ->
try {
PixelCopy.request(
videoView,
bitmap,
{ copyResult ->
if (copyResult == PixelCopy.SUCCESS) {
continuation.resume(bitmap) { bitmap.recycle() }
} else {
continuation.resumeWithException(RuntimeException("PixelCopy failed with result: $copyResult"))
}
},
android.os.Handler(pixelCopyHandlerThread.looper)
)
} catch (e: IllegalArgumentException) {
continuation.resumeWithException(e)
}
}
}
}
参考リスト
Media options
PixelCopy
RTSPと映像/音声の多重化(RTSP/RTP/MPEG2-TS)