[feat] Video system optimization2

This commit is contained in:
acite
2025-09-20 03:18:25 +08:00
parent 947ffc4599
commit 55ea2e1ae3
4 changed files with 245 additions and 39 deletions

View File

@@ -28,6 +28,13 @@ class Video(
"${ApiClient.getBase()}api/video/$klass/$id/av?token=$token" "${ApiClient.getBase()}api/video/$klass/$id/av?token=$token"
} }
fun getSubtitle(): String {
return if (isLocal)
"$localBase/videos/$klass/$id/subtitle.ass"
else
"${ApiClient.getBase()}api/video/$klass/$id/subtitle?token=$token"
}
fun getGallery(): List<KeyImage> { fun getGallery(): List<KeyImage> {
return if (isLocal) return if (isLocal)
video.gallery.map { video.gallery.map {

View File

@@ -157,6 +157,11 @@ class FetchManager @Inject constructor(
video.getCover(), video.getCover(),
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg")) File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg"))
downloadFile(
client!!,
video.getSubtitle(),
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/subtitle.ass"))
enqueue(request) enqueue(request)
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/summary.json").writeText(Json.encodeToString(video)) File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/summary.json").writeText(Json.encodeToString(video))

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.media.AudioManager import android.media.AudioManager
import android.view.View
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -46,6 +47,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -85,6 +87,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -93,9 +96,16 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.text.Cue
import androidx.media3.common.util.UnstableApi
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
@@ -207,6 +217,64 @@ fun BiliMiniSlider(
) )
} }
@Composable
fun SubtitleOverlay(
cues: List<Cue>,
modifier: Modifier = Modifier,
maxLines: Int = 2,
textSize: TextUnit = 14.sp,
backgroundAlpha: Float = 0.6f,
horizontalMargin: Dp = 16.dp,
bottomMargin: Dp = 14.dp,
contentPadding: Dp = 6.dp,
cornerRadius: Dp = 6.dp,
textColor: Color = Color.White
) {
val raw = if (cues.isEmpty()) "" else cues.joinToString(separator = "\n") { it.text?.toString() ?: "" }.trim()
if (raw.isEmpty()) return
val textAlign = when (cues.firstOrNull()?.textAlignment) {
android.text.Layout.Alignment.ALIGN_CENTER -> TextAlign.Center
android.text.Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End
android.text.Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start
else -> TextAlign.Center
}
val blurPx = with(LocalDensity.current) { (2.dp).toPx() }
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.padding(start = horizontalMargin, end = horizontalMargin, bottom = bottomMargin)
.wrapContentWidth(Alignment.CenterHorizontally)
.clip(RoundedCornerShape(cornerRadius))
.background(Color.Black.copy(alpha = backgroundAlpha))
.padding(horizontal = 12.dp, vertical = contentPadding)
) {
Text(
text = raw,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
color = textColor,
fontSize = textSize,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.85f),
offset = Offset(0f, 0f),
blurRadius = blurPx
)
),
textAlign = textAlign,
modifier = Modifier
)
}
}
}
@Composable @Composable
fun VideoPlayer( fun VideoPlayer(
videoPlayerViewModel: VideoPlayerViewModel = hiltViewModel<VideoPlayerViewModel>(), videoPlayerViewModel: VideoPlayerViewModel = hiltViewModel<VideoPlayerViewModel>(),
@@ -227,6 +295,7 @@ fun VideoPlayer(
} }
} }
@androidx.annotation.OptIn(UnstableApi::class)
@Composable @Composable
fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float) fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float)
{ {
@@ -255,6 +324,9 @@ fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewMo
).apply { ).apply {
player = exoPlayer player = exoPlayer
useController = false useController = false
subtitleView?.let { sv ->
sv.visibility = View.GONE
}
} }
}, },
modifier = Modifier modifier = Modifier
@@ -555,6 +627,11 @@ fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewMo
} }
} }
} }
SubtitleOverlay(
cues = videoPlayerViewModel.cues,
modifier = Modifier.matchParentSize()
)
} }
} }
@@ -829,6 +906,7 @@ fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) {
) )
} }
@androidx.annotation.OptIn(UnstableApi::class)
@Composable @Composable
fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
{ {
@@ -867,6 +945,9 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
).apply { ).apply {
player = exoPlayer player = exoPlayer
useController = false useController = false
subtitleView?.let { sv ->
sv.visibility = View.GONE
}
} }
}, },
modifier = Modifier modifier = Modifier
@@ -1106,8 +1187,6 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility, visible = videoPlayerViewModel.planeVisibility,
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }),
@@ -1170,6 +1249,11 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
} }
} }
} }
SubtitleOverlay(
cues = videoPlayerViewModel.cues,
modifier = Modifier.matchParentSize()
)
} }
} }
} }

View File

@@ -18,6 +18,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.text.Cue
import androidx.media3.common.util.Log import androidx.media3.common.util.Log
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -40,8 +41,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
@HiltViewModel @HiltViewModel
class VideoPlayerViewModel @Inject constructor( class VideoPlayerViewModel @Inject constructor(
@@ -74,6 +81,7 @@ class VideoPlayerViewModel @Inject constructor(
var imageLoader: ImageLoader? = null; var imageLoader: ImageLoader? = null;
var brit by mutableFloatStateOf(0.5f) var brit by mutableFloatStateOf(0.5f)
val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context) val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context)
var cues by mutableStateOf(listOf<Cue>())
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun init(videoId: String) { fun init(videoId: String) {
@@ -88,53 +96,155 @@ class VideoPlayerViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
video = mediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! video = mediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!!
recentManager.pushVideo(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) recentManager.pushVideo(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1]))
_player =
(if (video!!.isLocal) ExoPlayer.Builder(context) else ExoPlayer.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)))
.build().apply {
val url = video?.getVideo() ?: ""
val mediaItem = if (video!!.isLocal)
MediaItem.fromUri(Uri.fromFile(File(url)))
else
MediaItem.fromUri(url)
setMediaItem(mediaItem) val subtitleCandidate = video?.getSubtitle()?.trim()
prepare() val subtitleUri = tryResolveSubtitleUri(subtitleCandidate)
playWhenReady = true
addListener(object : Player.Listener { // decide whether we need network-capable media source factory:
override fun onPlaybackStateChanged(playbackState: Int) { val subtitleIsRemote = subtitleUri?.scheme?.startsWith("http", ignoreCase = true) == true
if (playbackState == STATE_READY) { val videoIsRemote = !video!!.isLocal
startPlaying = true val needNetworkFactory = videoIsRemote || subtitleIsRemote
} val trackSelector = DefaultTrackSelector(context)
}
override fun onRenderedFirstFrame() { // build ExoPlayer with or without custom DefaultMediaSourceFactory
super.onRenderedFirstFrame() val builder = if (needNetworkFactory)
if(!renderedFirst) ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
{ else
viewModelScope.launch { ExoPlayer.Builder(context)
val ii = database.userDao().getById(video!!.id)
if(ii != null)
{
_player!!.seekTo(ii.position)
Toast.makeText(context, "Recover from ${formatTime(ii.position)} ", Toast.LENGTH_SHORT).show()
}
}
}
renderedFirst = true
}
override fun onPlayerError(error: PlaybackException) { _player = builder.setTrackSelector(trackSelector).build().apply {
val url = video?.getVideo() ?: ""
val videoUri = if (video!!.isLocal) Uri.fromFile(File(url)) else url.toUri()
} val mediaItem: MediaItem = if (subtitleUri != null) {
}) // prepare subtitle configuration with guessed mime type
val mime = "text/vtt"
val subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri)
.setMimeType(mime)
.build()
MediaItem.Builder()
.setUri(videoUri)
.setSubtitleConfigurations(listOf(subConfig))
.build()
} else {
MediaItem.fromUri(videoUri)
}
setMediaItem(mediaItem)
prepare()
playWhenReady = true
addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
val trackSelector = _player?.trackSelector
if (trackSelector is DefaultTrackSelector) {
val parameters = trackSelector.buildUponParameters()
.setSelectUndeterminedTextLanguage(true)
.build()
trackSelector.parameters = parameters
}
} }
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == STATE_READY) {
startPlaying = true
}
}
override fun onRenderedFirstFrame() {
super.onRenderedFirstFrame()
if(!renderedFirst)
{
viewModelScope.launch {
val ii = database.userDao().getById(video!!.id)
if(ii != null)
{
_player!!.seekTo(ii.position)
Toast.makeText(context, "Recover from ${formatTime(ii.position)} ", Toast.LENGTH_SHORT).show()
}
}
}
renderedFirst = true
}
override fun onPlayerError(error: PlaybackException)
{
print(error.message)
}
override fun onCues(lcues: MutableList<Cue>) {
cues = lcues
}
})
}
startListen() startListen()
} }
_init = true; _init = true;
} }
/**
* Try to resolve the given subtitle pathOrUrl to a Uri.
* - If it's a local path and file exists -> Uri.fromFile
* - If it's a http(s) URL -> try HEAD; if HEAD unsupported, try GET with Range: bytes=0-1
* - Return null when unreachable / 404 / not exist
*/
private suspend fun tryResolveSubtitleUri(pathOrUrl: String?): Uri? = withContext(Dispatchers.IO) {
if (pathOrUrl.isNullOrBlank()) return@withContext null
val trimmed = pathOrUrl.trim()
// Remote URL case (http/https)
if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true)) {
try {
val client = createOkHttp()
var headReq = Request.Builder().url(trimmed).head().build()
var headResp = try { client.newCall(headReq).execute() } catch (e: Exception) { null }
headResp?.use { resp ->
val code = resp.code
if (code == 200 || code == 206) {
return@withContext trimmed.toUri()
}
if (code == 404) {
return@withContext null
}
}
val rangeReq = Request.Builder()
.url(trimmed)
.addHeader("Range", "bytes=0-1")
.get()
.build()
var rangeResp = try { client.newCall(rangeReq).execute() } catch (e: Exception) { null }
rangeResp?.use { resp ->
val code = resp.code
if (code == 206) {
return@withContext trimmed.toUri()
}
if (code == 200) {
return@withContext trimmed.toUri()
}
if (code == 404) {
return@withContext null
}
}
} catch (e: Exception) {
return@withContext null
}
return@withContext null
} else {
// Local path
val f = File(trimmed)
return@withContext if (f.exists() && f.isFile) Uri.fromFile(f) else null
}
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun startListen() { fun startListen() {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
@@ -156,4 +266,4 @@ class VideoPlayerViewModel @Inject constructor(
database.userDao().insert(VideoRecord(video!!.id, video!!.klass, p)) database.userDao().insert(VideoRecord(video!!.id, video!!.klass, p))
} }
} }
} }