feat: initial commit for video recording. (contains test code)

This commit is contained in:
oxy-macmini
2025-03-23 03:12:05 +08:00
parent 1d0db72f4f
commit 9314229573
9 changed files with 178 additions and 29 deletions

View File

@ -3,6 +3,8 @@ package com.m3u.smartphone.ui.business.channel
import android.Manifest
import android.content.Intent
import android.graphics.Rect
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@ -68,6 +70,7 @@ import com.m3u.smartphone.ui.material.components.mask.rememberMaskState
import com.m3u.smartphone.ui.material.components.mask.toggle
import com.m3u.smartphone.ui.material.components.rememberPullPanelLayoutState
import com.m3u.smartphone.ui.material.ktx.checkPermissionOrRationale
import kotlinx.datetime.Clock
@Composable
fun ChannelRoute(
@ -121,6 +124,12 @@ fun ChannelRoute(
val isPanelExpanded = pullPanelLayoutState.value == PullPanelLayoutValue.EXPANDED
val createRecordFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("video/mp4")) { uri ->
uri ?: return@rememberLauncherForActivityResult
viewModel.recordVideo(uri)
}
LifecycleResumeEffect(Unit) {
with(helper) {
isSystemBarUseDarkMode = true
@ -217,6 +226,8 @@ fun ChannelRoute(
ChannelPlayer(
isSeriesPlaylist = isSeriesPlaylist,
openDlnaDevices = {
// viewModel.recordVideo()
createRecordFileLauncher.launch("record_${Clock.System.now().toEpochMilliseconds()}.mp4")
viewModel.openDlnaDevices()
pullPanelLayoutState.collapse()
},

View File

@ -72,7 +72,11 @@ internal fun PlayerMask(
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = spacing.medium)
.padding(
start = spacing.medium,
end = spacing.medium,
bottom = spacing.small
)
) {
Row(
modifier = Modifier.fillMaxWidth(),

View File

@ -8,12 +8,10 @@ import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@ -55,8 +53,6 @@ import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.common.helper.Metadata
import com.m3u.smartphone.ui.common.helper.useRailNav
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeChild
@Composable
@OptIn(InternalComposeApi::class)
@ -133,13 +129,13 @@ internal fun MainContent(
windowInsets = windowInsets,
title = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.defaultMinSize(minHeight = 56.dp)
) {
Column(
modifier = Modifier
.padding(horizontal = spacing.medium)
.padding(start = spacing.medium)
.weight(1f)
) {
Text(
@ -157,22 +153,19 @@ internal fun MainContent(
)
}
}
Row {
actions.forEach { action ->
IconButton(
onClick = action.onClick,
enabled = action.enabled
) {
Icon(
imageVector = action.icon,
contentDescription = action.contentDescription,
)
}
}
}
},
actions = {
actions.forEach { action ->
IconButton(
onClick = action.onClick,
enabled = action.enabled
) {
Icon(
imageVector = action.icon,
contentDescription = action.contentDescription,
)
}
Spacer(modifier = Modifier.width(spacing.medium))
}
},
navigationIcon = {
@ -194,9 +187,7 @@ internal fun MainContent(
}
}
},
modifier = Modifier
.hazeChild(hazeState, style = HazeStyle(blurRadius = 6.dp))
.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
)
},
contentWindowInsets = windowInsets,

View File

@ -1,9 +1,13 @@
package com.m3u.smartphone.ui.material.components
import android.view.Surface
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
@ -17,7 +21,9 @@ data class PlayerState(
val player: Player?,
@ClipMode val clipMode: Int,
val keepScreenOn: Boolean
)
) {
var surface: Surface? by mutableStateOf(null)
}
@Composable
fun rememberPlayerState(
@ -53,6 +59,7 @@ fun Player(
factory = { context ->
PlayerView(context).apply {
useController = false
videoSurfaceView
}
},
update = { view ->

View File

@ -1,7 +1,9 @@
package com.m3u.business.channel
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@ -38,6 +40,7 @@ import com.m3u.data.service.currentTracks
import com.m3u.data.service.tracks
import com.m3u.data.worker.ProgrammeReminder
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@ -70,6 +73,7 @@ class ChannelViewModel @Inject constructor(
private val audioManager: AudioManager,
private val programmeRepository: ProgrammeRepository,
private val workManager: WorkManager,
@ApplicationContext private val context: Context,
delegate: Logger,
) : ViewModel(), ControlPoint.DiscoveryListener {
private val logger = delegate.install(Profiles.VIEWMODEL_CHANNEL)
@ -382,6 +386,12 @@ class ChannelViewModel @Inject constructor(
playerManager.updateSpeed(race)
}
fun recordVideo(uri: Uri) {
viewModelScope.launch {
playerManager.recordVideo(uri)
}
}
companion object {
private const val ACTION_SET_AV_TRANSPORT_URI = "SetAVTransportURI"
private const val ACTION_PLAY = "Play"

View File

@ -70,6 +70,8 @@ dependencies {
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.extractor)
implementation(libs.androidx.media3.common.ktx)
implementation(libs.androidx.media3.transformer)
implementation(libs.androidx.media3.muxer)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)

View File

@ -1,6 +1,8 @@
package com.m3u.data.service
import android.graphics.Rect
import android.net.Uri
import android.view.Surface
import androidx.compose.runtime.Immutable
import androidx.media3.common.C
import androidx.media3.common.Format
@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import java.io.FileDescriptor
interface PlayerManager {
val player: StateFlow<Player?>
@ -42,6 +45,10 @@ interface PlayerManager {
fun clearCache()
fun pauseOrContinue(value: Boolean)
fun updateSpeed(race: Float)
suspend fun recordVideo(
uri: Uri,
)
}
@Immutable

View File

@ -2,7 +2,14 @@ package com.m3u.data.service.internal
import android.content.Context
import android.graphics.Rect
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.net.Uri
import android.view.Surface
import androidx.compose.runtime.snapshotFlow
import androidx.core.net.toUri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
@ -19,6 +26,7 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.datasource.rtmp.RtmpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.MediaExtractorCompat
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
@ -35,7 +43,18 @@ import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory.FLAG_DETECT_ACCESS_UNITS
import androidx.media3.muxer.FragmentedMp4Muxer
import androidx.media3.muxer.Mp4Muxer
import androidx.media3.session.MediaSession
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.InAppFragmentedMp4Muxer
import androidx.media3.transformer.InAppMp4Muxer
import androidx.media3.transformer.TransformationRequest
import androidx.media3.transformer.Transformer
import androidx.media3.transformer.VideoEncoderSettings
import com.m3u.core.architecture.Publisher
import com.m3u.core.architecture.dispatcher.Dispatcher
import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO
@ -57,6 +76,7 @@ import com.m3u.data.repository.channel.ChannelRepository
import com.m3u.data.repository.playlist.PlaylistRepository
import com.m3u.data.service.MediaCommand
import com.m3u.data.service.PlayerManager
import com.m3u.data.service.currentTracks
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.Url
import kotlinx.coroutines.CoroutineDispatcher
@ -80,10 +100,10 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.singleOrNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@ -247,6 +267,7 @@ class PlayerManagerImpl @Inject constructor(
}
}
private var extractor: MediaExtractorCompat? = null
private fun tryPlay(
url: String = channel.value?.url.orEmpty(),
userAgent: String? = getUserAgent(channel.value?.url.orEmpty(), playlist.value),
@ -264,6 +285,7 @@ class PlayerManagerImpl @Inject constructor(
this.chain = chain.next()
return tryPlay(url, userAgent, licenseType, licenseKey, applyContinueWatching)
}
is MimetypeChain.Unsupported -> throw UnsupportedOperationException()
}
@ -282,6 +304,7 @@ class PlayerManagerImpl @Inject constructor(
val extractorsFactory = DefaultExtractorsFactory().setTsExtractorFlags(
FLAG_ALLOW_NON_IDR_KEYFRAMES and FLAG_DETECT_ACCESS_UNITS
)
extractor = MediaExtractorCompat(extractorsFactory, dataSourceFactory)
val mediaSourceFactory = when (mimeType) {
MimeTypes.APPLICATION_M3U8 -> HlsMediaSource.Factory(dataSourceFactory)
.setAllowChunklessPreparation(false)
@ -356,6 +379,7 @@ class PlayerManagerImpl @Inject constructor(
logger.post { "release" }
observePreferencesChangingJob?.cancel()
observePreferencesChangingJob = null
extractor = null
player.update {
it ?: return
it.stop()
@ -520,6 +544,7 @@ class PlayerManagerImpl @Inject constructor(
is MimetypeChain.Unsupported -> {
playbackException.value = exception
}
else -> tryPlay(applyContinueWatching = false)
}
}
@ -563,6 +588,94 @@ class PlayerManagerImpl @Inject constructor(
}
}
override suspend fun recordVideo(uri: Uri) {
withContext(mainDispatcher) {
try {
val currentPlayer = player.value ?: return@withContext
val tracksGroup = currentPlayer.currentTracks.groups.first {
it.type == C.TRACK_TYPE_VIDEO
} ?: return@withContext
val formats = (0 until tracksGroup.length).mapNotNull {
if (!tracksGroup.isTrackSupported(it)) null
else tracksGroup.getTrackFormat(it)
}
.mapNotNull { it.containerMimeType ?: it.sampleMimeType }
val (mimeType, muxerFactory) = when {
formats.any { it in FragmentedMp4Muxer.SUPPORTED_VIDEO_SAMPLE_MIME_TYPES } -> {
val mimeType = formats.first { it in FragmentedMp4Muxer.SUPPORTED_VIDEO_SAMPLE_MIME_TYPES }
val muxerFactory = InAppFragmentedMp4Muxer.Factory()
mimeType to muxerFactory
}
formats.any { it in Mp4Muxer.SUPPORTED_VIDEO_SAMPLE_MIME_TYPES } -> {
val mimeType = formats.first { it in Mp4Muxer.SUPPORTED_VIDEO_SAMPLE_MIME_TYPES }
val muxerFactory = InAppMp4Muxer.Factory()
mimeType to muxerFactory
}
else -> {
logger.post { "recordVideo, unsupported video formats: $formats" }
return@withContext
}
}
val transformer = Transformer.Builder(context)
.setMuxerFactory(muxerFactory)
.setVideoMimeType(mimeType)
.setEncoderFactory(
DefaultEncoderFactory.Builder(context.applicationContext)
.setEnableFallback(true)
// .setRequestedVideoEncoderSettings(
// VideoEncoderSettings.Builder()
// .
// .build()
// )
.build()
)
.addListener(
object : Transformer.Listener {
override fun onCompleted(
composition: Composition,
exportResult: ExportResult
) {
super.onCompleted(composition, exportResult)
logger.post { "transformer, onCompleted" }
}
override fun onError(
composition: Composition,
exportResult: ExportResult,
exportException: ExportException
) {
super.onError(composition, exportResult, exportException)
logger.post { "transformer, onError. message=${exportException.message}, code=[${exportException.errorCode}]${exportException.errorCodeName}" }
}
override fun onFallbackApplied(
composition: Composition,
originalTransformationRequest: TransformationRequest,
fallbackTransformationRequest: TransformationRequest
) {
super.onFallbackApplied(
composition,
originalTransformationRequest,
fallbackTransformationRequest
)
logger.post { "transformer, onFallbackApplied" }
}
}
)
.build()
withContext(mainDispatcher) {
transformer.start(
MediaItem.fromUri(channel.value?.url.orEmpty()),
uri.path.orEmpty()
)
}
} finally {
logger.post { "record video completed" }
}
}
}
private suspend fun onPlaybackIdle() {}
private suspend fun onPlaybackBuffering() {}
@ -572,6 +685,7 @@ class PlayerManagerImpl @Inject constructor(
is MimetypeChain.Remembered -> {
storeContinueWatching(chain.url)
}
is MimetypeChain.Trying -> {
val channelPreference = getChannelPreference(chain.url)
updateChannelPreference(
@ -581,6 +695,7 @@ class PlayerManagerImpl @Inject constructor(
)
storeContinueWatching(chain.url)
}
else -> {}
}
}
@ -634,9 +749,9 @@ class PlayerManagerImpl @Inject constructor(
private suspend fun resetContinueWatching(channelUrl: String) {
logger.post { "resetContinueWatching, channelUrl=$channelUrl" }
val channelPreference = getChannelPreference(channelUrl)
val currentPlayer = player.value
val player = this@PlayerManagerImpl.player.value
withContext(mainDispatcher) {
if (currentPlayer != null && continueWatchingCondition.isResettingSupported(currentPlayer)) {
if (player != null && continueWatchingCondition.isResettingSupported(player)) {
updateChannelPreference(
channelUrl,
channelPreference?.copy(cwPosition = -1L) ?: ChannelPreference(cwPosition = -1L)

View File

@ -110,6 +110,8 @@ androidx-media3-exoplayer-workmanager = { group = "androidx.media3", name = "med
androidx-media3-datasource-rtmp = { group = "androidx.media3", name = "media3-datasource-rtmp", version.ref = "androidx-media3" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "androidx-media3" }
androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "androidx-media3" }
androidx-media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "androidx-media3" }
androidx-media3-muxer = { group = "androidx.media3", name = "media3-muxer", version.ref = "androidx-media3" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidx-work" }