Files
Aether/app/src/main/java/com/acitelight/aether/viewModel/VideoPlayerViewModel.kt

335 lines
12 KiB
Kotlin

package com.acitelight.aether.viewModel
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.model.VideoRecord
import com.acitelight.aether.model.VideoRecordDatabase
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.view.formatTime
import com.acitelight.aether.view.hexToString
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
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.Tracks
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.acitelight.aether.model.KeyImage
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@HiltViewModel
class VideoPlayerViewModel @Inject constructor(
@ApplicationContext private val context: Context,
val mediaManager: MediaManager,
val recentManager: RecentManager
) : ViewModel() {
var tabIndex by mutableIntStateOf(0)
var isPlaying by mutableStateOf(true)
var playProcess by mutableFloatStateOf(0.0f)
var planeVisibility by mutableStateOf(true)
var isLongPressing by mutableStateOf(false)
// -1 : Not dragging
// 0 : Seek
// 1 : Volume
// 2 : Brightness
var draggingPurpose by mutableIntStateOf(-1)
var locked by mutableStateOf(false)
private var _init: Boolean = false
var startPlaying by mutableStateOf(false)
var renderedFirst = false
var videos: List<Video> = listOf()
private val httpDataSourceFactory = OkHttpDataSource.Factory(createOkHttp())
private val defaultDataSourceFactory by lazy {
DefaultDataSource.Factory(
context,
httpDataSourceFactory
)
}
var imageLoader: ImageLoader? = null
var brit by mutableFloatStateOf(0.0f)
val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context)
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)
fun init(videoId: String) {
if (_init)
return
_init = true
val vs = videoId.hexToString().split(",").map { it.split("/") }
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
viewModelScope.launch {
videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!!
val ii = database.userDao().getAll().first()
val ix = ii.filter { it.id in videos.map{ m -> m.id } }.maxByOrNull { it.time }
startPlay(
if (ix != null)
videos.first { it.id == ix.id }
else videos.first()
)
startListen()
}
}
/**
* 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()
val headReq = Request.Builder().url(trimmed).head().build()
val headResp = try {
client.newCall(headReq).execute()
} catch (_: 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()
val rangeResp = try {
client.newCall(rangeReq).execute()
} catch (_: 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 (_: 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 {
while (_init) {
player?.let { playProcess = it.currentPosition.toFloat() / it.duration.toFloat() }
delay(100)
}
}
}
@OptIn(UnstableApi::class)
suspend fun startPlay(video: Video) {
if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty()) {
val pos = player?.currentPosition ?: 0L
database.userDao().insert(
VideoRecord(
currentId.value,
currentKlass.value,
pos,
System.currentTimeMillis(),
videos.joinToString(",") { it.id })
)
}
renderedFirst = false
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(defaultDataSourceFactory))
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(currentId.value, currentKlass.value)
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() {
super.onCleared()
_init = false
val pos = player?.currentPosition ?: 0L
player?.release()
player = null
CoroutineScope(Dispatchers.IO).launch {
if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty())
database.userDao().insert(
VideoRecord(
currentId.value,
currentKlass.value,
pos,
System.currentTimeMillis(),
videos.joinToString(",") { it.id })
)
}
}
}