[add] Implement Bilibili style

This commit is contained in:
acite
2025-08-25 04:26:34 +08:00
parent d0a6497dd6
commit 484f158e17
12 changed files with 766 additions and 424 deletions

View File

@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "1.9.0"
}
android {

View File

@@ -6,8 +6,6 @@ import androidx.compose.runtime.setValue
import com.acitelight.aether.model.Video
object Global {
var videoName: String = ""
var videoClass: String = ""
var loggedIn by mutableStateOf(false)
var video: Video? = null
var sameClassVideos: List<Video>? = null
}

View File

@@ -116,7 +116,7 @@ fun AppNavigation() {
modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp)
) {
composable(Screen.Home.route) {
HomeScreen()
HomeScreen(navController = navController)
}
composable(Screen.Video.route) {
VideoScreen(navController = navController)
@@ -159,8 +159,6 @@ fun BottomNavigationBar(navController: NavController) {
Screen.Me
) else listOf(
Screen.Home,
Screen.Video,
Screen.Comic,
Screen.Transmission,
Screen.Me
)

View File

@@ -25,4 +25,5 @@ class Video constructor(
KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it")
}
}
}

View File

@@ -0,0 +1,9 @@
package com.acitelight.aether.model
import kotlinx.serialization.Serializable
@Serializable
data class VideoQueryIndex(
val klass: String,
val id: String
)

View File

@@ -0,0 +1,94 @@
package com.acitelight.aether.service
import android.content.Context
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.*
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
object RecentManager
{
private val mutex = Mutex()
suspend fun readFile(context: Context, filename: String): String {
return withContext(Dispatchers.IO) {
try {
val file = File(context.filesDir, filename)
val content = file.readText()
content
} catch (e: FileNotFoundException) {
"[]"
} catch (e: IOException) {
"[]"
}
}
}
suspend fun writeFile(context: Context, filename: String, content: String) {
withContext(Dispatchers.IO) {
try {
val file = File(context.filesDir, filename)
file.writeText(content)
} catch (e: IOException) {
e.printStackTrace()
}
}
}
suspend fun Query(context: Context): List<VideoQueryIndex>
{
val content = readFile(context, "recent.json")
try{
val r = Json.decodeFromString<List<VideoQueryIndex>>(content)
_recent.value = r.map{
MediaManager.queryVideo(it.klass, it.id)
}
return r
}catch (e: Exception)
{
print(e.message)
}
return listOf()
}
suspend fun Push(context: Context, video: VideoQueryIndex)
{
mutex.withLock{
val content = readFile(context, "recent.json")
var o = Json.decodeFromString<List<VideoQueryIndex>>(content).toMutableList();
if(o.contains(video))
{
val temp = o[0]
val index = o.indexOf(video)
o[0] = o[index]
o[index] = temp
}
else
{
o.add(0, video)
}
if(o.size >= 21)
o.removeAt(o.size - 1)
_recent.value = o.map{
MediaManager.queryVideo(it.klass, it.id)
}
writeFile(context, "recent.json", Json.encodeToString(o))
}
}
private val _recent = MutableStateFlow<List<Video>>(emptyList())
val recent: StateFlow<List<Video>> = _recent
}

View File

@@ -4,20 +4,64 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.acitelight.aether.Global
import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.viewModel.HomeScreenViewModel
@Composable
fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel())
fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navController: NavController)
{
if(Global.loggedIn)
homeScreenViewModel.Init()
val recent by RecentManager.recent.collectAsState()
LazyColumn(modifier = Modifier.fillMaxWidth())
{
item()
{
Column {
Text(
text = "Recent",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(16.dp).align(Alignment.Start)
)
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
for(i in recent)
{
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
{
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
navController.navigate(route)
})
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color)
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ package com.acitelight.aether.view
import android.R.id.tabs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -43,11 +44,14 @@ import com.acitelight.aether.viewModel.VideoScreenViewModel
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.navigation.NavHostController
import coil3.request.ImageRequest
import com.acitelight.aether.Global
import kotlinx.coroutines.flow.first
import java.nio.charset.Charset
fun String.toHex(): String {
@@ -83,7 +87,7 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navCon
)
{
items(videoList) { video ->
VideoCard(video, navController)
VideoCard(video, navController, videoScreenViewModel)
}
}
}
@@ -110,17 +114,17 @@ fun TopRow(videoScreenViewModel: VideoScreenViewModel)
}
@Composable
fun VideoCard(video: Video, navController: NavHostController) {
fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) {
val videoList by videoScreenViewModel.videos.collectAsState()
Card(
shape = RoundedCornerShape(6.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
onClick = {
Global.videoName = video.video.name
Global.videoClass = video.klass
Global.video = video
val route = "video_player_route/${ video.getVideo().toHex() }"
Global.sameClassVideos = videoList
val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }"
navController.navigate(route)
}
) {
@@ -128,17 +132,35 @@ fun VideoCard(video: Video, navController: NavHostController) {
modifier = Modifier
.fillMaxWidth()
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover())
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(modifier = Modifier.fillMaxSize()){
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover())
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize(),
contentScale = ContentScale.Crop
)
Text(
modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp),
text = formatTime(video.video.duration), fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold)
Box(
Modifier
.fillMaxWidth()
.height(24.dp)
.background( brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.45f)
)
))
.align(Alignment.BottomCenter))
}
Text(
text = video.video.name,
fontSize = 14.sp,
@@ -151,7 +173,8 @@ fun VideoCard(video: Video, navController: NavHostController) {
modifier = Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("class: ${video.klass}", fontSize = 12.sp)
Text("Class", fontSize = 12.sp)
Text("${video.klass}", fontSize = 12.sp)
}
}
}

View File

@@ -1,21 +1,81 @@
package com.acitelight.aether.viewModel
import android.app.Application
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.acitelight.aether.Global
import com.acitelight.aether.dataStore
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.AuthManager
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.MediaManager.token
import com.acitelight.aether.service.RecentManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class HomeScreenViewModel() : ViewModel()
class HomeScreenViewModel(application: Application) : AndroidViewModel(application)
{
private val dataStore = application.dataStore
private val USER_NAME_KEY = stringPreferencesKey("user_name")
private val PRIVATE_KEY = stringPreferencesKey("private_key")
val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[USER_NAME_KEY] ?: ""
}
val privateKeyFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[PRIVATE_KEY] ?: ""
}
var _init = false
@Composable
fun Init(){
if(_init) return
_init = true
val context = LocalContext.current
remember {
viewModelScope.launch {
RecentManager.Query(context)
}
}
}
init {
viewModelScope.launch {
val u = userNameFlow.first()
val p = privateKeyFlow.first()
if(u=="" || p=="") return@launch
try{
if (MediaManager.token == "null")
MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u,
p
)!!
}catch(e: Exception)
{
print(e.message)
}finally {
Global.loggedIn = true
}
}
}
}

View File

@@ -13,10 +13,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.acitelight.aether.Global
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.view.hexToString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -32,25 +37,47 @@ class VideoPlayerViewModel() : ViewModel()
var isLongPressing by mutableStateOf(false)
var dragging by mutableStateOf(false)
var thumbUp by mutableIntStateOf(0)
var thumbDown by mutableIntStateOf(0)
var star by mutableStateOf(false)
private var _init: Boolean = false;
var startPlaying by mutableStateOf(false)
var renderedFirst = false
var video: Video? = null
@Composable
fun Init(videoId: String)
{
if(_init) return;
val context = LocalContext.current
_player = remember {
ExoPlayer.Builder(context).build().apply {
val url = videoId.hexToString()
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
prepare()
playWhenReady = true
val v = videoId.hexToString()
remember {
viewModelScope.launch {
video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])
RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1]))
_player = ExoPlayer.Builder(context).build().apply {
val url = video?.getVideo() ?: ""
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
prepare()
playWhenReady = true
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == STATE_READY) {
startPlaying = true
}
}
override fun onRenderedFirstFrame() {
super.onRenderedFirstFrame()
renderedFirst = true
}
})
}
startListen()
}
}

View File

@@ -62,24 +62,7 @@ class VideoScreenViewModel(application: Application) : AndroidViewModel(applicat
init {
viewModelScope.launch {
val u = userNameFlow.first()
val p = privateKeyFlow.first()
if(u=="" || p=="") return@launch
try{
if (MediaManager.token == "null")
MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u,
p
)!!
init()
}catch(e: Exception)
{
print(e.message)
}
init()
}
}
}