[add] Implement Bilibili style
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -25,4 +25,5 @@ class Video constructor(
|
||||
KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.acitelight.aether.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VideoQueryIndex(
|
||||
val klass: String,
|
||||
val id: String
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user