优化
This commit is contained in:
@@ -146,6 +146,7 @@ object PreferKey {
|
||||
const val recordHeapDump = "recordHeapDump"
|
||||
const val optimizeRender = "optimizeRender"
|
||||
const val updateToVariant = "updateToVariant"
|
||||
const val streamReadAloudAudio = "streamReadAloudAudio"
|
||||
|
||||
const val cPrimary = "colorPrimary"
|
||||
const val cAccent = "colorAccent"
|
||||
|
||||
@@ -473,6 +473,8 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
val updateToVariant get() = appCtx.getPrefString(PreferKey.updateToVariant, "default_version")
|
||||
|
||||
val streamReadAloudAudio get() = appCtx.getPrefBoolean(PreferKey.streamReadAloudAudio, false)
|
||||
|
||||
val doublePageHorizontal: String?
|
||||
get() = appCtx.getPrefString(PreferKey.doublePageHorizontal)
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package io.legado.app.help.exoplayer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.datasource.BaseDataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class InputStreamDataSource(private val supplier: () -> InputStream) : BaseDataSource(false) {
|
||||
private var dataSpec: DataSpec? = null
|
||||
private var bytesRemaining: Long = 0
|
||||
private var opened = false
|
||||
private val inputStream by lazy {
|
||||
supplier.invoke()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
this.dataSpec = dataSpec
|
||||
transferInitializing(dataSpec)
|
||||
|
||||
inputStream.skip(dataSpec.position)
|
||||
|
||||
if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
|
||||
bytesRemaining = inputStream.available().toLong()
|
||||
if (bytesRemaining == 0L) bytesRemaining = C.LENGTH_UNSET.toLong()
|
||||
} else {
|
||||
bytesRemaining = dataSpec.length
|
||||
}
|
||||
|
||||
opened = true
|
||||
transferStarted(dataSpec)
|
||||
return bytesRemaining
|
||||
}
|
||||
|
||||
override fun getUri(): Uri? = dataSpec?.uri
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||
if (readLength == 0) {
|
||||
return 0
|
||||
} else if (bytesRemaining == 0L) {
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
|
||||
val bytesToRead =
|
||||
if (bytesRemaining == C.LENGTH_UNSET.toLong()) readLength
|
||||
else min(bytesRemaining, readLength.toLong()).toInt()
|
||||
|
||||
val bytesRead = inputStream.read(buffer, offset, bytesToRead)
|
||||
|
||||
if (bytesRead == -1) {
|
||||
if (bytesRemaining != C.LENGTH_UNSET.toLong()) {
|
||||
// End of stream reached having not read sufficient data.
|
||||
throw EOFException()
|
||||
}
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
|
||||
if (bytesRemaining != C.LENGTH_UNSET.toLong()) {
|
||||
bytesRemaining -= bytesRead.toLong()
|
||||
bytesTransferred(bytesRead)
|
||||
}
|
||||
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
inputStream.close()
|
||||
} finally {
|
||||
opened = false
|
||||
transferEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ abstract class BaseReadAloudService : BaseService(),
|
||||
var pageChanged = false
|
||||
private var toLast = false
|
||||
var paragraphStartPos = 0
|
||||
private var readAloudByPage = false
|
||||
var readAloudByPage = false
|
||||
private set
|
||||
|
||||
private val broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
package io.legado.app.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSink
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.offline.DefaultDownloaderFactory
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import androidx.media3.exoplayer.offline.Downloader
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import com.script.ScriptException
|
||||
import io.legado.app.R
|
||||
import io.legado.app.constant.AppLog
|
||||
@@ -15,6 +30,8 @@ import io.legado.app.data.entities.HttpTTS
|
||||
import io.legado.app.exception.NoStackTraceException
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.help.coroutine.Coroutine
|
||||
import io.legado.app.help.exoplayer.InputStreamDataSource
|
||||
import io.legado.app.help.http.okHttpClient
|
||||
import io.legado.app.model.ReadAloud
|
||||
import io.legado.app.model.ReadBook
|
||||
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
||||
@@ -26,13 +43,16 @@ import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.Response
|
||||
import org.mozilla.javascript.WrappedException
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.ConnectException
|
||||
@@ -42,6 +62,7 @@ import kotlin.coroutines.coroutineContext
|
||||
/**
|
||||
* 在线朗读
|
||||
*/
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class HttpReadAloudService : BaseReadAloudService(),
|
||||
Player.Listener {
|
||||
|
||||
@@ -51,6 +72,24 @@ class HttpReadAloudService : BaseReadAloudService(),
|
||||
private val ttsFolderPath: String by lazy {
|
||||
cacheDir.absolutePath + File.separator + "httpTTS" + File.separator
|
||||
}
|
||||
private val cache by lazy {
|
||||
SimpleCache(
|
||||
File(cacheDir, "httpTTS_cache"),
|
||||
LeastRecentlyUsedCacheEvictor((50 * 1024 * 1024).toLong()),
|
||||
StandaloneDatabaseProvider(appCtx)
|
||||
)
|
||||
}
|
||||
private val cacheDataSinkFactory by lazy {
|
||||
CacheDataSink.Factory()
|
||||
.setCache(cache)
|
||||
}
|
||||
private val loadErrorHandlingPolicy by lazy {
|
||||
object : DefaultLoadErrorHandlingPolicy(0) {
|
||||
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
|
||||
return C.TIME_UNSET
|
||||
}
|
||||
}
|
||||
}
|
||||
private var speechRate: Int = AppConfig.speechRatePlay + 5
|
||||
private var downloadTask: Coroutine<*>? = null
|
||||
private var playIndexJob: Job? = null
|
||||
@@ -67,6 +106,7 @@ class HttpReadAloudService : BaseReadAloudService(),
|
||||
super.onDestroy()
|
||||
downloadTask?.cancel()
|
||||
exoPlayer.release()
|
||||
cache.release()
|
||||
Coroutine.async {
|
||||
removeCacheFile()
|
||||
}
|
||||
@@ -81,9 +121,13 @@ class HttpReadAloudService : BaseReadAloudService(),
|
||||
ReadBook.readAloud()
|
||||
} else {
|
||||
super.play()
|
||||
if (AppConfig.streamReadAloudAudio) {
|
||||
downloadAndPlayAudiosStream()
|
||||
} else {
|
||||
downloadAndPlayAudios()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun playStop() {
|
||||
exoPlayer.stop()
|
||||
@@ -149,12 +193,150 @@ class HttpReadAloudService : BaseReadAloudService(),
|
||||
}
|
||||
}
|
||||
}
|
||||
preDownloadAudios(httpTts)
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun preDownloadAudios(httpTts: HttpTTS) {
|
||||
val textChapter = ReadBook.nextTextChapter ?: return
|
||||
val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
|
||||
.splitToSequence("\n")
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(10)
|
||||
.toList()
|
||||
contentList.forEach { content ->
|
||||
coroutineContext.ensureActive()
|
||||
val fileName = md5SpeakFileName(content)
|
||||
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
|
||||
if (speakText.isEmpty()) {
|
||||
createSilentSound(fileName)
|
||||
} else if (!hasSpeakFile(fileName)) {
|
||||
runCatching {
|
||||
val inputStream = getSpeakStream(httpTts, speakText)
|
||||
if (inputStream != null) {
|
||||
createSpeakFile(fileName, inputStream)
|
||||
} else {
|
||||
createSilentSound(fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAndPlayAudiosStream() {
|
||||
exoPlayer.clearMediaItems()
|
||||
downloadTask?.cancel()
|
||||
downloadTask = execute {
|
||||
downloadTaskActiveLock.withLock {
|
||||
ensureActive()
|
||||
val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
|
||||
val downloaderChannel = Channel<Downloader>()
|
||||
launch {
|
||||
for (downloader in downloaderChannel) {
|
||||
downloader.download(null)
|
||||
}
|
||||
}
|
||||
contentList.forEachIndexed { index, content ->
|
||||
ensureActive()
|
||||
if (index < nowSpeak) return@forEachIndexed
|
||||
var text = content
|
||||
if (paragraphStartPos > 0 && index == nowSpeak) {
|
||||
text = text.substring(paragraphStartPos)
|
||||
}
|
||||
val speakText = text.replace(AppPattern.notReadAloudRegex, "")
|
||||
if (speakText.isEmpty()) {
|
||||
AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$speakText")
|
||||
}
|
||||
val fileName = md5SpeakFileName(text)
|
||||
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
|
||||
val downloader = createDownloader(dataSourceFactory, fileName)
|
||||
downloaderChannel.send(downloader)
|
||||
val mediaSource = createMediaSource(dataSourceFactory, fileName)
|
||||
launch(Main) {
|
||||
if (exoPlayer.playbackState == Player.STATE_ENDED) {
|
||||
exoPlayer.stop()
|
||||
exoPlayer.clearMediaItems()
|
||||
}
|
||||
exoPlayer.addMediaSource(mediaSource)
|
||||
if (!exoPlayer.isPlaying) {
|
||||
exoPlayer.playWhenReady = !pause
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
preDownloadAudiosStream(httpTts, downloaderChannel)
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun preDownloadAudiosStream(
|
||||
httpTts: HttpTTS,
|
||||
downloaderChannel: Channel<Downloader>
|
||||
) {
|
||||
val textChapter = ReadBook.nextTextChapter ?: return
|
||||
val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
|
||||
.splitToSequence("\n")
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(10)
|
||||
.toList()
|
||||
contentList.forEach { content ->
|
||||
coroutineContext.ensureActive()
|
||||
val fileName = md5SpeakFileName(content)
|
||||
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
|
||||
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
|
||||
val downloader = createDownloader(dataSourceFactory, fileName)
|
||||
downloaderChannel.send(downloader)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDataSourceFactory(
|
||||
httpTts: HttpTTS,
|
||||
speakText: String
|
||||
): CacheDataSource.Factory {
|
||||
val upstreamFactory = DataSource.Factory {
|
||||
InputStreamDataSource {
|
||||
if (speakText.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
kotlin.runCatching {
|
||||
runBlocking {
|
||||
getSpeakStream(httpTts, speakText)
|
||||
}
|
||||
}.onFailure {
|
||||
when (it) {
|
||||
is InterruptedException -> Unit
|
||||
else -> pauseReadAloud()
|
||||
}
|
||||
}.getOrThrow()
|
||||
} ?: resources.openRawResource(R.raw.silent_sound)
|
||||
}
|
||||
}
|
||||
val factory = CacheDataSource.Factory()
|
||||
.setCache(cache)
|
||||
.setUpstreamDataSourceFactory(upstreamFactory)
|
||||
.setCacheWriteDataSinkFactory(cacheDataSinkFactory)
|
||||
return factory
|
||||
}
|
||||
|
||||
private fun createDownloader(factory: CacheDataSource.Factory, fileName: String): Downloader {
|
||||
val uri = Uri.parse(fileName)
|
||||
val request = DownloadRequest.Builder(fileName, uri).build()
|
||||
return DefaultDownloaderFactory(factory, okHttpClient.dispatcher.executorService)
|
||||
.createDownloader(request)
|
||||
}
|
||||
|
||||
private fun createMediaSource(factory: DataSource.Factory, fileName: String): MediaSource {
|
||||
return DefaultMediaSourceFactory(this)
|
||||
.setDataSourceFactory(factory)
|
||||
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
.createMediaSource(MediaItem.fromUri(fileName))
|
||||
}
|
||||
|
||||
private suspend fun getSpeakStream(
|
||||
httpTts: HttpTTS,
|
||||
speakText: String
|
||||
@@ -329,8 +511,12 @@ class HttpReadAloudService : BaseReadAloudService(),
|
||||
downloadTask?.cancel()
|
||||
exoPlayer.stop()
|
||||
speechRate = AppConfig.speechRatePlay + 5
|
||||
if (AppConfig.streamReadAloudAudio) {
|
||||
downloadAndPlayAudiosStream()
|
||||
} else {
|
||||
downloadAndPlayAudios()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
@@ -20,7 +20,13 @@ import io.legado.app.lib.theme.backgroundColor
|
||||
import io.legado.app.lib.theme.primaryColor
|
||||
import io.legado.app.model.ReadAloud
|
||||
import io.legado.app.service.BaseReadAloudService
|
||||
import io.legado.app.utils.*
|
||||
import io.legado.app.utils.GSON
|
||||
import io.legado.app.utils.StringUtils
|
||||
import io.legado.app.utils.fromJsonObject
|
||||
import io.legado.app.utils.postEvent
|
||||
import io.legado.app.utils.setEdgeEffectColor
|
||||
import io.legado.app.utils.setLayout
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
|
||||
class ReadAloudConfigDialog : DialogFragment() {
|
||||
private val readAloudPreferTag = "readAloudPreferTag"
|
||||
@@ -103,7 +109,7 @@ class ReadAloudConfigDialog : DialogFragment() {
|
||||
key: String?
|
||||
) {
|
||||
when (key) {
|
||||
PreferKey.readAloudByPage -> {
|
||||
PreferKey.readAloudByPage, PreferKey.streamReadAloudAudio -> {
|
||||
if (BaseReadAloudService.isRun) {
|
||||
postEvent(EventBus.MEDIA_BUTTON, false)
|
||||
}
|
||||
@@ -117,6 +123,7 @@ class ReadAloudConfigDialog : DialogFragment() {
|
||||
val index = preference.findIndexOfValue(value)
|
||||
preference.summary = if (index >= 0) preference.entries[index] else null
|
||||
}
|
||||
|
||||
else -> {
|
||||
preference?.summary = value
|
||||
}
|
||||
|
||||
@@ -177,10 +177,15 @@ data class TextChapter(
|
||||
* @param pageSplit 是否分页
|
||||
* @param startPos 从当前页什么地方开始朗读
|
||||
*/
|
||||
fun getNeedReadAloud(pageIndex: Int, pageSplit: Boolean, startPos: Int): String {
|
||||
fun getNeedReadAloud(
|
||||
pageIndex: Int,
|
||||
pageSplit: Boolean,
|
||||
startPos: Int,
|
||||
pageEndIndex: Int = pages.lastIndex
|
||||
): String {
|
||||
val stringBuilder = StringBuilder()
|
||||
if (pages.isNotEmpty()) {
|
||||
for (index in pageIndex..pages.lastIndex) {
|
||||
for (index in pageIndex..min(pageEndIndex, pages.lastIndex)) {
|
||||
stringBuilder.append(pages[index].text)
|
||||
if (pageSplit && !stringBuilder.endsWith("\n")) {
|
||||
stringBuilder.append("\n")
|
||||
|
||||
@@ -1159,4 +1159,6 @@
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -1162,4 +1162,6 @@
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -1162,4 +1162,6 @@
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -1158,4 +1158,6 @@ Còn </string>
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -1159,4 +1159,6 @@
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -1161,4 +1161,6 @@
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -1161,4 +1161,6 @@
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -1162,4 +1162,6 @@
|
||||
<string name="official_version">正式版</string>
|
||||
<string name="beta_release_version">测试版</string>
|
||||
<string name="beta_releaseA_version">共存版</string>
|
||||
<string name="stream_read_aloud_audio">流式播放音频</string>
|
||||
<string name="stream_read_aloud_audio_summary">即边下边播,网络不好时播放会断断续续,仅TTS源有效</string>
|
||||
</resources>
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
android:key="readAloudByPage"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<io.legado.app.lib.prefs.SwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:title="@string/stream_read_aloud_audio"
|
||||
android:summary="@string/stream_read_aloud_audio_summary"
|
||||
android:key="streamReadAloudAudio"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<io.legado.app.lib.prefs.Preference
|
||||
android:title="@string/speak_engine"
|
||||
android:summary="TTS"
|
||||
|
||||
Reference in New Issue
Block a user