[feat] Video Group

This commit is contained in:
acite
2025-09-26 03:09:14 +08:00
parent 756c2ea9f8
commit e38d77b2f6
5 changed files with 244 additions and 186 deletions

View File

@@ -10,5 +10,6 @@ data class VideoResponse(
val comment: List<Comment>, val comment: List<Comment>,
val star: Boolean, val star: Boolean,
val like: Int, val like: Int,
val author: String val author: String,
val group: String?
) )

View File

@@ -3,7 +3,6 @@ package com.acitelight.aether.service
import android.content.Context import android.content.Context
import com.acitelight.aether.model.BookMark import com.acitelight.aether.model.BookMark
import com.acitelight.aether.model.Comic import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicResponse
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.tonyodev.fetch2.Status import com.tonyodev.fetch2.Status
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -27,7 +26,7 @@ class MediaManager @Inject constructor(
{ {
val j = ApiClient.api!!.getVideoClasses(token) val j = ApiClient.api!!.getVideoClasses(token)
return j.toList() return j.toList()
}catch(e: Exception) }catch(_: Exception)
{ {
return listOf() return listOf()
} }
@@ -39,7 +38,7 @@ class MediaManager @Inject constructor(
{ {
val j = ApiClient.api!!.queryVideoClasses(klass, token) val j = ApiClient.api!!.queryVideoClasses(klass, token)
return j.toList() return j.toList()
}catch(e: Exception) }catch(_: Exception)
{ {
return listOf() return listOf()
} }
@@ -65,7 +64,7 @@ class MediaManager @Inject constructor(
try { try {
val j = ApiClient.api!!.queryVideo(klass, id, token) val j = ApiClient.api!!.queryVideo(klass, id, token)
return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j) return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j)
}catch (e: Exception) }catch (_: Exception)
{ {
return null return null
} }
@@ -100,7 +99,7 @@ class MediaManager @Inject constructor(
Json.decodeFromString<Video>(jsonString).toLocal( Json.decodeFromString<Video>(jsonString).toLocal(
context.getExternalFilesDir(null)?.path ?: "" context.getExternalFilesDir(null)?.path ?: ""
) )
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} else { } else {
@@ -125,7 +124,7 @@ class MediaManager @Inject constructor(
} }
localVideos + remoteVideos localVideos + remoteVideos
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} }
@@ -135,7 +134,7 @@ class MediaManager @Inject constructor(
try{ try{
val j = ApiClient.api!!.getComics(token) val j = ApiClient.api!!.getComics(token)
return j return j
}catch (e: Exception) }catch (_: Exception)
{ {
return listOf() return listOf()
} }
@@ -146,7 +145,7 @@ class MediaManager @Inject constructor(
try{ try{
val j = ApiClient.api!!.queryComicInfo(id, token) val j = ApiClient.api!!.queryComicInfo(id, token)
return Comic(id = id, comic = j, token = token) return Comic(id = id, comic = j, token = token)
}catch (e: Exception) }catch (_: Exception)
{ {
return null return null
} }
@@ -157,7 +156,7 @@ class MediaManager @Inject constructor(
try{ try{
val j = ApiClient.api!!.queryComicInfoBulk(id, token) val j = ApiClient.api!!.queryComicInfoBulk(id, token)
return j.zip(id).map { Comic(id = it.second, comic = it.first, token = token) } return j.zip(id).map { Comic(id = it.second, comic = it.first, token = token) }
}catch (e: Exception) }catch (_: Exception)
{ {
return null return null
} }
@@ -166,9 +165,9 @@ class MediaManager @Inject constructor(
suspend fun postBookmark(id: String, bookMark: BookMark): Boolean suspend fun postBookmark(id: String, bookMark: BookMark): Boolean
{ {
try{ try{
val j = ApiClient.api!!.postBookmark(id, token, bookMark) ApiClient.api!!.postBookmark(id, token, bookMark)
return true return true
}catch (e: Exception) }catch (_: Exception)
{ {
return false return false
} }

View File

@@ -2,11 +2,9 @@ package com.acitelight.aether.view
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
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 android.view.View
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
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -27,7 +25,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -54,11 +51,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Brightness4 import androidx.compose.material.icons.filled.Brightness4
import androidx.compose.material.icons.filled.FastForward import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.LockOpen
@@ -66,9 +61,6 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.ThumbDown import androidx.compose.material.icons.filled.ThumbDown
import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardColors import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -76,12 +68,9 @@ import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -97,7 +86,6 @@ 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
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale 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
@@ -110,9 +98,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit 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.lifecycle.viewModelScope
import androidx.media3.common.text.Cue import androidx.media3.common.text.Cue
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.wear.compose.materialcore.screenHeightDp
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
@@ -120,6 +108,7 @@ import com.acitelight.aether.Global
import com.acitelight.aether.ToggleFullScreen import com.acitelight.aether.ToggleFullScreen
import com.acitelight.aether.model.KeyImage import com.acitelight.aether.model.KeyImage
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
fun formatTime(ms: Long): String { fun formatTime(ms: Long): String {
@@ -147,7 +136,6 @@ fun BiliStyleSlider(
valueRange: ClosedFloatingPointRange<Float> = 0f..1f valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val thumbRadius = 6.dp
val trackHeight = 3.dp val trackHeight = 3.dp
Slider( Slider(
@@ -189,7 +177,6 @@ fun BiliMiniSlider(
valueRange: ClosedFloatingPointRange<Float> = 0f..1f valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val thumbRadius = 6.dp
val trackHeight = 3.dp val trackHeight = 3.dp
Slider( Slider(
@@ -337,7 +324,7 @@ fun VideoPlayer(
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@Composable @Composable
fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float) { fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float) {
val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!; val exoPlayer: ExoPlayer = videoPlayerViewModel.player!!
val context = LocalContext.current val context = LocalContext.current
val activity = context as? Activity val activity = context as? Activity
@@ -379,16 +366,16 @@ fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewMo
onDragStart = { offset -> onDragStart = { offset ->
if (videoPlayerViewModel.locked) return@detectDragGestures if (videoPlayerViewModel.locked) return@detectDragGestures
if (offset.x < size.width / 2) { if (offset.x < size.width / 2) {
videoPlayerViewModel.draggingPurpose = -1; videoPlayerViewModel.draggingPurpose = -1
} else { } else {
videoPlayerViewModel.draggingPurpose = -2; videoPlayerViewModel.draggingPurpose = -2
} }
}, },
onDragEnd = { onDragEnd = {
if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0) if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0)
exoPlayer.play() exoPlayer.play()
videoPlayerViewModel.draggingPurpose = -1; videoPlayerViewModel.draggingPurpose = -1
}, },
onDrag = { change, dragAmount -> onDrag = { change, dragAmount ->
if (videoPlayerViewModel.locked) return@detectDragGestures if (videoPlayerViewModel.locked) return@detectDragGestures
@@ -412,15 +399,15 @@ fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewMo
volFactor = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) volFactor = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
.toFloat() / maxVolume.toFloat() .toFloat() / maxVolume.toFloat()
if (dragAmount.y < 0) if (dragAmount.y < 0)
setVolume(cu + 1); setVolume(cu + 1)
else if (dragAmount.y > 0) else if (dragAmount.y > 0)
setVolume(cu - 1); setVolume(cu - 1)
} else if (videoPlayerViewModel.draggingPurpose == 1) { } else if (videoPlayerViewModel.draggingPurpose == 1) {
videoPlayerViewModel.brit = videoPlayerViewModel.brit =
(videoPlayerViewModel.brit - dragAmount.y * 0.002f).coerceIn( (videoPlayerViewModel.brit - dragAmount.y * 0.002f).coerceIn(
0f, 0f,
1f 1f
); )
activity?.window?.attributes = activity.window.attributes.apply { activity?.window?.attributes = activity.window.attributes.apply {
screenBrightness = videoPlayerViewModel.brit.coerceIn(0f, 1f) screenBrightness = videoPlayerViewModel.brit.coerceIn(0f, 1f)
@@ -514,7 +501,7 @@ fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewMo
Text( Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime( formatTime(
(exoPlayer.duration).toLong() (exoPlayer.duration)
) )
}", }",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -719,7 +706,7 @@ fun VideoPlayerPortal(
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp; val screenHeight = configuration.screenHeightDp.dp
val minHeight = 42.dp val minHeight = 42.dp
var coverAlpha by remember { mutableFloatStateOf(0.0f) } var coverAlpha by remember { mutableFloatStateOf(0.0f) }
@@ -752,7 +739,7 @@ fun VideoPlayerPortal(
Offset.Zero Offset.Zero
} }
val dh = playerHeight - minHeight; val dh = playerHeight - minHeight
coverAlpha = (if (dh > 10.dp) coverAlpha = (if (dh > 10.dp)
0f 0f
else else
@@ -763,6 +750,11 @@ fun VideoPlayerPortal(
} }
} }
val klass by videoPlayerViewModel.currentKlass
val id by videoPlayerViewModel.currentId
val name by videoPlayerViewModel.currentName
val duration by videoPlayerViewModel.currentDuration
ToggleFullScreen(false) ToggleFullScreen(false)
Column(Modifier Column(Modifier
.nestedScroll(nestedScrollConnection) .nestedScroll(nestedScrollConnection)
@@ -844,7 +836,7 @@ fun VideoPlayerPortal(
.align(Alignment.Start) .align(Alignment.Start)
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.padding(top = 12.dp), .padding(top = 12.dp),
text = videoPlayerViewModel.video?.video?.name ?: "", text = name,
fontSize = 16.sp, fontSize = 16.sp,
maxLines = 2, maxLines = 2,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -856,7 +848,7 @@ fun VideoPlayerPortal(
.alpha(0.5f)) { .alpha(0.5f)) {
Text( Text(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
text = videoPlayerViewModel.video?.klass ?: "", text = klass,
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -864,7 +856,7 @@ fun VideoPlayerPortal(
Text( Text(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
text = formatTime(videoPlayerViewModel.video?.video?.duration ?: 0), text = formatTime(duration),
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -873,10 +865,8 @@ fun VideoPlayerPortal(
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
SocialPanel( PlaylistPanel(
Modifier Modifier,
.align(Alignment.CenterHorizontally)
.fillMaxWidth(),
videoPlayerViewModel = videoPlayerViewModel videoPlayerViewModel = videoPlayerViewModel
) )
@@ -886,7 +876,7 @@ fun VideoPlayerPortal(
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
for (i in Global.sameClassVideos ?: listOf()) { for (i in Global.sameClassVideos ?: listOf()) {
if (i.id == videoPlayerViewModel.video?.id) continue if (i.id == id) continue
MiniVideoCard( MiniVideoCard(
modifier = Modifier modifier = Modifier
@@ -894,7 +884,7 @@ fun VideoPlayerPortal(
i, i,
{ {
videoPlayerViewModel.isPlaying = false videoPlayerViewModel.isPlaying = false
videoPlayerViewModel._player?.pause() videoPlayerViewModel.player?.pause()
val route = "video_player_route/${"${i.klass}/${i.id}".toHex()}" val route = "video_player_route/${"${i.klass}/${i.id}".toHex()}"
navController.navigate(route) navController.navigate(route)
}, videoPlayerViewModel.imageLoader!! }, videoPlayerViewModel.imageLoader!!
@@ -1023,8 +1013,43 @@ fun SocialPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel)
} }
} }
@Composable
fun PlaylistPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel)
{
val name by videoPlayerViewModel.currentName
LazyRow(
modifier = modifier
.fillMaxWidth()
.height(80.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(horizontal = 24.dp)
) {
items(videoPlayerViewModel.videos) { it ->
// SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
Card(
modifier = Modifier.fillMaxHeight().width(140.dp),
onClick = {
if(name == it.video.name)
return@Card
videoPlayerViewModel.viewModelScope.launch {
videoPlayerViewModel.startPlay(it)
}
}
) {
Box(Modifier.padding(8.dp).fillMaxSize())
{
Text(modifier = Modifier.align(Alignment.Center), text = it.video.name, maxLines = 4, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 13.sp)
}
}
}
}
}
@Composable @Composable
fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel) { fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel) {
val gallery by videoPlayerViewModel.currentGallery
LazyRow( LazyRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1032,7 +1057,7 @@ fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel) {
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 24.dp) contentPadding = PaddingValues(horizontal = 24.dp)
) { ) {
items(videoPlayerViewModel.video?.getGallery() ?: listOf()) { it -> items(gallery) { it ->
SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!) SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
} }
} }
@@ -1060,7 +1085,7 @@ fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) {
fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) { fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? Activity val activity = context as? Activity
val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!; val exoPlayer: ExoPlayer = videoPlayerViewModel.player!!;
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
@@ -1070,6 +1095,8 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) {
) )
} }
val name by videoPlayerViewModel.currentName
fun setVolume(value: Int) { fun setVolume(value: Int) {
audioManager.setStreamVolume( audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC, AudioManager.STREAM_MUSIC,
@@ -1349,7 +1376,7 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) {
) )
{ {
Text( Text(
text = "${videoPlayerViewModel.video?.video?.name}", text = name,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier modifier = Modifier
.padding(horizontal = 46.dp).padding(top = 12.dp) .padding(horizontal = 46.dp).padding(top = 12.dp)
@@ -1437,7 +1464,6 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) {
@Composable @Composable
fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader) { fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader) {
var isImageLoaded by remember { mutableStateOf(false) }
Card( Card(
modifier = modifier modifier = modifier
.height(80.dp) .height(80.dp)
@@ -1460,7 +1486,6 @@ fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLo
.diskCacheKey("${video.klass}/${video.id}/cover") .diskCacheKey("${video.klass}/${video.id}/cover")
.listener( .listener(
onStart = { }, onStart = { },
onSuccess = { _, _ -> isImageLoaded = true },
onError = { _, _ -> } onError = { _, _ -> }
) )
.build(), .build(),

View File

@@ -92,6 +92,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.nio.charset.Charset import java.nio.charset.Charset
import java.security.KeyPair import java.security.KeyPair
import kotlin.collections.sortedWith
fun videoTOView(v: List<Video>): Map<String?, List<Video>>
{
return v.map { if(it.video.group != null) it else Video(id=it.id, isLocal = it.isLocal, localBase = it.localBase,
klass = it.klass, token = it.token, video = it.video.copy(group = it.video.name)) }.groupBy { it.video.group }
}
fun String.toHex(): String { fun String.toHex(): String {
return this.toByteArray().joinToString("") { "%02x".format(it) } return this.toByteArray().joinToString("") { "%02x".format(it) }
@@ -119,6 +126,13 @@ fun VideoScreen(
var menuVisibility by videoScreenViewModel.menuVisibility var menuVisibility by videoScreenViewModel.menuVisibility
var searchFilter by videoScreenViewModel.searchFilter var searchFilter by videoScreenViewModel.searchFilter
var doneInit by videoScreenViewModel.doneInit var doneInit by videoScreenViewModel.doneInit
val vb = videoTOView(videoScreenViewModel.videoLibrary.classesMap.getOrDefault(
videoScreenViewModel.videoLibrary.classes.getOrNull(
tabIndex
), listOf()
).filter { it.video.name.contains(searchFilter) }).filter { it.key != null }
.map{ i -> Pair(i.key!!, i.value.sortedWith(compareBy(naturalOrder()) { it.video.name }) ) }
.toList()
if (doneInit) if (doneInit)
CardPage(title = "Videos") { CardPage(title = "Videos") {
@@ -225,19 +239,16 @@ fun VideoScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items( items(
items = videoScreenViewModel.videoLibrary.classesMap.getOrDefault( items = vb,
videoScreenViewModel.videoLibrary.classes.getOrNull( key = { "${it.first}/${it.second}" }
tabIndex
), listOf()
).filter { it.video.name.contains(searchFilter) },
key = { "${it.klass}/${it.id}" }
) { video -> ) { video ->
androidx.compose.foundation.layout.Box( androidx.compose.foundation.layout.Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
) { ) {
VideoCard(video, navController, videoScreenViewModel) if(video.second.isNotEmpty())
VideoCard(video.second, navController, videoScreenViewModel)
} }
} }
} }
@@ -312,11 +323,12 @@ fun CatalogueItemRow(
@Composable @Composable
fun VideoCard( fun VideoCard(
video: Video, videos: List<Video>,
navController: NavHostController, navController: NavHostController,
videoScreenViewModel: VideoScreenViewModel videoScreenViewModel: VideoScreenViewModel
) { ) {
val tabIndex by videoScreenViewModel.tabIndex; val tabIndex by videoScreenViewModel.tabIndex;
val video = videos.first()
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -327,7 +339,8 @@ fun VideoCard(
videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]] videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]]
?: mutableStateListOf(), video ?: mutableStateListOf(), video
) )
val route = "video_player_route/${"${video.klass}/${video.id}".toHex()}" val vg = videos.joinToString(",") { "${it.klass}/${it.id}" }.toHex()
val route = "video_player_route/$vg"
navController.navigate(route) navController.navigate(route)
}, },
onLongClick = { onLongClick = {
@@ -362,14 +375,6 @@ fun VideoCard(
imageLoader = videoScreenViewModel.imageLoader!! imageLoader = videoScreenViewModel.imageLoader!!
) )
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(2.dp),
text = formatTime(video.video.duration),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Box( Box(
Modifier Modifier
@@ -379,13 +384,35 @@ fun VideoCard(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colors = listOf( colors = listOf(
Color.Transparent, Color.Transparent,
Color.Black.copy(alpha = 0.45f) Color.Black.copy(alpha = 0.6f)
) )
) )
) )
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
) )
Text(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 2.dp),
text = "${videos.size} Videos",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
lineHeight = 13.sp,
color = Color.White
)
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(horizontal = 2.dp),
text = formatTime(video.video.duration),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
lineHeight = 13.sp,
color = Color.White
)
if (video.isLocal) if (video.isLocal)
Card( Card(
Modifier Modifier
@@ -405,7 +432,7 @@ fun VideoCard(
} }
} }
Text( Text(
text = video.video.name, text = video.video.group ?: video.video.name,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 4, maxLines = 4,
@@ -423,14 +450,6 @@ fun VideoCard(
Text("Class: ", fontSize = 10.sp, maxLines = 1) Text("Class: ", fontSize = 10.sp, maxLines = 1)
Text(video.klass, fontSize = 10.sp, maxLines = 1) Text(video.klass, fontSize = 10.sp, maxLines = 1)
} }
Row(
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Id: ", fontSize = 10.sp, maxLines = 1, lineHeight = 10.sp)
Text(video.id, fontSize = 10.sp, maxLines = 1, lineHeight = 10.sp)
}
} }
} }
} }

View File

@@ -4,14 +4,13 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@@ -19,7 +18,6 @@ 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.text.Cue
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
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@@ -46,9 +44,9 @@ 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.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.acitelight.aether.model.KeyImage
@HiltViewModel @HiltViewModel
class VideoPlayerViewModel @Inject constructor( class VideoPlayerViewModel @Inject constructor(
@@ -67,26 +65,34 @@ class VideoPlayerViewModel @Inject constructor(
// 1 : Volume // 1 : Volume
// 2 : Brightness // 2 : Brightness
var draggingPurpose by mutableIntStateOf(-1) var draggingPurpose by mutableIntStateOf(-1)
var thumbUp by mutableIntStateOf(0) var thumbUp by mutableIntStateOf(0)
var thumbDown by mutableIntStateOf(0) var thumbDown by mutableIntStateOf(0)
var star by mutableStateOf(false) var star by mutableStateOf(false)
var locked by mutableStateOf(false) var locked by mutableStateOf(false)
private var _init: Boolean = false; private var _init: Boolean = false
var startPlaying by mutableStateOf(false) var startPlaying by mutableStateOf(false)
var renderedFirst = false var renderedFirst = false
var video: Video? = null var videos: List<Video> = listOf()
val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp()) val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp())
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>()) var cues by mutableStateOf(listOf<Cue>())
var currentKlass = mutableStateOf("")
var currentId = mutableStateOf("")
var currentName = mutableStateOf("")
var currentDuration = mutableLongStateOf(0)
var currentGallery = mutableStateOf(listOf<KeyImage>())
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun init(videoId: String) { fun init(videoId: String) {
if (_init) return; if (_init)
val v = videoId.hexToString() return
_init = true
val vs = videoId.hexToString().split(",").map { it.split("/") }
imageLoader = ImageLoader.Builder(context) imageLoader = ImageLoader.Builder(context)
.components { .components {
add(OkHttpNetworkFetcherFactory(createOkHttp())) add(OkHttpNetworkFetcherFactory(createOkHttp()))
@@ -94,94 +100,10 @@ class VideoPlayerViewModel @Inject constructor(
.build() .build()
viewModelScope.launch { viewModelScope.launch {
video = mediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!!
recentManager.pushVideo(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) startPlay(videos.first())
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
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 subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri)
.setMimeType("text/vtt")
.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().get(video!!.id, video!!.klass)
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;
} }
/** /**
@@ -199,8 +121,8 @@ class VideoPlayerViewModel @Inject constructor(
try { try {
val client = createOkHttp() val client = createOkHttp()
var headReq = Request.Builder().url(trimmed).head().build() val headReq = Request.Builder().url(trimmed).head().build()
var headResp = try { client.newCall(headReq).execute() } catch (e: Exception) { null } val headResp = try { client.newCall(headReq).execute() } catch (_: Exception) { null }
headResp?.use { resp -> headResp?.use { resp ->
val code = resp.code val code = resp.code
@@ -217,7 +139,7 @@ class VideoPlayerViewModel @Inject constructor(
.get() .get()
.build() .build()
var rangeResp = try { client.newCall(rangeReq).execute() } catch (e: Exception) { null } val rangeResp = try { client.newCall(rangeReq).execute() } catch (_: Exception) { null }
rangeResp?.use { resp -> rangeResp?.use { resp ->
val code = resp.code val code = resp.code
@@ -233,7 +155,7 @@ class VideoPlayerViewModel @Inject constructor(
return@withContext null return@withContext null
} }
} }
} catch (e: Exception) { } catch (_: Exception) {
return@withContext null return@withContext null
} }
return@withContext null return@withContext null
@@ -247,22 +169,114 @@ class VideoPlayerViewModel @Inject constructor(
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun startListen() { fun startListen() {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
while (_player?.isReleased != true) { while (_init) {
val __player = _player!!; player?.let { playProcess = it.currentPosition.toFloat() / it.duration.toFloat() }
playProcess = __player.currentPosition.toFloat() / __player.duration.toFloat()
delay(100) delay(100)
} }
} }
} }
var _player: ExoPlayer? = null; @OptIn(UnstableApi::class)
suspend fun startPlay(video: Video) {
currentId.value = video.id
currentKlass.value = video.klass
currentName.value = video.video.name
currentDuration.longValue = video.video.duration
currentGallery.value = video.getGallery()
player?.apply {
stop()
clearMediaItems()
}
recentManager.pushVideo(context, VideoQueryIndex(video.klass, video.id))
val subtitleCandidate = video.getSubtitle().trim()
val subtitleUri = tryResolveSubtitleUri(subtitleCandidate)
if (player == null) {
val trackSelector = DefaultTrackSelector(context)
val builder = ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
player = builder.setTrackSelector(trackSelector).build().apply {
addListener(object : Player.Listener {
override fun onTracksChanged(tracks: 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() {
if (!renderedFirst) {
viewModelScope.launch {
val ii = database.userDao().get(video.id, video.klass)
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
}
})
}
}
val url = video.getVideo()
val videoUri = if (video.isLocal) Uri.fromFile(File(url)) else url.toUri()
val mediaItem: MediaItem = if (subtitleUri != null) {
val subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri)
.setMimeType("text/vtt")
.build()
MediaItem.Builder()
.setUri(videoUri)
.setSubtitleConfigurations(listOf(subConfig))
.build()
} else {
MediaItem.fromUri(videoUri)
}
player?.apply {
setMediaItem(mediaItem)
prepare()
playWhenReady = true
}
}
var player: ExoPlayer? = null
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
val p = _player!!.currentPosition _init = false
_player?.release() val p = player!!.currentPosition
player?.release()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
database.userDao().insert(VideoRecord(video!!.id, video!!.klass, p)) if(currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty())
database.userDao().insert(VideoRecord(currentId.value, currentKlass.value, p))
} }
} }
} }