Skip to content
This repository has been archived by the owner on Aug 19, 2023. It is now read-only.

Commit

Permalink
Kinda working DVR recording.
Browse files Browse the repository at this point in the history
  • Loading branch information
d4rken committed Aug 26, 2021
1 parent b81945e commit 8f6b131
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 159 deletions.
25 changes: 0 additions & 25 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ class ViewBindingProperty<ComponentT : LifecycleOwner, BindingT : ViewBinding>(
viewBinding = null
}

/**
* When quickly navigating, a fragment may be created that was never visible to the user.
* It's possible that [Fragment.onDestroyView] is called, but [DefaultLifecycleObserver.onDestroy] is not.
* This means the ViewBinding will is not be set to `null` and it still holds the previous layout,
* instead of the new layout that the Fragment inflated when navigating back to it.
*/
(localRef as? Fragment)?.view?.let {
if (it != viewBinding?.root && localRef === thisRef) {
Timber.w("Different view for the same fragment, resetting old viewBinding.")
viewBinding = null
}
}

viewBinding?.let {
// Only accessible from within the same component
require(localRef === thisRef)
Expand Down
48 changes: 41 additions & 7 deletions app/src/main/java/eu/darken/fpv/dvca/dvr/core/DvrController.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package eu.darken.fpv.dvca.dvr.core

import android.content.Context
import androidx.documentfile.provider.DocumentFile
import com.google.android.exoplayer2.util.MimeTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.androidstarter.common.logging.d
import eu.darken.androidstarter.common.logging.i
import eu.darken.androidstarter.common.logging.v
import eu.darken.androidstarter.common.logging.w
import eu.darken.fpv.dvca.App
import eu.darken.fpv.dvca.common.coroutine.AppScope
import eu.darken.fpv.dvca.common.flow.HotDataFlow
import eu.darken.fpv.dvca.dvr.GeneralDvrSettings
import eu.darken.fpv.dvca.dvr.core.ffmpeg.FFmpegDvrRecorder
import eu.darken.fpv.dvca.dvr.core.service.DvrServiceController
import eu.darken.fpv.dvca.gear.goggles.Goggles
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import okio.buffer
import java.time.Instant
import java.util.*
import javax.inject.Inject
Expand All @@ -21,8 +27,10 @@ import javax.inject.Singleton
@Singleton
class DvrController @Inject constructor(
@AppScope private val appScope: CoroutineScope,
@ApplicationContext private val context: Context,
private val serviceController: DvrServiceController,
private val fFmpegDvrRecorder: FFmpegDvrRecorder
private val fFmpegDvrRecorder: FFmpegDvrRecorder,
private val dvrSettings: GeneralDvrSettings,
) {

private val internalData: HotDataFlow<Set<DvrRecording>> = HotDataFlow(
Expand Down Expand Up @@ -50,13 +58,38 @@ class DvrController @Inject constructor(
val existing = singleOrNull { it.goggle == goggle }
if (existing != null) {
i(TAG) { "Stopping DvrRecording: $existing" }
existing.feedJob.cancel()

// TODO send notification for result

affected = existing
this.minus(existing)
} else {
val target = dvrSettings.dvrStoragePath.value!!
val videoFile = DocumentFile.fromTreeUri(context, target)!!
.createFile(MimeTypes.VIDEO_H264, "DVCA-${System.currentTimeMillis()}.mp4")!!

var currentDvrSession: DvrRecorder.Session? = null

// While the recording is running, we keep collecting, otherwise the feed is stopped.
val feedJob = goggle.videoFeed
.onEach { feed ->
v(TAG) { "Received DVR feed for $goggle: $feed" }
currentDvrSession?.cancel()

val dvrSession = fFmpegDvrRecorder.record(videoFile.uri)
feed.source.addSideSink(dvrSession.sink.buffer())
currentDvrSession = dvrSession
}
.onCompletion { d(TAG) { "Feed job was cancelled: $it" } }
.catch { w(TAG, it) { "DVR feed failed for $goggle" } }
.launchIn(goggle.gearScope)

val newRecording = DvrRecording(
goggle = goggle,
feedJob = feedJob,
)

i(TAG) { "Started DvrRecording: $newRecording" }
affected = newRecording
this.plus(newRecording)
Expand All @@ -67,13 +100,14 @@ class DvrController @Inject constructor(
return affected!!
}


data class DvrRecording(
val goggle: Goggles,
val feedJob: Job,
val recordingId: String = UUID.randomUUID().toString(),
val startedAt: Instant = Instant.now(),
val isFinished: Boolean = false,
)
) {
val isFinished = feedJob.isCompleted
}


companion object {
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/java/eu/darken/fpv/dvca/dvr/core/DvrRecorder.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package eu.darken.fpv.dvca.dvr.core

import android.net.Uri
import okio.Source
import okio.Sink

interface DvrRecorder {

fun record(source: Source, target: Uri): Session
fun record(storagePath: Uri): Session

interface Session {
val sink: Sink

fun stop()

fun cancel()
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,41 @@ import android.net.Uri
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.androidstarter.common.logging.i
import eu.darken.androidstarter.common.logging.v
import eu.darken.fpv.dvca.App
import eu.darken.fpv.dvca.dvr.core.DvrRecorder
import okio.Source
import okio.buffer
import okio.sink
import java.io.FileOutputStream
import okio.Sink
import okio.appendingSink
import java.io.File
import javax.inject.Inject
import kotlin.concurrent.thread

class FFmpegDvrRecorder @Inject constructor(
@ApplicationContext private val context: Context
) : DvrRecorder {

// TODO
override fun record(source: Source, safUri: Uri): DvrRecorder.Session {
override fun record(safUri: Uri): DvrRecorder.Session {
val inPipe = FFmpegKitConfig.registerNewFFmpegPipe(context)
val outFile = FFmpegKitConfig.getSafParameterForWrite(context, safUri)
i(TAG) { "Starting FFmpeg: IN=$inPipe OUT=$outFile" }

val ffmpegTarget = FFmpegKitConfig.getSafParameterForWrite(context, safUri)

var recording = true
thread {
FFmpegKit.execute("-fflags nobuffer -f:v h264 -probesize 8192 -i $inPipe -f mpegts -vcodec copy -preset ultrafast $ffmpegTarget")
}
thread {
val ffmpegSink = FileOutputStream(inPipe).sink().buffer()
source.use {
val sour = it.buffer()
while (recording) {
ffmpegSink.write(sour, 8192)
}
}
val ffmpegSession = FFmpegKit.executeAsync(
"-fflags nobuffer -f:v h264 -probesize 8192 -i $inPipe -f mpegts -vcodec copy -preset ultrafast $outFile"
) {
v(TAG) { "Session completed:\n$it " }
}

return object : DvrRecorder.Session {
override fun stop() {
recording = false
override val sink: Sink = File(inPipe).appendingSink()
override fun cancel() {
i(TAG) { "Canceling session" }
v(TAG) { "Cancelled sesion:\n$ffmpegSession" }
ffmpegSession.cancel()
}

}
}

companion object {
private val TAG = App.logTag("DVR", "Recorder", "FFmpeg")
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/eu/darken/fpv/dvca/gear/Gear.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eu.darken.fpv.dvca.gear

import eu.darken.fpv.dvca.usb.HWDevice
import kotlinx.coroutines.CoroutineScope
import java.time.Instant

interface Gear {
Expand All @@ -23,6 +24,8 @@ interface Gear {
val logId: String
get() = "$identifier $gearName"

val gearScope: CoroutineScope

suspend fun release()

interface Factory {
Expand Down
7 changes: 3 additions & 4 deletions app/src/main/java/eu/darken/fpv/dvca/gear/goggles/Goggles.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
package eu.darken.fpv.dvca.gear.goggles

import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.upstream.DataSource
import eu.darken.fpv.dvca.gear.Gear
import eu.darken.fpv.dvca.gear.goggles.common.TeeSource
import eu.darken.fpv.dvca.usb.connection.HWEndpoint
import kotlinx.coroutines.flow.Flow
import okio.Source

interface Goggles : Gear {

val videoFeed: Flow<VideoFeed>

interface VideoFeed {
val source: Source
val exoDataSource: DataSource
val source: TeeSource

val exoMediaSource: MediaSource

val usbReadMode: HWEndpoint.ReadMode
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package eu.darken.fpv.dvca.gear.goggles.common

import eu.darken.androidstarter.common.logging.v
import eu.darken.androidstarter.common.logging.w
import okio.*
import java.io.IOException

class TeeSource(private val upstream: Source) : Source {

private val sideStreams = mutableListOf<BufferedSink>()

fun addSideSink(sink: BufferedSink) {
v { "addSideSink(sink=$sink)" }
sideStreams.add(sink)
}

override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = try {
upstream.read(sink, byteCount)
} catch (e: IOException) {
sideStreams.iterator().forEach { it.tryClose() }
throw e
}

if (bytesRead == -1L) {
sideStreams.iterator().forEach { it.tryClose() }
return -1L
}

val offset = sink.size - bytesRead

with(sideStreams.listIterator()) {
forEach {
if (!it.isOpen) {
remove()
return@forEach
}

sink.copyTo(it.buffer, offset, bytesRead)
it.emitCompleteSegments()
}

return bytesRead
}
}

override fun close() {
sideStreams.iterator().forEach { it.tryClose() }
upstream.close()
}

override fun timeout(): Timeout = upstream.timeout()

private fun Sink.tryClose() = try {
close()
} catch (e: Exception) {
w { "Sink failed to close: already closed: $e" }
}
}

fun Source.tee(): TeeSource = TeeSource(this)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package eu.darken.fpv.dvca.gear.goggles.djifpv

import android.net.Uri
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.TransferListener
import eu.darken.fpv.dvca.gear.goggles.Goggles
import okio.BufferedSource
import okio.buffer
import timber.log.Timber

class ExoDataSource(
private val videoFeed: Goggles.VideoFeed,
private val tag: String,
) : DataSource {
private var exoBuffer: BufferedSource? = null

override fun getUri(): Uri? = Uri.EMPTY

override fun addTransferListener(transferListener: TransferListener) {}

override fun open(dataSpec: DataSpec): Long {
Timber.tag(tag).v("open(dataSpec=%s) this=%s", dataSpec, this)
videoFeed.open()
exoBuffer = videoFeed.source.buffer()

return if (dataSpec.length != C.LENGTH_UNSET.toLong()) {
dataSpec.length
} else {
C.LENGTH_UNSET.toLong()
}
}

override fun read(target: ByteArray, offset: Int, length: Int): Int {
return exoBuffer?.read(target, offset, length) ?: -1
}

override fun close() {
Timber.tag(tag).d("close(), source, this=%s", this)
videoFeed.close()
exoBuffer?.close()
exoBuffer = null
}
}
Loading

0 comments on commit 8f6b131

Please sign in to comment.