[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"
}
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> {
return if (isLocal)
video.gallery.map {

View File

@@ -157,6 +157,11 @@ class FetchManager @Inject constructor(
video.getCover(),
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)
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.res.Configuration
import android.media.AudioManager
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
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.geometry.Offset
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.NestedScrollSource
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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.text.Cue
import androidx.media3.common.util.UnstableApi
import coil3.ImageLoader
import coil3.compose.AsyncImage
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
fun VideoPlayer(
videoPlayerViewModel: VideoPlayerViewModel = hiltViewModel<VideoPlayerViewModel>(),
@@ -227,6 +295,7 @@ fun VideoPlayer(
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float)
{
@@ -255,6 +324,9 @@ fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewMo
).apply {
player = exoPlayer
useController = false
subtitleView?.let { sv ->
sv.visibility = View.GONE
}
}
},
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
fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
{
@@ -867,6 +945,9 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
).apply {
player = exoPlayer
useController = false
subtitleView?.let { sv ->
sv.visibility = View.GONE
}
}
},
modifier = Modifier
@@ -1106,8 +1187,6 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility,
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.Player
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.text.Cue
import androidx.media3.common.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -40,8 +41,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File
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
class VideoPlayerViewModel @Inject constructor(
@@ -74,6 +81,7 @@ class VideoPlayerViewModel @Inject constructor(
var imageLoader: ImageLoader? = null;
var brit by mutableFloatStateOf(0.5f)
val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context)
var cues by mutableStateOf(listOf<Cue>())
@OptIn(UnstableApi::class)
fun init(videoId: String) {
@@ -88,21 +96,58 @@ class VideoPlayerViewModel @Inject constructor(
viewModelScope.launch {
video = mediaManager.queryVideo(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)))
val subtitleCandidate = video?.getSubtitle()?.trim()
val subtitleUri = tryResolveSubtitleUri(subtitleCandidate)
// decide whether we need network-capable media source factory:
val subtitleIsRemote = subtitleUri?.scheme?.startsWith("http", ignoreCase = true) == true
val videoIsRemote = !video!!.isLocal
val needNetworkFactory = videoIsRemote || subtitleIsRemote
val trackSelector = DefaultTrackSelector(context)
// build ExoPlayer with or without custom DefaultMediaSourceFactory
val builder = if (needNetworkFactory)
ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
else
MediaItem.fromUri(url)
ExoPlayer.Builder(context)
_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
@@ -125,8 +170,13 @@ class VideoPlayerViewModel @Inject constructor(
renderedFirst = true
}
override fun onPlayerError(error: PlaybackException) {
override fun onPlayerError(error: PlaybackException)
{
print(error.message)
}
override fun onCues(lcues: MutableList<Cue>) {
cues = lcues
}
})
}
@@ -135,6 +185,66 @@ class VideoPlayerViewModel @Inject constructor(
_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)
fun startListen() {
CoroutineScope(Dispatchers.Main).launch {