[feat] Complete video caching system
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.acitelight.aether.viewModel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -17,11 +18,13 @@ import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||
import com.acitelight.aether.service.MediaManager
|
||||
import com.acitelight.aether.service.RecentManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ComicGridViewModel @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val mediaManager: MediaManager
|
||||
) : ViewModel()
|
||||
{
|
||||
@@ -31,22 +34,17 @@ class ComicGridViewModel @Inject constructor(
|
||||
var db: ComicRecordDatabase? = null
|
||||
var record = mutableStateOf<ComicRecord?>(null)
|
||||
|
||||
@Composable
|
||||
fun SetupClient()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
init {
|
||||
imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||
}
|
||||
.build()
|
||||
db = remember {
|
||||
try{
|
||||
db = try{
|
||||
ComicRecordDatabase.getDatabase(context)
|
||||
}catch (e: Exception) {
|
||||
print(e.message)
|
||||
} as ComicRecordDatabase?
|
||||
}
|
||||
}
|
||||
|
||||
fun resolve(id: String)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.acitelight.aether.viewModel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -12,6 +13,7 @@ import com.acitelight.aether.model.ComicResponse
|
||||
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||
import com.acitelight.aether.service.MediaManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -19,6 +21,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ComicScreenViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
val mediaManager: MediaManager
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -48,18 +51,13 @@ class ComicScreenViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SetupClient()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
init {
|
||||
imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val l = mediaManager.listComics()
|
||||
val m = mediaManager.queryComicInfoBulk(l)
|
||||
|
||||
@@ -5,11 +5,13 @@ import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.acitelight.aether.AetherApp
|
||||
import com.acitelight.aether.Global
|
||||
import com.acitelight.aether.dataStore
|
||||
import com.acitelight.aether.model.Video
|
||||
@@ -25,6 +27,8 @@ import javax.inject.Inject
|
||||
import com.acitelight.aether.service.*
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@HiltViewModel
|
||||
class MeScreenViewModel @Inject constructor(
|
||||
@@ -59,10 +63,19 @@ class MeScreenViewModel @Inject constructor(
|
||||
)!!
|
||||
|
||||
Global.loggedIn = true
|
||||
withContext(Dispatchers.IO)
|
||||
{
|
||||
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
|
||||
context.abyssService?.downloader?.init()
|
||||
}
|
||||
}catch(e: Exception)
|
||||
{
|
||||
Global.loggedIn = false
|
||||
print(e.message)
|
||||
withContext(Dispatchers.IO)
|
||||
{
|
||||
(context as AetherApp).abyssService?.downloader?.init()
|
||||
}
|
||||
Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +107,11 @@ class MeScreenViewModel @Inject constructor(
|
||||
)!!
|
||||
|
||||
Global.loggedIn = true
|
||||
withContext(Dispatchers.IO)
|
||||
{
|
||||
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
|
||||
context.abyssService?.downloader?.init()
|
||||
}
|
||||
Toast.makeText(context, "Server Updated, Used Url: $usedUrl", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
print(e.message)
|
||||
@@ -124,6 +142,11 @@ class MeScreenViewModel @Inject constructor(
|
||||
)!!
|
||||
|
||||
Global.loggedIn = true
|
||||
withContext(Dispatchers.IO)
|
||||
{
|
||||
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
|
||||
context.abyssService?.downloader?.init()
|
||||
}
|
||||
Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
print(e.message)
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
package com.acitelight.aether.viewModel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.acitelight.aether.model.DownloadItemState
|
||||
import com.acitelight.aether.service.FetchManager
|
||||
import com.acitelight.aether.service.VideoLibrary
|
||||
import com.tonyodev.fetch2.Download
|
||||
import com.tonyodev.fetch2.FetchListener
|
||||
import com.tonyodev.fetch2core.DownloadBlock
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TransmissionScreenViewModel @Inject constructor(
|
||||
private val fetchManager: FetchManager
|
||||
val fetchManager: FetchManager,
|
||||
@ApplicationContext val context: Context,
|
||||
private val videoLibrary: VideoLibrary
|
||||
) : ViewModel() {
|
||||
private val _downloads: SnapshotStateList<DownloadItemState> = mutableStateListOf()
|
||||
val downloads: SnapshotStateList<DownloadItemState> = _downloads
|
||||
@@ -37,11 +40,18 @@ class TransmissionScreenViewModel @Inject constructor(
|
||||
override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) { handleUpsert(download) }
|
||||
override fun onPaused(download: Download) { handleUpsert(download) }
|
||||
override fun onResumed(download: Download) { handleUpsert(download) }
|
||||
override fun onCompleted(download: Download) { handleUpsert(download) }
|
||||
override fun onCompleted(download: Download) {
|
||||
val ii = videoLibrary.classesMap[download.extras.getString("class", "")]
|
||||
?.indexOfFirst { it.id == download.extras.getString("id", "") }!!
|
||||
|
||||
val newi = videoLibrary.classesMap[download.extras.getString("class", "")]!![ii]
|
||||
videoLibrary.classesMap[download.extras.getString("class", "")]!![ii] = newi.toLocal(context.getExternalFilesDir(null)!!.path)
|
||||
handleUpsert(download)
|
||||
}
|
||||
override fun onCancelled(download: Download) { handleUpsert(download) }
|
||||
override fun onRemoved(download: Download) { handleRemove(download.id) }
|
||||
override fun onDeleted(download: Download) { handleRemove(download.id) }
|
||||
override fun onDownloadBlockUpdated(download: Download, downloadBlock: DownloadBlock, blockCount: Int) { handleUpsert(download) }
|
||||
override fun onDownloadBlockUpdated(download: Download, downloadBlock: DownloadBlock, totalBlocks: Int) { handleUpsert(download) }
|
||||
override fun onStarted(
|
||||
download: Download,
|
||||
downloadBlocks: List<DownloadBlock>,
|
||||
@@ -107,7 +117,9 @@ class TransmissionScreenViewModel @Inject constructor(
|
||||
progress = download.progress,
|
||||
status = download.status,
|
||||
downloadedBytes = download.downloaded,
|
||||
totalBytes = download.total
|
||||
totalBytes = download.total,
|
||||
klass = download.extras.getString("class", ""),
|
||||
vid = download.extras.getString("id", "")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,15 +140,17 @@ class TransmissionScreenViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
fetchManager.setListener(fetchListener)
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
fetchManager.getAllDownloads { list ->
|
||||
_downloads.clear()
|
||||
idToState.clear()
|
||||
list.sortedByDescending { it.id }.forEach { d ->
|
||||
val s = downloadToState(d)
|
||||
_downloads.add(s)
|
||||
idToState[s.id] = s
|
||||
viewModelScope.launch {
|
||||
fetchManager.setListener(fetchListener)
|
||||
withContext(Dispatchers.Main) {
|
||||
fetchManager.getAllDownloads { list ->
|
||||
_downloads.clear()
|
||||
idToState.clear()
|
||||
list.sortedBy { it.extras.getString("name", "") }.forEach { d ->
|
||||
val s = downloadToState(d)
|
||||
_downloads.add(s)
|
||||
idToState[s.id] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.acitelight.aether.viewModel
|
||||
|
||||
import android.app.Activity
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
@@ -14,8 +15,10 @@ import androidx.compose.ui.platform.LocalContext
|
||||
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.util.Log
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -34,16 +37,16 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class VideoPlayerViewModel @Inject constructor(
|
||||
val mediaManager: MediaManager,
|
||||
val recentManager: RecentManager
|
||||
) : ViewModel()
|
||||
{
|
||||
) : ViewModel() {
|
||||
var tabIndex by mutableIntStateOf(0)
|
||||
var isPlaying by mutableStateOf(true)
|
||||
var isPlaying by mutableStateOf(true)
|
||||
var playProcess by mutableFloatStateOf(0.0f)
|
||||
var planeVisibility by mutableStateOf(true)
|
||||
var isLongPressing by mutableStateOf(false)
|
||||
@@ -65,17 +68,16 @@ class VideoPlayerViewModel @Inject constructor(
|
||||
|
||||
val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp())
|
||||
var imageLoader: ImageLoader? = null;
|
||||
var brit by mutableFloatStateOf(0.5f)
|
||||
var brit by mutableFloatStateOf(0.5f)
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun Init(videoId: String)
|
||||
{
|
||||
if(_init) return;
|
||||
fun Init(videoId: String) {
|
||||
if (_init) return;
|
||||
val context = LocalContext.current
|
||||
val v = videoId.hexToString()
|
||||
|
||||
imageLoader = ImageLoader.Builder(context)
|
||||
imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||
}
|
||||
@@ -85,29 +87,35 @@ class VideoPlayerViewModel @Inject constructor(
|
||||
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)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||
_player = (if(video!!.isLocal) ExoPlayer.Builder(context) else ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)))
|
||||
.build().apply {
|
||||
val url = video?.getVideo() ?: ""
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
setMediaItem(mediaItem)
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
val url = video?.getVideo() ?: ""
|
||||
val mediaItem = if (video!!.isLocal)
|
||||
MediaItem.fromUri(Uri.fromFile(File(url)))
|
||||
else
|
||||
MediaItem.fromUri(url)
|
||||
|
||||
addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == STATE_READY) {
|
||||
startPlaying = true
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
override fun onRenderedFirstFrame() {
|
||||
super.onRenderedFirstFrame()
|
||||
renderedFirst = true
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Log.e("ExoPlayer", "Playback error: ", error)
|
||||
}
|
||||
})
|
||||
}
|
||||
startListen()
|
||||
}
|
||||
}
|
||||
@@ -116,8 +124,7 @@ class VideoPlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun startListen()
|
||||
{
|
||||
fun startListen() {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
while (_player?.isReleased != true) {
|
||||
val __player = _player!!;
|
||||
|
||||
@@ -13,86 +13,106 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil3.ImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.acitelight.aether.Global
|
||||
import com.acitelight.aether.model.Video
|
||||
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||
import com.acitelight.aether.service.FetchManager
|
||||
import com.acitelight.aether.service.MediaManager
|
||||
import com.acitelight.aether.service.RecentManager
|
||||
import com.acitelight.aether.service.VideoLibrary
|
||||
import com.tonyodev.fetch2.Status
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class VideoScreenViewModel @Inject constructor(
|
||||
private val fetchManager: FetchManager,
|
||||
@ApplicationContext val context: Context,
|
||||
val mediaManager: MediaManager,
|
||||
val recentManager: RecentManager
|
||||
) : ViewModel()
|
||||
{
|
||||
val recentManager: RecentManager,
|
||||
val videoLibrary: VideoLibrary
|
||||
) : ViewModel() {
|
||||
private val _tabIndex = mutableIntStateOf(0)
|
||||
val tabIndex: State<Int> = _tabIndex
|
||||
// val videos = mutableStateListOf<Video>()
|
||||
var classes = mutableStateListOf<String>()
|
||||
val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>()
|
||||
|
||||
var imageLoader: ImageLoader? = null;
|
||||
val updatingMap: MutableMap<Int, Boolean> = mutableMapOf()
|
||||
|
||||
@Composable
|
||||
fun SetupClient()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun init() {
|
||||
classes.addAll(mediaManager.listVideoKlasses())
|
||||
var i = 0
|
||||
for(it in classes)
|
||||
{
|
||||
updatingMap[i++] = false
|
||||
classesMap[it] = mutableStateListOf<Video>()
|
||||
}
|
||||
updatingMap[0] = true
|
||||
val vl = mediaManager.queryVideoBulk(classes[0], mediaManager.queryVideoKlasses(classes[0]))
|
||||
fetchManager.configured.filter { it }.first()
|
||||
|
||||
if(vl != null){
|
||||
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
|
||||
classesMap[classes[0]]?.addAll(r)
|
||||
if (Global.loggedIn) {
|
||||
videoLibrary.classes.addAll(mediaManager.listVideoKlasses())
|
||||
var i = 0
|
||||
for (it in videoLibrary.classes) {
|
||||
videoLibrary.updatingMap[i++] = false
|
||||
videoLibrary.classesMap[it] = mutableStateListOf<Video>()
|
||||
}
|
||||
videoLibrary.updatingMap[0] = true
|
||||
val vl =
|
||||
mediaManager.queryVideoBulk(videoLibrary.classes[0], mediaManager.queryVideoKlasses(videoLibrary.classes[0]))
|
||||
|
||||
if (vl != null) {
|
||||
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
|
||||
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r)
|
||||
}
|
||||
} else {
|
||||
videoLibrary.classes.add("Offline")
|
||||
videoLibrary.updatingMap[0] = true
|
||||
videoLibrary.classesMap["Offline"] = mutableStateListOf<Video>()
|
||||
|
||||
val downloaded = fetchManager.getAllDownloadsAsync().filter {
|
||||
it.status == Status.COMPLETED && it.extras.getString("isComic", "") != "true"
|
||||
}
|
||||
|
||||
val jsonQuery = downloaded.map{ File(
|
||||
context.getExternalFilesDir(null),
|
||||
"videos/${it.extras.getString("class", "")}/${it.extras.getString("id", "")}/summary.json").readText() }
|
||||
.map { Json.decodeFromString<Video>(it).toLocal(context.getExternalFilesDir(null)!!.path) }
|
||||
|
||||
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(jsonQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTabIndex(index: Int)
|
||||
{
|
||||
fun setTabIndex(index: Int) {
|
||||
viewModelScope.launch()
|
||||
{
|
||||
_tabIndex.intValue = index;
|
||||
if(updatingMap[index] == true) return@launch
|
||||
if (videoLibrary.updatingMap[index] == true) return@launch
|
||||
|
||||
updatingMap[index] = true
|
||||
videoLibrary.updatingMap[index] = true
|
||||
|
||||
val vl = mediaManager.queryVideoBulk(classes[index], mediaManager.queryVideoKlasses(classes[index]))
|
||||
val vl = mediaManager.queryVideoBulk(
|
||||
videoLibrary.classes[index],
|
||||
mediaManager.queryVideoKlasses(videoLibrary.classes[index])
|
||||
)
|
||||
|
||||
if(vl != null){
|
||||
if (vl != null) {
|
||||
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
|
||||
classesMap[classes[index]]?.addAll(r)
|
||||
videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun download(video :Video)
|
||||
{
|
||||
suspend fun download(video: Video) {
|
||||
fetchManager.startVideoDownload(video)
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||
}
|
||||
.build()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
init()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user