mirror of
https://github.com/oxyroid/M3UAndroid.git
synced 2025-05-17 19:35:58 +08:00
feat: initial commit for video recording. (contains test code)
This commit is contained in:
@ -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()
|
||||
},
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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 ->
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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" }
|
||||
|
||||
|
Reference in New Issue
Block a user