From 55ea2e1ae3451f983864060a396ad2fb58ad95c6 Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Sat, 20 Sep 2025 03:18:25 +0800 Subject: [PATCH] [feat] Video system optimization2 --- .../java/com/acitelight/aether/model/Video.kt | 7 + .../acitelight/aether/service/FetchManager.kt | 5 + .../com/acitelight/aether/view/VideoPlayer.kt | 88 ++++++++- .../aether/viewModel/VideoPlayerViewModel.kt | 184 ++++++++++++++---- 4 files changed, 245 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/acitelight/aether/model/Video.kt b/app/src/main/java/com/acitelight/aether/model/Video.kt index d389fde..fcee2a8 100644 --- a/app/src/main/java/com/acitelight/aether/model/Video.kt +++ b/app/src/main/java/com/acitelight/aether/model/Video.kt @@ -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 { return if (isLocal) video.gallery.map { diff --git a/app/src/main/java/com/acitelight/aether/service/FetchManager.kt b/app/src/main/java/com/acitelight/aether/service/FetchManager.kt index 771aa78..982e970 100644 --- a/app/src/main/java/com/acitelight/aether/service/FetchManager.kt +++ b/app/src/main/java/com/acitelight/aether/service/FetchManager.kt @@ -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)) diff --git a/app/src/main/java/com/acitelight/aether/view/VideoPlayer.kt b/app/src/main/java/com/acitelight/aether/view/VideoPlayer.kt index 684d7a8..5632085 100644 --- a/app/src/main/java/com/acitelight/aether/view/VideoPlayer.kt +++ b/app/src/main/java/com/acitelight/aether/view/VideoPlayer.kt @@ -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, + 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(), @@ -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() + ) } } } diff --git a/app/src/main/java/com/acitelight/aether/viewModel/VideoPlayerViewModel.kt b/app/src/main/java/com/acitelight/aether/viewModel/VideoPlayerViewModel.kt index 4dfc78c..c2ee481 100644 --- a/app/src/main/java/com/acitelight/aether/viewModel/VideoPlayerViewModel.kt +++ b/app/src/main/java/com/acitelight/aether/viewModel/VideoPlayerViewModel.kt @@ -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()) @OptIn(UnstableApi::class) fun init(videoId: String) { @@ -88,53 +96,155 @@ 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))) - else - MediaItem.fromUri(url) - setMediaItem(mediaItem) - prepare() - playWhenReady = true + val subtitleCandidate = video?.getSubtitle()?.trim() + val subtitleUri = tryResolveSubtitleUri(subtitleCandidate) - addListener(object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == STATE_READY) { - startPlaying = true - } - } + // 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) - 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 - } + // build ExoPlayer with or without custom DefaultMediaSourceFactory + val builder = if (needNetworkFactory) + ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + else + ExoPlayer.Builder(context) - 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) { + cues = lcues + } + }) + } startListen() } _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 { @@ -156,4 +266,4 @@ class VideoPlayerViewModel @Inject constructor( database.userDao().insert(VideoRecord(video!!.id, video!!.klass, p)) } } -} \ No newline at end of file +}