feat: import update
This commit is contained in:
parent
efb18fc747
commit
390c2244e7
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -47,8 +47,8 @@ android {
|
|||
applicationId "net.schueller.peertube"
|
||||
minSdk 21
|
||||
targetSdk 32
|
||||
versionCode 1076
|
||||
versionName "1.10.4"
|
||||
versionCode 1077
|
||||
versionName "1.0.0"
|
||||
buildConfigField "long", "BUILD_TIME", readPropertyWithDefault('buildTimestamp', System.currentTimeMillis()) + 'L'
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
@ -136,7 +136,8 @@ dependencies {
|
|||
|
||||
// Compose dependencies
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4"
|
||||
implementation "androidx.navigation:navigation-compose:2.4.0-rc01"
|
||||
implementation "androidx.navigation:navigation-compose:2.5.0-alpha01"
|
||||
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0"
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:0.17.0"
|
||||
|
||||
// Paging Compose
|
||||
|
|
|
@ -10,6 +10,9 @@ object Constants {
|
|||
const val PREF_VIDEO_SPEED_KEY = "pref_video_speed"
|
||||
const val PREF_BACK_PAUSE_KEY = "pref_back_pause"
|
||||
const val PREF_BACKGROUND_BEHAVIOR_KEY = "pref_background_behavior"
|
||||
const val PREF_BACKGROUND_AUDIO_KEY = "backgroundAudio"
|
||||
const val PREF_BACKGROUND_STOP_KEY = "backgroundStop"
|
||||
const val PREF_BACKGROUND_FLOAT_KEY = "backgroundFloat"
|
||||
const val PREF_TORRENT_PLAYER_KEY = "pref_torrent_player"
|
||||
const val PREF_ACCEPT_INSECURE_KEY = "pref_accept_insecure"
|
||||
const val PREF_CLEAR_HISTORY_KEY = "pref_clear_history"
|
||||
|
@ -25,22 +28,19 @@ object Constants {
|
|||
const val PREF_API_BASE_KEY = "pref_api_base_key"
|
||||
const val PREF_QUALITY_KEY = "pref_quality_key"
|
||||
|
||||
const val PARAM_QUERY_START = "start"
|
||||
const val PARAM_QUERY_COUNT = "count"
|
||||
const val PARAM_QUERY_SORT = "sort"
|
||||
const val PARAM_QUERY_NSFW = "nsfw"
|
||||
const val PARAM_QUERY_FILTER = "filter"
|
||||
const val PARAM_QUERY_LANGUAGES = "languages"
|
||||
|
||||
const val BASE_URL = "https://troll.tv/api/v1/"
|
||||
const val BASE_IMAGE_URL = "https://troll.tv"
|
||||
|
||||
const val FALLBACK_BASE_URL = "https://troll.tv" // Thorium test peertube server
|
||||
const val SERVER_IDX_BASE_URL = "https://instances.joinpeertube.org/api/v1/"
|
||||
|
||||
const val INVALID_URL_PLACEHOLDER = "http://invalid"
|
||||
const val VIDEO_SHARE_URI_PATH = "/videos/watch/"
|
||||
const val PEERTUBE_API_PATH = "/api/v1/"
|
||||
|
||||
const val VIDEOS_API_PAGE_SIZE = 25
|
||||
const val SERVERS_API_PAGE_SIZE = 25
|
||||
const val VIDEOS_API_START_INDEX = 0
|
||||
const val SERVERS_API_START_INDEX = 0
|
||||
const val PARAM_VIDEO_UUID = "uuid"
|
||||
|
||||
const val APP_BACKGROUND_AUDIO_INTENT = "BACKGROUND_AUDIO"
|
||||
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
package net.schueller.peertube.common
|
||||
|
||||
import android.content.Context
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import android.webkit.URLUtil
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.schueller.peertube.common.Constants.INVALID_URL_PLACEHOLDER
|
||||
import net.schueller.peertube.common.Constants.PEERTUBE_API_PATH
|
||||
import net.schueller.peertube.common.Constants.PREF_API_BASE_KEY
|
||||
import net.schueller.peertube.common.Constants.VIDEO_SHARE_URI_PATH
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
||||
@Singleton
|
||||
|
@ -14,20 +17,27 @@ class UrlHelper @Inject constructor(
|
|||
) {
|
||||
private val sharedPreferences = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
fun getUrl(): String? {
|
||||
// Get currently set baseUrl
|
||||
private fun getBaseUrl(): String? {
|
||||
|
||||
// validate URL is valid
|
||||
val url = sharedPreferences.getString(PREF_API_BASE_KEY, "TODO")
|
||||
val url = sharedPreferences.getString(PREF_API_BASE_KEY, Constants.FALLBACK_BASE_URL)
|
||||
return if (!URLUtil.isValidUrl(url)) {
|
||||
"http://invalid"
|
||||
INVALID_URL_PLACEHOLDER
|
||||
} else url
|
||||
}
|
||||
|
||||
//
|
||||
fun getShareUrl(videoUuid: String): String {
|
||||
return getUrl().toString() + "/videos/watch/" + videoUuid
|
||||
return getBaseUrl().toString() + VIDEO_SHARE_URI_PATH + videoUuid
|
||||
}
|
||||
|
||||
// all servers currently have the same version prefix
|
||||
fun getUrlWithVersion(): String {
|
||||
return getBaseUrl() + PEERTUBE_API_PATH
|
||||
}
|
||||
|
||||
// remove bad characters and add protocol to a server address
|
||||
fun cleanServerUrl(url: String?): String {
|
||||
if (url != null) {
|
||||
var cleanUrl = url.lowercase()
|
||||
|
|
|
@ -3,9 +3,27 @@ package net.schueller.peertube.common
|
|||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import android.app.AppOpsManager
|
||||
|
||||
import android.os.Build
|
||||
|
||||
import android.R
|
||||
import android.content.Context
|
||||
|
||||
import android.preference.PreferenceManager
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Process
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_BEHAVIOR_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_FLOAT_KEY
|
||||
|
||||
|
||||
@Singleton
|
||||
class VideoHelper @Inject constructor() {
|
||||
class VideoHelper @Inject constructor(
|
||||
) {
|
||||
|
||||
|
||||
|
||||
fun pickPlaybackResolution(video: Video, preferredQuality: Int = 999999): String?
|
||||
{
|
||||
|
@ -27,5 +45,33 @@ class VideoHelper @Inject constructor() {
|
|||
|
||||
return urlToPlay
|
||||
}
|
||||
|
||||
|
||||
fun canEnterPipMode(context: Context): Boolean {
|
||||
val sharedPreferences = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
// pref is disabled
|
||||
if (!PREF_BACKGROUND_FLOAT_KEY.equals(
|
||||
sharedPreferences.getString(
|
||||
PREF_BACKGROUND_BEHAVIOR_KEY,
|
||||
PREF_BACKGROUND_FLOAT_KEY
|
||||
)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// api does not support it
|
||||
if (Build.VERSION.SDK_INT > 27) {
|
||||
val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||
return AppOpsManager.MODE_ALLOWED == appOpsManager.checkOp(
|
||||
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
|
||||
Process.myUid(),
|
||||
context.packageName
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@ import dagger.hilt.InstallIn
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import net.schueller.peertube.common.Constants
|
||||
import net.schueller.peertube.common.UrlHelper
|
||||
import net.schueller.peertube.feature_server_address.data.data_source.database.ServerAddressDatabase
|
||||
import net.schueller.peertube.feature_video.data.remote.PeerTubeApi
|
||||
import net.schueller.peertube.feature_server_address.data.data_source.remote.ServerInstanceApi
|
||||
|
@ -53,31 +52,24 @@ object AppModule {
|
|||
repository: ServerAddressRepository,
|
||||
@ApplicationContext context: Context,
|
||||
session: Session,
|
||||
loginService: LoginService
|
||||
loginService: LoginService,
|
||||
retrofitInstance: RetrofitInstance
|
||||
): ServerAddressUseCases {
|
||||
return ServerAddressUseCases(
|
||||
getServerAddresses = GetServerAddresses(repository),
|
||||
deleteServerAddress = DeleteServerAddress(repository),
|
||||
addServerAddress = AddServerAddress(repository),
|
||||
getServerAddress = GetServerAddress(repository),
|
||||
selectServerAddress = SelectServerAddress(context,session,loginService)
|
||||
getServerAddressesUseCase = GetServerAddressesUseCase(repository),
|
||||
deleteServerAddressUseCase = DeleteServerAddressUseCase(repository),
|
||||
addServerAddressUseCase = AddServerAddressUseCase(repository),
|
||||
getServerAddressUseCase = GetServerAddressUseCase(repository),
|
||||
selectServerAddressUseCase = SelectServerAddressUseCase(context,session,loginService, retrofitInstance)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePeerTubeApi(
|
||||
retrofitInstance: RetrofitInstance
|
||||
): PeerTubeApi {
|
||||
|
||||
return retrofitInstance.getRetrofitInstance()
|
||||
|
||||
// return Retrofit.Builder()
|
||||
// .baseUrl(Constants.BASE_URL)
|
||||
// .addConverterFactory(GsonConverterFactory.create())
|
||||
// .build()
|
||||
// .create(PeerTubeApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
package net.schueller.peertube.feature_server_address.domain.use_case
|
||||
|
||||
import net.schueller.peertube.feature_server_address.domain.model.InvalidServerAddressException
|
||||
import net.schueller.peertube.feature_server_address.domain.model.ServerAddress
|
||||
import net.schueller.peertube.feature_server_address.domain.repository.ServerAddressRepository
|
||||
|
||||
class AddServerAddressUseCase(
|
||||
private val repository: ServerAddressRepository
|
||||
) {
|
||||
|
||||
@Throws(InvalidServerAddressException::class)
|
||||
suspend operator fun invoke(serverAddress: ServerAddress) {
|
||||
if(serverAddress.serverName.isBlank()) {
|
||||
throw InvalidServerAddressException("Server Name is required")
|
||||
}
|
||||
|
||||
repository.insertServerAddress(serverAddress)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package net.schueller.peertube.feature_server_address.domain.use_case
|
||||
|
||||
import net.schueller.peertube.feature_server_address.domain.model.ServerAddress
|
||||
import net.schueller.peertube.feature_server_address.domain.repository.ServerAddressRepository
|
||||
|
||||
class DeleteServerAddressUseCase(
|
||||
private val repository: ServerAddressRepository
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(serverAddress: ServerAddress) {
|
||||
repository.deleteServerAddress(serverAddress)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package net.schueller.peertube.feature_server_address.domain.use_case
|
||||
|
||||
import net.schueller.peertube.feature_server_address.domain.model.ServerAddress
|
||||
import net.schueller.peertube.feature_server_address.domain.repository.ServerAddressRepository
|
||||
|
||||
class GetServerAddressUseCase(
|
||||
private val repository: ServerAddressRepository
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(id: Int): ServerAddress? {
|
||||
return repository.getServerAddressById(id)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package net.schueller.peertube.feature_server_address.domain.use_case
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import net.schueller.peertube.feature_server_address.domain.model.ServerAddress
|
||||
import net.schueller.peertube.feature_server_address.domain.repository.ServerAddressRepository
|
||||
import net.schueller.peertube.feature_server_address.domain.util.OrderType
|
||||
import net.schueller.peertube.feature_server_address.domain.util.ServerAddressOrder
|
||||
|
||||
class GetServerAddressesUseCase(
|
||||
private val repository: ServerAddressRepository
|
||||
) {
|
||||
|
||||
operator fun invoke(
|
||||
serverAddressOrder: ServerAddressOrder = ServerAddressOrder.Title(OrderType.Descending)
|
||||
): Flow<List<ServerAddress>> {
|
||||
return repository.getServerAddresses().map { serverAddresses ->
|
||||
when(serverAddressOrder.orderType) {
|
||||
is OrderType.Ascending -> {
|
||||
when(serverAddressOrder) {
|
||||
is ServerAddressOrder.Title -> serverAddresses.sortedBy { it.serverName.lowercase() }
|
||||
is ServerAddressOrder.Host -> serverAddresses.sortedBy { it.serverHost?.lowercase() }
|
||||
}
|
||||
}
|
||||
is OrderType.Descending -> {
|
||||
when(serverAddressOrder) {
|
||||
is ServerAddressOrder.Title -> serverAddresses.sortedByDescending { it.serverName.lowercase() }
|
||||
is ServerAddressOrder.Host -> serverAddresses.sortedByDescending { it.serverHost?.lowercase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package net.schueller.peertube.feature_server_address.domain.use_case
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.schueller.peertube.common.Constants.PREF_API_BASE_KEY
|
||||
import net.schueller.peertube.feature_server_address.domain.model.ServerAddress
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.LoginService
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.Session
|
||||
import net.schueller.peertube.feature_video.data.repository.RetrofitInstance
|
||||
|
||||
|
||||
class SelectServerAddressUseCase (
|
||||
@ApplicationContext private val context: Context,
|
||||
private val session: Session,
|
||||
private val loginService: LoginService,
|
||||
private val retrofitInstance: RetrofitInstance
|
||||
) {
|
||||
private val sharedPreferences = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
operator fun invoke(serverAddress: ServerAddress) {
|
||||
|
||||
Log.v("SSA", "Server: " + serverAddress.serverHost)
|
||||
|
||||
// save new server to pref
|
||||
val editor = sharedPreferences.edit()
|
||||
editor.putString(PREF_API_BASE_KEY, serverAddress.serverHost)
|
||||
editor.apply()
|
||||
|
||||
// reload Retrofit
|
||||
retrofitInstance.updateRetrofitInstance()
|
||||
|
||||
// invalidate session
|
||||
if (session.isLoggedIn()) {
|
||||
Log.v("SSA", "session invalidate")
|
||||
session.invalidate()
|
||||
}
|
||||
|
||||
// attempt auth if we have username
|
||||
if (serverAddress.username.isNullOrBlank().not()) {
|
||||
Log.v("SSA", "Attempt auth")
|
||||
loginService.authenticate(serverAddress.username, serverAddress.password)
|
||||
}
|
||||
|
||||
// TODO: notify views that this has changed
|
||||
|
||||
}
|
||||
}
|
|
@ -1,15 +1,9 @@
|
|||
package net.schueller.peertube.feature_server_address.domain.use_case
|
||||
|
||||
import net.schueller.peertube.feature_server_address.domain.use_case.AddServerAddress
|
||||
import net.schueller.peertube.feature_server_address.domain.use_case.DeleteServerAddress
|
||||
import net.schueller.peertube.feature_server_address.domain.use_case.GetServerAddress
|
||||
import net.schueller.peertube.feature_server_address.domain.use_case.GetServerAddresses
|
||||
import net.schueller.peertube.feature_server_address.domain.use_case.SelectServerAddress
|
||||
|
||||
data class ServerAddressUseCases(
|
||||
val getServerAddresses: GetServerAddresses,
|
||||
val deleteServerAddress: DeleteServerAddress,
|
||||
val addServerAddress: AddServerAddress,
|
||||
val getServerAddress: GetServerAddress,
|
||||
val selectServerAddress: SelectServerAddress
|
||||
val getServerAddressesUseCase: GetServerAddressesUseCase,
|
||||
val deleteServerAddressUseCase: DeleteServerAddressUseCase,
|
||||
val addServerAddressUseCase: AddServerAddressUseCase,
|
||||
val getServerAddressUseCase: GetServerAddressUseCase,
|
||||
val selectServerAddressUseCase: SelectServerAddressUseCase
|
||||
)
|
|
@ -45,7 +45,7 @@ class AddEditAddressViewModel @Inject constructor(
|
|||
savedStateHandle.get<Int>("serverAddressId")?.let { serverAddressId ->
|
||||
if(serverAddressId != -1) {
|
||||
viewModelScope.launch {
|
||||
serverAddressUseCases.getServerAddress(serverAddressId)?.also { serverAddress ->
|
||||
serverAddressUseCases.getServerAddressUseCase(serverAddressId)?.also { serverAddress ->
|
||||
currentServerAddressId = serverAddress.id
|
||||
_serverName.value = serverName.value.copy(
|
||||
text = serverAddress.serverName
|
||||
|
@ -125,7 +125,7 @@ class AddEditAddressViewModel @Inject constructor(
|
|||
is AddEditAddressEvent.SaveServerAddress -> {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
serverAddressUseCases.addServerAddress(
|
||||
serverAddressUseCases.addServerAddressUseCase(
|
||||
ServerAddress(
|
||||
serverName = serverName.value.text,
|
||||
serverHost = serverHost.value.text,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package net.schueller.peertube.feature_server_address.presentation.address_list
|
||||
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
@ -18,7 +17,6 @@ import net.schueller.peertube.feature_server_address.domain.model.ServerAddress
|
|||
import net.schueller.peertube.feature_server_address.domain.use_case.ServerAddressUseCases
|
||||
import net.schueller.peertube.feature_server_address.domain.util.OrderType
|
||||
import net.schueller.peertube.feature_server_address.domain.util.ServerAddressOrder
|
||||
import net.schueller.peertube.feature_server_address.presentation.address_add_edit.AddEditAddressViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -44,14 +42,14 @@ class AddressListViewModel @Inject constructor(
|
|||
when (event) {
|
||||
is AddressListEvent.DeleteServerAddress -> {
|
||||
viewModelScope.launch {
|
||||
serverAddressUseCases.deleteServerAddress(event.serverAddress)
|
||||
serverAddressUseCases.deleteServerAddressUseCase(event.serverAddress)
|
||||
recentlyDeletedServerAddress = event.serverAddress
|
||||
}
|
||||
}
|
||||
is AddressListEvent.SelectServerAddress -> {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
serverAddressUseCases.selectServerAddress(
|
||||
serverAddressUseCases.selectServerAddressUseCase(
|
||||
event.serverAddress
|
||||
)
|
||||
_eventFlow.emit(UiEvent.SelectServerAddress)
|
||||
|
@ -66,7 +64,7 @@ class AddressListViewModel @Inject constructor(
|
|||
}
|
||||
is AddressListEvent.RestoreServerAddress -> {
|
||||
viewModelScope.launch {
|
||||
serverAddressUseCases.addServerAddress(recentlyDeletedServerAddress ?: return@launch)
|
||||
serverAddressUseCases.addServerAddressUseCase(recentlyDeletedServerAddress ?: return@launch)
|
||||
recentlyDeletedServerAddress = null
|
||||
}
|
||||
}
|
||||
|
@ -78,7 +76,7 @@ class AddressListViewModel @Inject constructor(
|
|||
|
||||
private fun getServerAddresses(serverAddressOrder: ServerAddressOrder) {
|
||||
getServerAddressesJob?.cancel()
|
||||
getServerAddressesJob = serverAddressUseCases.getServerAddresses(serverAddressOrder)
|
||||
getServerAddressesJob = serverAddressUseCases.getServerAddressesUseCase(serverAddressOrder)
|
||||
.onEach { serverAddresses ->
|
||||
_state.value = state.value.copy(
|
||||
serverAddresses = serverAddresses,
|
||||
|
|
|
@ -12,6 +12,16 @@ import com.jamal.composeprefs.ui.PrefsScreen
|
|||
import com.jamal.composeprefs.ui.prefs.*
|
||||
import net.schueller.peertube.presentation.dataStore
|
||||
import net.schueller.peertube.R
|
||||
import net.schueller.peertube.common.Constants.PREF_ACCEPT_INSECURE_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_AUDIO_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_BEHAVIOR_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_FLOAT_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_STOP_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACK_PAUSE_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_DARK_MODE_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_SHOW_NSFW_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_THEME_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_VIDEO_SPEED_KEY
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@ExperimentalComposeUiApi
|
||||
|
@ -231,7 +241,7 @@ fun SettingsScreen() {
|
|||
}
|
||||
prefsItem {
|
||||
ListPref(
|
||||
key = "pref_theme_key",
|
||||
key = PREF_THEME_KEY,
|
||||
title = stringResource(R.string.pref_title_app_theme),
|
||||
useSelectedAsSummary = true,
|
||||
entries = mapOf(
|
||||
|
@ -243,7 +253,7 @@ fun SettingsScreen() {
|
|||
}
|
||||
prefsItem {
|
||||
SwitchPref(
|
||||
key = "pref_dark_mode_key",
|
||||
key = PREF_DARK_MODE_KEY,
|
||||
title = stringResource(R.string.pref_title_dark_mode),
|
||||
summary = stringResource(R.string.pref_description_dark_mode)
|
||||
)
|
||||
|
@ -259,7 +269,7 @@ fun SettingsScreen() {
|
|||
}) {
|
||||
prefsItem {
|
||||
SwitchPref(
|
||||
key = "pref_show_nsfw_key",
|
||||
key = PREF_SHOW_NSFW_KEY,
|
||||
title = stringResource(R.string.pref_title_show_nsfw),
|
||||
summary = stringResource(R.string.pref_description_show_nsfw)
|
||||
)
|
||||
|
@ -307,34 +317,35 @@ fun SettingsScreen() {
|
|||
}) {
|
||||
prefsItem {
|
||||
ListPref(
|
||||
key = "pref_video_speed_key",
|
||||
key = PREF_VIDEO_SPEED_KEY,
|
||||
title = stringResource(R.string.pref_title_video_speed),
|
||||
useSelectedAsSummary = true,
|
||||
entries = mapOf(
|
||||
"0" to "Entry 1",
|
||||
"1" to "Entry 2",
|
||||
"2" to "Entry 3",
|
||||
"3" to "Entry 4",
|
||||
"4" to "Entry 5"
|
||||
"0.5" to "0.5x",
|
||||
"0.75" to "0.75x",
|
||||
"1.0" to "Normal",
|
||||
"1.25" to "1.25x",
|
||||
"1.5" to "1.5x",
|
||||
"2" to "2x",
|
||||
)
|
||||
)
|
||||
}
|
||||
prefsItem {
|
||||
SwitchPref(
|
||||
key = "pref_back_pause_key",
|
||||
key = PREF_BACK_PAUSE_KEY,
|
||||
title = stringResource(R.string.pref_title_back_pause),
|
||||
summary = stringResource(R.string.pref_description_back_pause)
|
||||
)
|
||||
}
|
||||
prefsItem {
|
||||
ListPref(
|
||||
key = "pref_background_behavior_key",
|
||||
key = PREF_BACKGROUND_BEHAVIOR_KEY,
|
||||
title = stringResource(R.string.pref_background_behavior),
|
||||
useSelectedAsSummary = true,
|
||||
entries = mapOf(
|
||||
"0" to "Entry 1",
|
||||
"1" to "Entry 2",
|
||||
"2" to "Entry 3"
|
||||
PREF_BACKGROUND_AUDIO_KEY to stringResource(R.string.pref_background_audio),
|
||||
PREF_BACKGROUND_STOP_KEY to stringResource(R.string.pref_background_stop),
|
||||
PREF_BACKGROUND_FLOAT_KEY to stringResource(R.string.pref_background_float),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -348,7 +359,7 @@ fun SettingsScreen() {
|
|||
}) {
|
||||
prefsItem {
|
||||
SwitchPref(
|
||||
key = "pref_accept_insecure",
|
||||
key = PREF_ACCEPT_INSECURE_KEY,
|
||||
title = stringResource(R.string.pref_title_accept_insecure),
|
||||
summary = stringResource(R.string.pref_description_accept_insecure)
|
||||
)
|
||||
|
|
|
@ -38,7 +38,7 @@ interface PeerTubeApi {
|
|||
|
||||
@GET("users/me/videos/{id}/rating")
|
||||
suspend fun getVideoRating(
|
||||
@Path(value = "id", encoded = true) id: Int?
|
||||
@Path(value = "id") id: Int?
|
||||
): RatingDto
|
||||
|
||||
@GET("videos/{uuid}/description")
|
||||
|
@ -51,7 +51,7 @@ interface PeerTubeApi {
|
|||
suspend fun rateVideo(
|
||||
@Path(value = "id", encoded = true) id: Int?,
|
||||
@Body params: RequestBody?
|
||||
): ResponseBody
|
||||
): Response<Unit> // https://github.com/square/retrofit/issues/3075
|
||||
|
||||
@GET("accounts/{displayNameAndHost}/videos")
|
||||
suspend fun getAccountVideos(
|
||||
|
|
|
@ -15,13 +15,18 @@ class AccessTokenAuthenticator @Inject constructor(
|
|||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
|
||||
Log.v(tag, "authenticate")
|
||||
|
||||
// check if we are using tokens
|
||||
val accessToken = session.getToken()
|
||||
Log.v(tag, "accessToken: " + accessToken)
|
||||
if (!isRequestWithAccessToken(response) || accessToken == null) {
|
||||
Log.v(tag, "isRequestWithAccessToken")
|
||||
return null
|
||||
}
|
||||
synchronized(this) {
|
||||
val newAccessToken = session.getToken()
|
||||
Log.v(tag, "newAccessToken: " + newAccessToken)
|
||||
// Access token is refreshed in another thread.
|
||||
if (accessToken != newAccessToken) {
|
||||
Log.v(tag, "Access token is refreshed in another thread")
|
||||
|
@ -57,16 +62,21 @@ class AccessTokenAuthenticator @Inject constructor(
|
|||
}
|
||||
|
||||
private fun isRequestWithAccessToken(response: Response): Boolean {
|
||||
Log.v(tag, "isRequestWithAccessToken")
|
||||
val header = response.request.header("Authorization")
|
||||
return header != null && header.startsWith("Bearer")
|
||||
}
|
||||
|
||||
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
|
||||
Log.v(tag, "newRequestWithAccessToken")
|
||||
|
||||
return request.newBuilder()
|
||||
.header("Authorization", accessToken)
|
||||
.build()
|
||||
}
|
||||
private fun newRequestWithOutAccessToken(request: Request): Request {
|
||||
Log.v(tag, "newRequestWithOutAccessToken")
|
||||
|
||||
return request.newBuilder()
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ class AuthorizationInterceptor @Inject constructor(
|
|||
) :Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
|
||||
val tag = "AInterc"
|
||||
|
||||
val mainResponse: Response
|
||||
val mainRequest: Request = chain.request()
|
||||
val token = session.getToken()
|
||||
|
@ -21,7 +23,7 @@ class AuthorizationInterceptor @Inject constructor(
|
|||
val builder: Request.Builder =
|
||||
mainRequest.newBuilder().header("Authorization", token)
|
||||
.method(mainRequest.method, mainRequest.body)
|
||||
// Log.v("Authorization", "Intercept: " + session.getToken());
|
||||
Log.v(tag, "Intercept: " + session.getToken());
|
||||
|
||||
// build request
|
||||
val req: Request = builder.build()
|
||||
|
@ -30,9 +32,10 @@ class AuthorizationInterceptor @Inject constructor(
|
|||
// logout on auth error
|
||||
if (401 or 403 == mainResponse.code) {
|
||||
session.invalidate()
|
||||
Log.v("Authorization", "Intercept: Logout forced")
|
||||
Log.v(tag, "Intercept: Logout forced")
|
||||
}
|
||||
} else {
|
||||
Log.v(tag, "Mot logged in or no token")
|
||||
mainResponse = chain.proceed(chain.request())
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ package net.schueller.peertube.feature_video.data.remote.auth
|
|||
import android.content.Context
|
||||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -26,7 +25,7 @@ class LoginService @Inject constructor(
|
|||
@ApplicationContext private val context: Context,
|
||||
private val api: PeerTubeApi
|
||||
){
|
||||
private val TAG = "authserv"
|
||||
private val tag = "LoginService"
|
||||
private val sharedPreferences = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
fun authenticate(username: String?, password: String?) {
|
||||
|
@ -42,13 +41,13 @@ class LoginService @Inject constructor(
|
|||
editor.putString(PREF_CLIENT_SECRET, oauthClientDto.clientSecret)
|
||||
editor.apply()
|
||||
|
||||
Log.wtf(TAG, "sharedPreferences save")
|
||||
Log.wtf(tag, "sharedPreferences save")
|
||||
|
||||
|
||||
Log.wtf(TAG, "getAuthenticationToken 1")
|
||||
Log.wtf(tag, "getAuthenticationToken 1")
|
||||
|
||||
|
||||
Log.wtf(TAG, "getAuthenticationToken 2")
|
||||
Log.wtf(tag, "getAuthenticationToken 2")
|
||||
|
||||
val response = api.getAuthenticationToken(
|
||||
oauthClientDto.clientId,
|
||||
|
@ -64,23 +63,24 @@ class LoginService @Inject constructor(
|
|||
|
||||
if (response.isSuccessful && tokenDto != null) {
|
||||
|
||||
Log.wtf(TAG, "getAuthenticationToken $tokenDto")
|
||||
Log.wtf(tag, "accessToken: " + tokenDto.accessToken)
|
||||
|
||||
editor.putString(PREF_TOKEN_ACCESS, tokenDto.accessToken)
|
||||
editor.putString(PREF_TOKEN_REFRESH, tokenDto.refreshToken)
|
||||
editor.putString(PREF_TOKEN_EXPIRATION, tokenDto.tokenType)
|
||||
|
||||
editor.apply()
|
||||
|
||||
} else {
|
||||
Log.wtf(TAG, "Login failed")
|
||||
Log.wtf(tag, "Login failed")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch (e: HttpException) {
|
||||
Log.wtf(TAG, e.localizedMessage)
|
||||
Log.wtf(tag, e.localizedMessage)
|
||||
} catch (e: IOException) {
|
||||
Log.wtf(TAG, "Login Error")
|
||||
Log.wtf(tag, "Login Error")
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -113,17 +113,17 @@ class LoginService @Inject constructor(
|
|||
editor.putString(PREF_TOKEN_REFRESH, tokenDto.refreshToken)
|
||||
editor.putString(PREF_TOKEN_TYPE, tokenDto.tokenType)
|
||||
editor.apply()
|
||||
Log.wtf(TAG, "Logged in")
|
||||
Log.wtf(tag, "Logged in")
|
||||
} else {
|
||||
Log.wtf(TAG, "Login failed")
|
||||
Log.wtf(tag, "Login failed")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch (e: HttpException) {
|
||||
Log.wtf(TAG, e.localizedMessage)
|
||||
Log.wtf(tag, e.localizedMessage)
|
||||
} catch (e: IOException) {
|
||||
Log.wtf(TAG, "Refresh Error")
|
||||
Log.wtf(tag, "Refresh Error")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package net.schueller.peertube.feature_video.data.remote.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.schueller.peertube.common.Constants.PREF_AUTH_PASSWORD
|
||||
import net.schueller.peertube.common.Constants.PREF_AUTH_USERNAME
|
||||
|
@ -31,10 +32,11 @@ class Session @Inject constructor(
|
|||
|
||||
fun getToken(): String? {
|
||||
// return the token that was saved earlier
|
||||
val token = sharedPreferences.getString(PREF_TOKEN_ACCESS, null
|
||||
)
|
||||
val type = sharedPreferences.getString(PREF_TOKEN_TYPE, "Bearer"
|
||||
)
|
||||
val token = sharedPreferences.getString(PREF_TOKEN_ACCESS, null)
|
||||
val type = sharedPreferences.getString(PREF_TOKEN_TYPE, "Bearer")
|
||||
|
||||
Log.v("Session", "token: " + token)
|
||||
|
||||
return if (token != null) {
|
||||
"$type $token"
|
||||
} else null
|
||||
|
|
|
@ -1,46 +1,53 @@
|
|||
package net.schueller.peertube.feature_video.data.repository
|
||||
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit;
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import net.schueller.peertube.common.Constants.BASE_URL
|
||||
import net.schueller.peertube.common.UrlHelper
|
||||
import net.schueller.peertube.feature_video.data.remote.PeerTubeApi
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.AccessTokenAuthenticator
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.AuthorizationInterceptor
|
||||
import net.schueller.peertube.feature_video.data.remote.PeerTubeApi
|
||||
import java.lang.Exception
|
||||
import java.lang.RuntimeException
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import javax.net.ssl.*
|
||||
|
||||
@Singleton
|
||||
|
||||
class RetrofitInstance @Inject constructor(
|
||||
private val authorizationInterceptor: AuthorizationInterceptor,
|
||||
private val accessTokenAuthenticator: AccessTokenAuthenticator
|
||||
private val accessTokenAuthenticator: AccessTokenAuthenticator,
|
||||
private val urlHelper: UrlHelper
|
||||
) {
|
||||
private val tag = "RetrofitInstance"
|
||||
private val tag = "RFI"
|
||||
|
||||
private var retrofitInstance = build(BASE_URL, false)
|
||||
companion object {
|
||||
private var retrofitInstance : PeerTubeApi? = null
|
||||
}
|
||||
|
||||
fun updateBaseUrl(
|
||||
baseUrl: String = BASE_URL,
|
||||
// TODO: this doesn't work, existing UseCases keep using previous instance
|
||||
fun updateRetrofitInstance(
|
||||
insecure: Boolean = false
|
||||
) {
|
||||
retrofitInstance = build(baseUrl, insecure)
|
||||
retrofitInstance = build(insecure)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getRetrofitInstance(): PeerTubeApi {
|
||||
return retrofitInstance;
|
||||
if (retrofitInstance == null) {
|
||||
retrofitInstance = build( false)
|
||||
}
|
||||
return retrofitInstance as PeerTubeApi
|
||||
}
|
||||
|
||||
fun build(baseUrl: String = BASE_URL,
|
||||
insecure: Boolean = false): PeerTubeApi {
|
||||
fun build(insecure: Boolean = false): PeerTubeApi {
|
||||
|
||||
val apiUrl = urlHelper.getUrlWithVersion()
|
||||
|
||||
Log.v(tag, "current Url: $apiUrl")
|
||||
|
||||
val okhttpClientBuilder: OkHttpClient.Builder = if (!insecure) {
|
||||
Log.v(tag, "Secure Instance")
|
||||
OkHttpClient.Builder()
|
||||
|
@ -48,18 +55,28 @@ class RetrofitInstance @Inject constructor(
|
|||
Log.v(tag, "Unsafe Instance")
|
||||
getUnsafeOkHttpClient()
|
||||
}
|
||||
|
||||
// TODO: Remove debug
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BASIC)
|
||||
okhttpClientBuilder.addInterceptor(logging)
|
||||
|
||||
// Add auth
|
||||
okhttpClientBuilder.addInterceptor(authorizationInterceptor)
|
||||
okhttpClientBuilder.authenticator(accessTokenAuthenticator)
|
||||
|
||||
return Retrofit.Builder()
|
||||
.client(okhttpClientBuilder.build())
|
||||
.baseUrl(baseUrl)
|
||||
.baseUrl(apiUrl)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build().create(PeerTubeApi::class.java)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun getUnsafeOkHttpClient(): OkHttpClient.Builder {
|
||||
// Create a trust manager that does not validate certificate chains
|
||||
// Install the all-trusting trust manager
|
||||
|
|
|
@ -8,7 +8,9 @@ import net.schueller.peertube.feature_video.domain.model.*
|
|||
import net.schueller.peertube.feature_video.domain.repository.VideoRepository
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
|
||||
class VideoRepositoryImpl @Inject constructor(
|
||||
|
@ -27,17 +29,18 @@ class VideoRepositoryImpl @Inject constructor(
|
|||
return api.getVideoFullDescription(uuid).toDescription()
|
||||
}
|
||||
|
||||
override suspend fun rateVideo(id: Int, upVote: Boolean): ResponseBody {
|
||||
data class JsonDataParser(
|
||||
@SerializedName("rating") val rating: String,
|
||||
)
|
||||
val payload = if (upVote) {
|
||||
JsonDataParser(rating = RATING_LIKE)
|
||||
override suspend fun rateVideo(id: Int, upVote: Boolean) {
|
||||
|
||||
val rating = if (upVote) {
|
||||
RATING_LIKE
|
||||
} else {
|
||||
JsonDataParser(rating = RATING_DISLIKE)
|
||||
RATING_DISLIKE
|
||||
}
|
||||
val gson = Gson()
|
||||
return api.rateVideo(id, gson.toJson(payload).toRequestBody("application/json".toMediaType()))
|
||||
val jsonObject = JSONObject()
|
||||
jsonObject.put("rating", rating)
|
||||
val jsonObjectString = jsonObject.toString()
|
||||
|
||||
api.rateVideo(id, jsonObjectString.toRequestBody("application/json".toMediaType()))
|
||||
}
|
||||
|
||||
override suspend fun getVideoRating(id: Int): Rating {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
package net.schueller.peertube.feature_video.domain.model
|
||||
|
||||
data class Description (
|
||||
val description: String
|
||||
val description: String? = ""
|
||||
)
|
|
@ -1,6 +1,7 @@
|
|||
package net.schueller.peertube.feature_video.domain.repository
|
||||
|
||||
import net.schueller.peertube.feature_video.domain.model.*
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
|
||||
interface VideoRepository {
|
||||
|
@ -13,7 +14,7 @@ interface VideoRepository {
|
|||
|
||||
suspend fun getVideoDescriptionByUuid(uuid: String): Description
|
||||
|
||||
suspend fun rateVideo(id: Int, upVote: Boolean): ResponseBody
|
||||
suspend fun rateVideo(id: Int, upVote: Boolean)
|
||||
|
||||
suspend fun getVideoRating(id: Int): Rating
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package net.schueller.peertube.feature_video.domain.use_case
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import net.schueller.peertube.common.Resource
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
package net.schueller.peertube.feature_video.domain.use_case
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import net.schueller.peertube.common.Resource
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.Session
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutUseCase @Inject constructor(
|
||||
private val session: Session,
|
||||
) {
|
||||
operator fun invoke(): Flow<Resource<String>> = flow {
|
||||
try {
|
||||
emit(Resource.Loading())
|
||||
if (session.isLoggedIn()) {
|
||||
session.invalidate()
|
||||
}
|
||||
emit(Resource.Success(""))
|
||||
} catch(e: HttpException) {
|
||||
emit(Resource.Error(e.localizedMessage ?: "An unexpected error occurred"))
|
||||
} catch(e: IOException) {
|
||||
emit(Resource.Error("Couldn't reach server. Check your internet connection."))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package net.schueller.peertube.feature_video.domain.use_case
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import net.schueller.peertube.common.Resource
|
||||
|
@ -15,11 +16,14 @@ class UpVoteVideoUseCase @Inject constructor(
|
|||
operator fun invoke(video: Video): Flow<Resource<Video>> = flow {
|
||||
try {
|
||||
emit(Resource.Loading<Video>())
|
||||
Log.v("UpVoteVideoUseCase", "UpVote: " + video.id)
|
||||
repository.rateVideo(video.id, true)
|
||||
emit(Resource.Success<Video>(video))
|
||||
} catch(e: HttpException) {
|
||||
Log.v("UpVoteVideoUseCase", "Error: " + e.localizedMessage)
|
||||
emit(Resource.Error<Video>(e.localizedMessage ?: "An unexpected error occurred"))
|
||||
} catch(e: IOException) {
|
||||
Log.v("UpVoteVideoUseCase", "Error: ??" )
|
||||
emit(Resource.Error<Video>("Couldn't reach server. Check your internet connection."))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package net.schueller.peertube.feature_video.domain.use_case
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
data class VideoPlayUseCases @Inject constructor(
|
||||
val addVideoToPlaylistUseCase: AddVideoToPlaylistUseCase,
|
||||
val blockVideoUseCase: BlockVideoUseCase,
|
||||
val downloadVideoUseCase: DownloadVideoUseCase,
|
||||
val downVoteVideoUseCase: DownVoteVideoUseCase,
|
||||
val flagVideoUseCase: FlagVideoUseCase,
|
||||
val getVideoDescriptionUseCase: GetVideoDescriptionUseCase,
|
||||
val getVideoRatingUseCase: GetVideoRatingUseCase,
|
||||
val getVideoUseCase: GetVideoUseCase,
|
||||
val shareVideoUseCase: ShareVideoUseCase,
|
||||
val upVoteVideoUseCase: UpVoteVideoUseCase,
|
||||
)
|
|
@ -1,5 +1,6 @@
|
|||
package net.schueller.peertube.feature_video.presentation.common
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import net.schueller.peertube.R
|
||||
|
@ -95,7 +96,12 @@ fun getCreatorAvatarUrl(avatar: Avatar?): String {
|
|||
|
||||
@Composable
|
||||
fun getImageUrl(image: String?): String {
|
||||
return Constants.BASE_IMAGE_URL + image
|
||||
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
val baseUrl = sharedPreferences.getString(Constants.PREF_API_BASE_KEY, "")
|
||||
|
||||
return baseUrl + image
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package net.schueller.peertube.feature_video.presentation.me
|
||||
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
|
||||
sealed class MeEvent {
|
||||
// data class Logout(val video: Video): MeEvent()
|
||||
object Logout: MeEvent()
|
||||
|
||||
}
|
|
@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExitToApp
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -52,47 +52,71 @@ fun MeScreen (
|
|||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
if (viewModel.isLoggedIn) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
|
||||
}
|
||||
) {
|
||||
MeAvatar(
|
||||
avatar = viewModel.stateMe.value.me?.account?.avatar,
|
||||
onItemClick = {}
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = viewModel.stateMe.value.me?.username ?: "",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
viewModel.onEvent(MeEvent.Logout)
|
||||
navController.navigateUp()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.ExitToApp,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.width(48.dp)
|
||||
.padding(8.dp),
|
||||
contentDescription = "Logout",
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = "Logout",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
) {
|
||||
MeAvatar(
|
||||
avatar = viewModel.stateMe.value.me?.account?.avatar,
|
||||
onItemClick = {}
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = viewModel.stateMe.value.me?.username ?: "",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
|
||||
navController.navigate("settings_screen")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.ExitToApp,
|
||||
Icons.Filled.Settings,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.width(48.dp)
|
||||
.padding(8.dp),
|
||||
contentDescription = "Logout",
|
||||
contentDescription = "Settings",
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = "Logout",
|
||||
text = "Settings",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// }
|
||||
|
|
|
@ -9,12 +9,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import net.schueller.peertube.common.Resource
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.Session
|
||||
import net.schueller.peertube.feature_video.domain.use_case.GetMeUseCase
|
||||
import net.schueller.peertube.feature_video.domain.use_case.LogoutUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MeViewModel @Inject constructor(
|
||||
private val getMeUseCase: GetMeUseCase
|
||||
private val getMeUseCase: GetMeUseCase,
|
||||
private val logoutUseCase: LogoutUseCase,
|
||||
private val session: Session
|
||||
) : ViewModel() {
|
||||
|
||||
private val _stateMe = mutableStateOf(MeState())
|
||||
|
@ -24,6 +28,8 @@ class MeViewModel @Inject constructor(
|
|||
getMe()
|
||||
}
|
||||
|
||||
val isLoggedIn = session.isLoggedIn()
|
||||
|
||||
private fun getMe() {
|
||||
// get description data
|
||||
getMeUseCase().onEach { result ->
|
||||
|
@ -43,4 +49,14 @@ class MeViewModel @Inject constructor(
|
|||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
|
||||
fun onEvent(event: MeEvent) {
|
||||
when (event) {
|
||||
MeEvent.Logout -> {
|
||||
logoutUseCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import net.schueller.peertube.feature_video.domain.model.Overview
|
||||
import net.schueller.peertube.feature_video.domain.repository.VideoRepository
|
||||
import net.schueller.peertube.feature_video.domain.source.ExplorePagingSource
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class VideoExploreViewModel @Inject constructor(
|
||||
private val repository: VideoRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _videos = MutableStateFlow<PagingData<Overview>>(PagingData.empty())
|
||||
val videos = _videos
|
||||
|
||||
init {
|
||||
getVideos()
|
||||
}
|
||||
|
||||
private fun getVideos() {
|
||||
viewModelScope.launch {
|
||||
Pager(
|
||||
PagingConfig(
|
||||
pageSize = 1,
|
||||
maxSize = 5
|
||||
)
|
||||
) {
|
||||
ExplorePagingSource(repository)
|
||||
}.flow.cachedIn(viewModelScope).collect {
|
||||
_videos.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,264 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video
|
||||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import net.schueller.peertube.feature_video.domain.model.Overview
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.presentation.video.components.*
|
||||
import net.schueller.peertube.feature_video.presentation.video.components.appBarBottom.BottomBarComponent
|
||||
import net.schueller.peertube.feature_video.presentation.video.components.appBarTop.TopAppBarComponent
|
||||
import net.schueller.peertube.feature_video.presentation.video.components.videoPlay.VideoPlayScreen
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoPlayEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.player.ExoPlayerHolder
|
||||
import net.schueller.peertube.presentation.Screen
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@ExperimentalCoilApi
|
||||
@Composable
|
||||
fun VideoListScreen(
|
||||
navController: NavController,
|
||||
exoPlayerHolder: ExoPlayerHolder,
|
||||
viewModel: VideoListViewModel = hiltViewModel(),
|
||||
videoPlayViewModel: VideoPlayViewModel = hiltViewModel(),
|
||||
viewExploreModel: VideoExploreViewModel = hiltViewModel()
|
||||
) {
|
||||
|
||||
val state = viewModel.state.value
|
||||
|
||||
val lazyVideoExploreItems: LazyPagingItems<Overview> = viewExploreModel.videos.collectAsLazyPagingItems()
|
||||
val lazyVideoItems: LazyPagingItems<Video> = viewModel.videos.collectAsLazyPagingItems()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
// Events
|
||||
LaunchedEffect(key1 = true) {
|
||||
viewModel.eventFlow.collectLatest { event ->
|
||||
when(event) {
|
||||
is VideoListViewModel.UiEvent.ScrollToTop -> {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
is VideoListViewModel.UiEvent.ShowToast -> {
|
||||
Toast.makeText(
|
||||
context,
|
||||
event.message,
|
||||
event.length
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto hide top appbar
|
||||
val toolBarHeight = 56.dp
|
||||
val toolBarHeightPx = with(LocalDensity.current) { toolBarHeight.roundToPx().toFloat()}
|
||||
val toolBarOffsetHeightPx = remember { mutableStateOf(0f) }
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.y
|
||||
val newOffset = toolBarOffsetHeightPx.value + delta
|
||||
toolBarOffsetHeightPx.value = newOffset.coerceIn(-toolBarHeightPx, 0f)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
)
|
||||
{
|
||||
// List
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
BottomBarComponent(navController)
|
||||
}
|
||||
) {
|
||||
// Pull to refresh
|
||||
// TODO: fix appbar blank issue
|
||||
SwipeRefresh(
|
||||
state = rememberSwipeRefreshState(
|
||||
isRefreshing = (lazyVideoItems.loadState.refresh is LoadState.Loading) || (lazyVideoExploreItems.loadState.refresh is LoadState.Loading)
|
||||
),
|
||||
onRefresh = {
|
||||
// Which model do we want to refresh
|
||||
if (state.explore) {
|
||||
lazyVideoExploreItems.refresh()
|
||||
} else {
|
||||
lazyVideoItems.refresh()
|
||||
}
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(top = toolBarHeight),
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (state.explore) {
|
||||
itemsIndexed(lazyVideoExploreItems) { _, overview ->
|
||||
if (overview != null) {
|
||||
// Categories
|
||||
if (overview.categories?.isNotEmpty() == true) {
|
||||
overview.categories.forEach { categoryVideo ->
|
||||
VideoCategory(categoryVideo.category)
|
||||
if (categoryVideo.videos.isNotEmpty()) {
|
||||
categoryVideo.videos.forEach { video ->
|
||||
VideoListItem(
|
||||
video = video,
|
||||
onItemClick = {
|
||||
videoPlayViewModel.onEvent(VideoPlayEvent.PlayVideo(video))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Channels
|
||||
if (overview.channels?.isNotEmpty() == true) {
|
||||
overview.channels.forEach { channelVideo ->
|
||||
VideoChannel(channelVideo.channel)
|
||||
if (channelVideo.videos.isNotEmpty()) {
|
||||
channelVideo.videos.forEach { video ->
|
||||
VideoListItem(
|
||||
video = video,
|
||||
onItemClick = {
|
||||
videoPlayViewModel.onEvent(VideoPlayEvent.PlayVideo(video))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tags
|
||||
if (overview.tags?.isNotEmpty() == true) {
|
||||
overview.tags.forEach { tagVideo ->
|
||||
VideoTag(tagVideo.tag)
|
||||
if (tagVideo.videos.isNotEmpty()) {
|
||||
tagVideo.videos.forEach { video ->
|
||||
VideoListItem(
|
||||
video = video,
|
||||
onItemClick = {
|
||||
videoPlayViewModel.onEvent(VideoPlayEvent.PlayVideo(video))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(lazyVideoItems) { item, video ->
|
||||
if (video != null) {
|
||||
// Log.v("VLV", video.id.toString() + "-" + item.toString())
|
||||
VideoListItem(
|
||||
video = video,
|
||||
onItemClick = {
|
||||
videoPlayViewModel.onEvent(VideoPlayEvent.PlayVideo(video))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
lazyVideoItems.apply {
|
||||
when {
|
||||
loadState.refresh is LoadState.Loading -> {
|
||||
item {
|
||||
// LoadingView(modifier = Modifier.fillParentMaxSize())
|
||||
}
|
||||
}
|
||||
loadState.append is LoadState.Loading -> {
|
||||
item {
|
||||
// LoadingItem()
|
||||
}
|
||||
}
|
||||
loadState.refresh is LoadState.Error -> {
|
||||
val e = lazyVideoItems.loadState.refresh as LoadState.Error
|
||||
item {
|
||||
// ErrorItem(
|
||||
// message = e.error.localizedMessage!!,
|
||||
// modifier = Modifier.fillParentMaxSize(),
|
||||
// onClickRetry = { retry() }
|
||||
// )
|
||||
}
|
||||
}
|
||||
loadState.append is LoadState.Error -> {
|
||||
val e = lazyVideoItems.loadState.append as LoadState.Error
|
||||
item {
|
||||
// ErrorItem(
|
||||
// message = e.error.localizedMessage!!,
|
||||
// onClickRetry = { retry() }
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: deal with errors, https://proandroiddev.com/infinite-lists-with-paging-3-in-jetpack-compose-b095533aefe6
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Place after list, so it floats above the list in z-height
|
||||
TopAppBarComponent(
|
||||
navController,
|
||||
modifier = Modifier
|
||||
.height(toolBarHeight)
|
||||
.offset {
|
||||
IntOffset(x = 0, y = toolBarOffsetHeightPx.value.roundToInt())
|
||||
}
|
||||
)
|
||||
// if(error) {
|
||||
// Text(
|
||||
// text = "Error Text",
|
||||
// color = MaterialTheme.colors.error,
|
||||
// textAlign = TextAlign.Center,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(horizontal = 20.dp)
|
||||
// .align(Alignment.Center)
|
||||
// )
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
if (videoPlayViewModel.playerVisible.value) {
|
||||
Log.v("VLS", "Show Video Player")
|
||||
VideoPlayScreen(exoPlayerHolder)
|
||||
} else {
|
||||
Log.v("VLS", "Close Video Player")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video
|
||||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import net.schueller.peertube.common.Constants.VIDEOS_API_PAGE_SIZE
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.Session
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.domain.repository.VideoRepository
|
||||
import net.schueller.peertube.feature_video.domain.source.VideoPagingSource
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.*
|
||||
import net.schueller.peertube.feature_video.presentation.video.states.VideoListState
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class VideoListViewModel @Inject constructor(
|
||||
private val repository: VideoRepository,
|
||||
private val session: Session
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = mutableStateOf(VideoListState())
|
||||
val state: State<VideoListState> = _state
|
||||
|
||||
private val _eventFlow = MutableSharedFlow<UiEvent>()
|
||||
val eventFlow = _eventFlow.asSharedFlow()
|
||||
|
||||
private val _videos = MutableStateFlow<PagingData<Video>>(PagingData.empty())
|
||||
val videos = _videos
|
||||
|
||||
init {
|
||||
Log.v("VLM", "INIT")
|
||||
getVideos()
|
||||
}
|
||||
|
||||
private fun getVideos() {
|
||||
viewModelScope.launch {
|
||||
Pager(
|
||||
PagingConfig(
|
||||
pageSize = VIDEOS_API_PAGE_SIZE,
|
||||
maxSize = 100
|
||||
)
|
||||
) {
|
||||
VideoPagingSource(
|
||||
repository,
|
||||
_state.value.sort,
|
||||
_state.value.nsfw,
|
||||
_state.value.filter,
|
||||
_state.value.languages
|
||||
)
|
||||
}.flow.cachedIn(viewModelScope).collect {
|
||||
_videos.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: VideoListEvent) {
|
||||
when (event) {
|
||||
is VideoListEvent.UpdateQuery -> {
|
||||
viewModelScope.launch {
|
||||
|
||||
when (event.set) {
|
||||
SET_DISCOVER -> {
|
||||
_state.value = VideoListState(
|
||||
explore = true,
|
||||
local = false,
|
||||
subscriptions = false,
|
||||
filter = null
|
||||
)
|
||||
}
|
||||
SET_TRENDING -> {
|
||||
_state.value = VideoListState(
|
||||
sort = "-trending",
|
||||
explore = false,
|
||||
local = false,
|
||||
subscriptions = false,
|
||||
filter = null
|
||||
)
|
||||
}
|
||||
SET_RECENT -> {
|
||||
_state.value = VideoListState(
|
||||
sort = "-createdAt",
|
||||
explore = false,
|
||||
local = false,
|
||||
subscriptions = false,
|
||||
filter = null
|
||||
)
|
||||
|
||||
}
|
||||
SET_LOCAL -> {
|
||||
_state.value = VideoListState(
|
||||
sort = "-publishedAt",
|
||||
explore = false,
|
||||
local = true,
|
||||
subscriptions = false,
|
||||
filter = "local"
|
||||
)
|
||||
}
|
||||
SET_SUBSCRIPTIONS -> {
|
||||
if (session.isLoggedIn()) {
|
||||
_state.value = VideoListState(
|
||||
explore = false,
|
||||
local = false,
|
||||
subscriptions = true,
|
||||
filter = null
|
||||
)
|
||||
} else {
|
||||
_eventFlow.emit(UiEvent.ShowToast("You must be logged in", Toast.LENGTH_SHORT))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
getVideos()
|
||||
|
||||
Log.v("vvm", "Update sort: " + event.set)
|
||||
|
||||
_eventFlow.emit(UiEvent.ScrollToTop)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class UiEvent {
|
||||
object ScrollToTop : UiEvent()
|
||||
data class ShowToast(val message: String, val length: Int): VideoListViewModel.UiEvent()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,263 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video
|
||||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import net.schueller.peertube.common.Constants
|
||||
import net.schueller.peertube.common.Resource
|
||||
import net.schueller.peertube.feature_video.data.remote.auth.Session
|
||||
import net.schueller.peertube.feature_video.domain.model.Description
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.domain.repository.VideoRepository
|
||||
import net.schueller.peertube.feature_video.domain.source.VideoPagingSource
|
||||
import net.schueller.peertube.feature_video.domain.use_case.*
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoPlayEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.states.VideoDescriptionState
|
||||
import net.schueller.peertube.feature_video.presentation.video.states.VideoPlayState
|
||||
import net.schueller.peertube.feature_video.presentation.video.states.VideoRatingState
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class VideoPlayViewModel @Inject constructor(
|
||||
private val videoPlayUseCases: VideoPlayUseCases,
|
||||
private val session: Session,
|
||||
private val repository: VideoRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = mutableStateOf(VideoPlayState())
|
||||
val state: State<VideoPlayState> = _state
|
||||
|
||||
private val _stateVideoDescription = mutableStateOf(VideoDescriptionState())
|
||||
val stateVideoDescription: State<VideoDescriptionState> = _stateVideoDescription
|
||||
|
||||
private val _stateVideoRating = mutableStateOf(VideoRatingState())
|
||||
val stateVideoRating: State<VideoRatingState> = _stateVideoRating
|
||||
|
||||
var playerVisible = mutableStateOf(false)
|
||||
|
||||
private val _eventFlow = MutableSharedFlow<UiEvent>()
|
||||
val eventFlow = _eventFlow.asSharedFlow()
|
||||
|
||||
// init {
|
||||
// savedStateHandle.get<String>(Constants.PARAM_VIDEO_UUID)?.let { uuid ->
|
||||
// getVideo(uuid)
|
||||
// getDescription(uuid)
|
||||
// }
|
||||
// }
|
||||
|
||||
var relatedVideos: Flow<PagingData<Video>> = Pager(
|
||||
PagingConfig(
|
||||
pageSize = Constants.VIDEOS_API_PAGE_SIZE,
|
||||
maxSize = 100
|
||||
)
|
||||
) {
|
||||
// if (session.isLoggedIn()) {
|
||||
// PlaylistVideoPagingSource(repository, "-publishedAt", video)
|
||||
// } else {
|
||||
VideoPagingSource(repository, "-publishedAt", null, null ,null)
|
||||
// }
|
||||
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
|
||||
|
||||
|
||||
private fun getVideo(uuid: String) {
|
||||
videoPlayUseCases.getVideoUseCase(uuid).onEach { result ->
|
||||
when (result) {
|
||||
is Resource.Success -> {
|
||||
_state.value = VideoPlayState(video = result.data)
|
||||
// Add short description
|
||||
_stateVideoDescription.value = VideoDescriptionState(description = Description(description = result.data?.description ?: "") )
|
||||
if (result.data != null) {
|
||||
getRating(result.data.id)
|
||||
}
|
||||
getDescription(uuid)
|
||||
}
|
||||
is Resource.Error -> {
|
||||
_state.value = VideoPlayState(
|
||||
error = result.message ?: "An unexpected error occurred"
|
||||
)
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
_state.value = VideoPlayState(isLoading = true)
|
||||
}
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun getDescription(uuid: String) {
|
||||
// get description data
|
||||
videoPlayUseCases.getVideoDescriptionUseCase(uuid).onEach { result ->
|
||||
when (result) {
|
||||
is Resource.Success -> {
|
||||
_stateVideoDescription.value = VideoDescriptionState(description = result.data)
|
||||
|
||||
}
|
||||
is Resource.Error -> {
|
||||
_stateVideoDescription.value = VideoDescriptionState(
|
||||
error = result.message ?: "An unexpected error occurred"
|
||||
)
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
_stateVideoDescription.value = VideoDescriptionState(isLoading = true)
|
||||
}
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
|
||||
fun onEvent(event: VideoPlayEvent) {
|
||||
when (event) {
|
||||
is VideoPlayEvent.UpVoteVideo -> {
|
||||
if (session.isLoggedIn()) {
|
||||
videoPlayUseCases.upVoteVideoUseCase(event.video).onEach { result ->
|
||||
when (result) {
|
||||
is Resource.Success -> {
|
||||
// Update rating
|
||||
if (result.data != null) {
|
||||
getRating(result.data.id)
|
||||
}
|
||||
}
|
||||
is Resource.Error -> {
|
||||
_eventFlow.emit(
|
||||
UiEvent.ShowToast(
|
||||
"Up vote Failed: " + result.message,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
)
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
// Upvote Pending
|
||||
}
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
_eventFlow.emit(
|
||||
UiEvent.ShowToast(
|
||||
"You must be logged in",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is VideoPlayEvent.DownVoteVideo -> {
|
||||
if (session.isLoggedIn()) {
|
||||
videoPlayUseCases.downVoteVideoUseCase(event.video).onEach { result ->
|
||||
when (result) {
|
||||
is Resource.Success -> {
|
||||
// Update rating
|
||||
if (result.data != null) {
|
||||
getRating(result.data.id)
|
||||
}
|
||||
}
|
||||
is Resource.Error -> {
|
||||
_eventFlow.emit(
|
||||
UiEvent.ShowToast(
|
||||
"Down vote Failed: " + result.message,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
)
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
// Down vote Pending
|
||||
}
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
_eventFlow.emit(
|
||||
UiEvent.ShowToast(
|
||||
"You must be logged in",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is VideoPlayEvent.ShareVideo -> {
|
||||
videoPlayUseCases.shareVideoUseCase(event.video)
|
||||
}
|
||||
is VideoPlayEvent.AddVideoToPlaylist -> {
|
||||
videoPlayUseCases.addVideoToPlaylistUseCase(event.video)
|
||||
}
|
||||
is VideoPlayEvent.BlockVideo -> {
|
||||
videoPlayUseCases.blockVideoUseCase(event.video)
|
||||
}
|
||||
is VideoPlayEvent.FlagVideo -> {
|
||||
videoPlayUseCases.flagVideoUseCase(event.video)
|
||||
}
|
||||
is VideoPlayEvent.DownloadVideo -> {
|
||||
// TODO: permissions
|
||||
videoPlayUseCases.downloadVideoUseCase(event.video)
|
||||
}
|
||||
is VideoPlayEvent.OpenDescription -> {
|
||||
// Show description before we have the data
|
||||
viewModelScope.launch {
|
||||
_eventFlow.emit(UiEvent.ShowDescription)
|
||||
}
|
||||
}
|
||||
is VideoPlayEvent.CloseDescription -> {
|
||||
viewModelScope.launch {
|
||||
_eventFlow.emit(UiEvent.HideDescription)
|
||||
}
|
||||
}
|
||||
is VideoPlayEvent.MoreButton -> {
|
||||
// TODO: implement video options menu
|
||||
Log.v("VPVM", "Video More Pressed")
|
||||
viewModelScope.launch {
|
||||
_eventFlow.emit(UiEvent.ShowMore)
|
||||
}
|
||||
}
|
||||
is VideoPlayEvent.PlayVideo -> {
|
||||
// Load new video
|
||||
getVideo(event.video.uuid)
|
||||
// show video
|
||||
playerVisible.value = true
|
||||
}
|
||||
is VideoPlayEvent.CloseVideo -> {
|
||||
playerVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRating(id: Int) {
|
||||
Log.v("VPVM", "Get Rating: " + id)
|
||||
videoPlayUseCases.getVideoRatingUseCase(id).onEach { res ->
|
||||
when (res) {
|
||||
is Resource.Success -> {
|
||||
Log.v("VPVM", "Update Rating: " + res.data)
|
||||
_stateVideoRating.value = VideoRatingState(rating = res.data)
|
||||
}
|
||||
is Resource.Error -> {
|
||||
Log.v("VPVM", "Error getting rating")
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
// updating rating
|
||||
Log.v("VPVM", "updating rating")
|
||||
}
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
}
|
||||
|
||||
sealed class UiEvent {
|
||||
data class ShowToast(val message: String, val length: Int): UiEvent()
|
||||
object ShowDescription : UiEvent()
|
||||
object HideDescription : UiEvent()
|
||||
object ShowMore : UiEvent()
|
||||
object HideMore : UiEvent()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components;
|
||||
|
||||
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable;
|
||||
import net.schueller.peertube.feature_video.domain.model.Category
|
||||
|
||||
@Composable
|
||||
fun VideoCategory(
|
||||
category: Category
|
||||
) {
|
||||
Text(text = category.label)
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components;
|
||||
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable;
|
||||
import net.schueller.peertube.feature_video.domain.model.Channel
|
||||
|
||||
@Composable
|
||||
fun VideoChannel(
|
||||
channel: Channel
|
||||
) {
|
||||
Text(text = channel.name)
|
||||
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.ImagePainter
|
||||
import coil.compose.rememberImagePainter
|
||||
import com.google.accompanist.placeholder.PlaceholderHighlight
|
||||
import com.google.accompanist.placeholder.material.fade
|
||||
import com.google.accompanist.placeholder.material.placeholder
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.presentation.common.*
|
||||
|
||||
@ExperimentalCoilApi
|
||||
@Composable
|
||||
fun VideoListItem(
|
||||
video: Video,
|
||||
onItemClick: (Video) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.clickable { onItemClick(video) },
|
||||
shape = RectangleShape,
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.surface)
|
||||
.fillMaxWidth(),
|
||||
// .clickable { onItemClick(video) }
|
||||
) {
|
||||
Box() {
|
||||
val image = rememberImagePainter(
|
||||
data = getImageUrl(video.previewPath),
|
||||
// builder = {
|
||||
// placeholder(R.drawable.test_image)
|
||||
// }
|
||||
)
|
||||
Image(
|
||||
painter = image,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.placeholder(
|
||||
visible = (image.state is ImagePainter.State.Error || image.state is ImagePainter.State.Empty),
|
||||
highlight = PlaceholderHighlight.fade()
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(
|
||||
Alignment.BottomEnd
|
||||
)
|
||||
.padding(2.dp)
|
||||
) {
|
||||
VideoTime(video)
|
||||
}
|
||||
}
|
||||
|
||||
// Video Meta
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.surface)
|
||||
.height(84.dp) // TODO: not setting this causes odd up scroll effect
|
||||
) {
|
||||
val avatar = rememberImagePainter(
|
||||
data = getCreatorAvatarUrl(getCreatorAvatar(video))
|
||||
)
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.height(72.dp)
|
||||
.width(72.dp)
|
||||
.padding(12.dp)
|
||||
.clip(shape = RoundedCornerShape(100.dp))
|
||||
.placeholder(
|
||||
visible = (avatar.state is ImagePainter.State.Error || avatar.state is ImagePainter.State.Empty),
|
||||
highlight = if (avatar.state is ImagePainter.State.Empty) {
|
||||
PlaceholderHighlight.fade()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.padding(6.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = video.name ?: "No Name",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = getMetaDataTag(video.createdAt, video.views, "",true),
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
Text(
|
||||
text = getCreatorString(video, true),
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components;
|
||||
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun VideoTag(
|
||||
tag: String
|
||||
) {
|
||||
Text(text = tag)
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import net.schueller.peertube.R
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
|
||||
|
||||
@Composable
|
||||
fun VideoTime(
|
||||
video: Video
|
||||
) {
|
||||
val backgroundColor = if (video.isLive) {
|
||||
Color(
|
||||
red = 0xFF,
|
||||
blue = 0,
|
||||
green = 0,
|
||||
alpha = 0xCC
|
||||
)
|
||||
} else {
|
||||
Color(
|
||||
red = 0,
|
||||
blue = 0,
|
||||
green = 0,
|
||||
alpha = 0x99
|
||||
)
|
||||
}
|
||||
|
||||
val timeStamp = if (video.isLive) {
|
||||
"LIVE"
|
||||
} else {
|
||||
getDuration(video.duration.toLong())
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.background(
|
||||
color = backgroundColor
|
||||
)
|
||||
) {
|
||||
Row() {
|
||||
if (video.isLive) {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_radio),
|
||||
contentDescription = "signupAllowed",
|
||||
modifier = Modifier.requiredSize(18.dp)
|
||||
.align(CenterVertically)
|
||||
.padding(
|
||||
start = 4.dp
|
||||
),
|
||||
tint = Color(
|
||||
red = 0xFF,
|
||||
blue = 0xFF,
|
||||
green = 0xFF,
|
||||
alpha = 0xFF
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(
|
||||
end = 4.dp,
|
||||
start = 4.dp
|
||||
),
|
||||
color = Color(
|
||||
red = 0xFF,
|
||||
blue = 0xFF,
|
||||
green = 0xFF,
|
||||
alpha = 0xFF
|
||||
),
|
||||
text = timeStamp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getDuration(duration: Long?): String {
|
||||
return DateUtils.formatElapsedTime(duration!!)
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.appBarBottom
|
||||
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoListEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoListViewModel
|
||||
|
||||
@Composable
|
||||
fun BottomBarComponent(
|
||||
navController: NavController
|
||||
) {
|
||||
BottomAppBar(
|
||||
content = {
|
||||
BottomNavigationBar(navController)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomNavigationBar(
|
||||
navController: NavController,
|
||||
videoListViewModel: VideoListViewModel = hiltViewModel()
|
||||
) {
|
||||
val items = listOf(
|
||||
BottomBarItems.Discover,
|
||||
BottomBarItems.Trending,
|
||||
BottomBarItems.Recent,
|
||||
BottomBarItems.Local,
|
||||
BottomBarItems.Subscriptions
|
||||
)
|
||||
BottomNavigation(
|
||||
contentColor = Color.White
|
||||
) {
|
||||
// val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
// val currentRoute = navBackStackEntry?.destination?.route
|
||||
items.forEach { item ->
|
||||
BottomNavigationItem(
|
||||
// modifier = Modifier.width(105.dp),
|
||||
icon = {
|
||||
Icon(painterResource(id = item.icon),
|
||||
contentDescription = item.title
|
||||
)
|
||||
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = item.title,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
},
|
||||
selectedContentColor = Color.White,
|
||||
unselectedContentColor = Color.White.copy(0.75f),
|
||||
alwaysShowLabel = true,
|
||||
selected = false, //currentRoute == item.route,
|
||||
onClick = {
|
||||
videoListViewModel.onEvent(
|
||||
VideoListEvent.UpdateQuery(
|
||||
set = item.set
|
||||
)
|
||||
)
|
||||
navController.navigate(item.route) {
|
||||
// Pop up to the start destination of the graph to
|
||||
// avoid building up a large stack of destinations
|
||||
// on the back stack as users select items
|
||||
navController.graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route) {
|
||||
saveState = true
|
||||
}
|
||||
}
|
||||
// Avoid multiple copies of the same destination when
|
||||
// reselecting the same item
|
||||
launchSingleTop = true
|
||||
// Restore state when reselecting a previously selected item
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.appBarBottom
|
||||
|
||||
import net.schueller.peertube.R
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.*
|
||||
|
||||
sealed class BottomBarItems(
|
||||
var route: String,
|
||||
var icon: Int,
|
||||
var title: String,
|
||||
var set: String
|
||||
) {
|
||||
object Discover : BottomBarItems(
|
||||
"video_list_screen",
|
||||
R.drawable.ic_globe,
|
||||
"Discover",
|
||||
SET_DISCOVER
|
||||
)
|
||||
|
||||
object Trending : BottomBarItems(
|
||||
"video_list_screen",
|
||||
R.drawable.ic_trending_up,
|
||||
"Trending",
|
||||
SET_TRENDING
|
||||
)
|
||||
|
||||
object Recent : BottomBarItems(
|
||||
"video_list_screen",
|
||||
R.drawable.ic_plus_circle,
|
||||
"Recent",
|
||||
SET_RECENT
|
||||
)
|
||||
|
||||
object Local : BottomBarItems(
|
||||
"video_list_screen",
|
||||
R.drawable.ic_local,
|
||||
"Local",
|
||||
SET_LOCAL
|
||||
)
|
||||
|
||||
object Subscriptions : BottomBarItems(
|
||||
"video_list_screen",
|
||||
R.drawable.ic_subscriptions,
|
||||
"Subscriptions",
|
||||
SET_SUBSCRIPTIONS
|
||||
)
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.appBarTop
|
||||
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import net.schueller.peertube.R
|
||||
import net.schueller.peertube.feature_video.presentation.me.MeViewModel
|
||||
import net.schueller.peertube.feature_video.presentation.me.components.MeAvatar
|
||||
|
||||
@ExperimentalCoilApi
|
||||
@Composable
|
||||
fun TopAppBarComponent(
|
||||
navController: NavController,
|
||||
modifier: Modifier,
|
||||
meViewModel: MeViewModel = hiltViewModel()
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = { Text(text = "AppBar") },
|
||||
// color = Color.White,
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
navController.navigate("address_list") {
|
||||
// Pop up to the start destination of the graph to
|
||||
// avoid building up a large stack of destinations
|
||||
// on the back stack as users select items
|
||||
navController.graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route) {
|
||||
saveState = true
|
||||
}
|
||||
}
|
||||
// Avoid multiple copies of the same destination when
|
||||
// reselecting the same item
|
||||
launchSingleTop = true
|
||||
// Restore state when reselecting a previously selected item
|
||||
restoreState = true
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_server),
|
||||
contentDescription = "Address Book"
|
||||
)
|
||||
}
|
||||
MeAvatar(
|
||||
avatar = meViewModel.stateMe.value.me?.account?.avatar,
|
||||
onItemClick = {
|
||||
navController.navigate("me_screen")
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.videoPlay
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import net.schueller.peertube.R
|
||||
import net.schueller.peertube.feature_video.domain.model.RATING_DISLIKE
|
||||
import net.schueller.peertube.feature_video.domain.model.RATING_LIKE
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoPlayEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoPlayViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun VideoActions(
|
||||
video: Video,
|
||||
viewModel: VideoPlayViewModel = hiltViewModel()
|
||||
) {
|
||||
|
||||
val ratingState = viewModel.stateVideoRating.value
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
) {
|
||||
|
||||
val thumbsUpIcon = if (ratingState.rating?.rating === RATING_LIKE) {
|
||||
R.drawable.ic_thumbs_up_filled
|
||||
} else {
|
||||
R.drawable.ic_thumbs_up
|
||||
}
|
||||
|
||||
VideoAction(thumbsUpIcon, "Thumbs Up", video.likes.toString()) {
|
||||
viewModel.onEvent(VideoPlayEvent.UpVoteVideo(video))
|
||||
}
|
||||
|
||||
val thumbsDownIcon = if (ratingState.rating?.rating === RATING_DISLIKE) {
|
||||
R.drawable.ic_thumbs_down_filled
|
||||
} else {
|
||||
R.drawable.ic_thumbs_down
|
||||
}
|
||||
|
||||
VideoAction(thumbsDownIcon, "Thumbs Down", video.dislikes.toString()) {
|
||||
viewModel.onEvent(VideoPlayEvent.DownVoteVideo(video))
|
||||
}
|
||||
|
||||
VideoAction(R.drawable.ic_share_2, "Share", "Share") {
|
||||
viewModel.onEvent(VideoPlayEvent.ShareVideo(video))
|
||||
}
|
||||
|
||||
if (video.downloadEnabled == true) {
|
||||
VideoAction(R.drawable.ic_download, "Download", "Download") {
|
||||
viewModel.onEvent(VideoPlayEvent.DownloadVideo(video))
|
||||
}
|
||||
}
|
||||
|
||||
VideoAction(R.drawable.ic_playlist_add, "Add to Playlist", "Add") {
|
||||
viewModel.onEvent(VideoPlayEvent.AddVideoToPlaylist(video))
|
||||
}
|
||||
|
||||
VideoAction(R.drawable.ic_slash, "Block", "Block") {
|
||||
viewModel.onEvent(VideoPlayEvent.BlockVideo(video))
|
||||
}
|
||||
|
||||
VideoAction(R.drawable.ic_flag, "Flag", "Flag") {
|
||||
viewModel.onEvent(VideoPlayEvent.FlagVideo(video))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoAction(
|
||||
icon: Int,
|
||||
iconText: String,
|
||||
text: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
start = 8.dp,
|
||||
end = 8.dp
|
||||
)
|
||||
.width(56.dp)
|
||||
.clickable(
|
||||
onClick = onClick
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painterResource(id = icon),
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.padding(bottom = 4.dp),
|
||||
contentDescription = iconText
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.videoPlay
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoPlayEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoPlayViewModel
|
||||
|
||||
@Composable
|
||||
fun VideoDescriptionScreen(
|
||||
viewModel: VideoPlayViewModel = hiltViewModel()
|
||||
) {
|
||||
|
||||
val state = viewModel.stateVideoDescription.value
|
||||
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
|
||||
state.description?.let { description ->
|
||||
Column() {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.onEvent(VideoPlayEvent.CloseDescription)
|
||||
}
|
||||
) {
|
||||
|
||||
}
|
||||
Text(
|
||||
text = description.description ?: "",
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Left,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.videoPlay//package net.schueller.peertube.feature_video.presentation.video_play.components
|
||||
//
|
||||
//import android.app.Activity
|
||||
//import android.net.Uri
|
||||
//import android.view.ViewGroup
|
||||
//import android.widget.FrameLayout
|
||||
//import androidx.compose.foundation.layout.fillMaxSize
|
||||
//import androidx.compose.material.Surface
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.compose.runtime.DisposableEffect
|
||||
//import androidx.compose.runtime.LaunchedEffect
|
||||
//import androidx.compose.runtime.remember
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.platform.LocalContext
|
||||
//import androidx.compose.ui.viewinterop.AndroidView
|
||||
//import com.google.android.exoplayer2.C
|
||||
//import com.google.android.exoplayer2.MediaItem
|
||||
//import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
//import com.google.android.exoplayer2.source.dash.DashMediaSource
|
||||
//import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
//import com.google.android.exoplayer2.ui.PlayerView
|
||||
//import com.google.android.exoplayer2.util.Util
|
||||
//import net.schueller.peertube.common.VideoHelper
|
||||
//import net.schueller.peertube.feature_video.domain.model.Video
|
||||
//import net.schueller.peertube.feature_video.presentation.video.player.DataSourceHolder
|
||||
//import net.schueller.peertube.feature_video.presentation.video.player.PlayerViewPool
|
||||
//import net.schueller.peertube.feature_video.presentation.video.player.ExoPlayerHolder
|
||||
//
|
||||
//
|
||||
//@Composable
|
||||
//fun VideoItem(
|
||||
// video: Video,
|
||||
// modifier: Modifier
|
||||
//) {
|
||||
// Surface(
|
||||
// modifier = modifier
|
||||
// ) {
|
||||
// val videoHelper = VideoHelper()
|
||||
//
|
||||
// val context = LocalContext.current
|
||||
// val activity = context as Activity
|
||||
// val exoPlayer = remember { ExoPlayerHolder.get(context) }
|
||||
// var playerView: PlayerView? = null
|
||||
//
|
||||
// LaunchedEffect(videoHelper.pickPlaybackResolution(video)) {
|
||||
// val videoUri = Uri.parse(videoHelper.pickPlaybackResolution(video))
|
||||
// val dataSourceFactory = DataSourceHolder.getCacheFactory(context)
|
||||
// val source = when (Util.inferContentType(videoUri)) {
|
||||
// C.TYPE_DASH -> DashMediaSource.Factory(dataSourceFactory)
|
||||
// .createMediaSource(MediaItem.fromUri(videoUri))
|
||||
// C.TYPE_HLS -> HlsMediaSource.Factory(dataSourceFactory)
|
||||
// .createMediaSource(MediaItem.fromUri(videoUri))
|
||||
// else -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
// .createMediaSource(MediaItem.fromUri(videoUri))
|
||||
// }
|
||||
// exoPlayer.setMediaSource(source)
|
||||
// exoPlayer.prepare()
|
||||
// }
|
||||
//
|
||||
//
|
||||
// AndroidView(
|
||||
//// modifier = Modifier.aspectRatio(video.width.toFloat() / video.height.toFloat()),
|
||||
// factory = { context ->
|
||||
// val frameLayout = FrameLayout(context)
|
||||
//// frameLayout.setBackgroundColor(context.getColor(android.R.color.holo_blue_bright))
|
||||
// frameLayout
|
||||
// },
|
||||
// update = { frameLayout ->
|
||||
// frameLayout.removeAllViews()
|
||||
//
|
||||
// playerView = PlayerViewPool.get(frameLayout.context)
|
||||
// PlayerView.switchTargetView(
|
||||
// exoPlayer,
|
||||
// PlayerViewPool.currentPlayerView,
|
||||
// playerView
|
||||
// )
|
||||
// PlayerViewPool.currentPlayerView = playerView
|
||||
// playerView!!.apply {
|
||||
// player!!.playWhenReady = true
|
||||
// }
|
||||
//
|
||||
// playerView?.apply {
|
||||
// (parent as? ViewGroup)?.removeView(this)
|
||||
// }
|
||||
// frameLayout.addView(
|
||||
// playerView,
|
||||
// FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
// FrameLayout.LayoutParams.MATCH_PARENT
|
||||
// )
|
||||
////
|
||||
//// playerView?.apply {
|
||||
//// (parent as? ViewGroup)?.removeView(this)
|
||||
//// PlayerViewPool.release(this)
|
||||
//// }
|
||||
//// playerView = null
|
||||
//
|
||||
// }
|
||||
// )
|
||||
//
|
||||
// DisposableEffect(key1 = videoHelper.pickPlaybackResolution(video)) {
|
||||
// onDispose {
|
||||
// playerView?.apply {
|
||||
// (parent as? ViewGroup)?.removeView(this)
|
||||
// }
|
||||
// exoPlayer.stop()
|
||||
// playerView?.let {
|
||||
// PlayerViewPool.release(it)
|
||||
// }
|
||||
// playerView = null
|
||||
//
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//
|
|
@ -0,0 +1,168 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.videoPlay
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.ImagePainter
|
||||
import coil.compose.rememberImagePainter
|
||||
import com.google.accompanist.placeholder.PlaceholderHighlight
|
||||
import com.google.accompanist.placeholder.material.fade
|
||||
import com.google.accompanist.placeholder.material.placeholder
|
||||
import net.schueller.peertube.R
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.presentation.common.getCreatorAvatar
|
||||
import net.schueller.peertube.feature_video.presentation.common.getCreatorString
|
||||
import net.schueller.peertube.feature_video.presentation.common.getImageUrl
|
||||
import net.schueller.peertube.feature_video.presentation.common.getMetaDataTag
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoPlayEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoPlayViewModel
|
||||
|
||||
@ExperimentalCoilApi
|
||||
@Composable
|
||||
fun VideoMeta(
|
||||
video: Video,
|
||||
viewModel: VideoPlayViewModel = hiltViewModel()
|
||||
) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
color = MaterialTheme.colors.background
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = {
|
||||
viewModel.onEvent(VideoPlayEvent.OpenDescription(video))
|
||||
},
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(6.dp),
|
||||
text = video.name ?: "",
|
||||
// fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.h6
|
||||
)
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_chevron_down),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.padding(end = 6.dp),
|
||||
// .padding(end = 12.dp),
|
||||
contentDescription = "Open Description"
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
|
||||
text = getMetaDataTag(video.createdAt, video.views, "",true),
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
|
||||
VideoActions(video)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(5.dp)
|
||||
.padding(
|
||||
top = 2.dp,
|
||||
bottom = 2.dp
|
||||
)
|
||||
.background(MaterialTheme.colors.onBackground)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// .padding(end = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(56.dp),
|
||||
) {
|
||||
val avatar = rememberImagePainter(
|
||||
data = getImageUrl(getCreatorAvatar(video)?.path)
|
||||
)
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.width(48.dp)
|
||||
.padding(8.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.placeholder(
|
||||
visible = (avatar.state is ImagePainter.State.Error || avatar.state is ImagePainter.State.Empty),
|
||||
highlight = if (avatar.state is ImagePainter.State.Empty) {
|
||||
PlaceholderHighlight.fade()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
),
|
||||
// contentScale = ContentScale.Crop
|
||||
)
|
||||
Column(
|
||||
// modifier = Modifier
|
||||
// .padding(8.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = video.channel?.name ?: video.account?.name ?: "",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.subtitle1
|
||||
)
|
||||
Text(
|
||||
text = getCreatorString(video, true),
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp),
|
||||
text = "SUBSCRIBE",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary,
|
||||
style = MaterialTheme.typography.button
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(5.dp)
|
||||
.padding(
|
||||
top = 2.dp,
|
||||
bottom = 2.dp
|
||||
)
|
||||
.background(MaterialTheme.colors.onBackground)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.videoPlay
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoPlayViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun VideoMoreScreen(
|
||||
viewModel: VideoPlayViewModel = hiltViewModel()
|
||||
) {
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Settings,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.width(48.dp)
|
||||
.padding(8.dp),
|
||||
contentDescription = "Quality",
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = "Quality",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Settings,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.width(48.dp)
|
||||
.padding(8.dp),
|
||||
contentDescription = "Captions",
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = "Captions",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.PlayArrow,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.width(48.dp)
|
||||
.padding(8.dp),
|
||||
contentDescription = "Playback Speed",
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = "Playback Speed",
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,384 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.videoPlay
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.*
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import com.google.accompanist.systemuicontroller.SystemUiController
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoPlayViewModel
|
||||
import net.schueller.peertube.feature_video.presentation.video.components.VideoListItem
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoPlayEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.player.ExoPlayerHolder
|
||||
|
||||
|
||||
@OptIn(ExperimentalMotionApi::class)
|
||||
@ExperimentalCoilApi
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun VideoPlayScreen(
|
||||
exoPlayerHolder: ExoPlayerHolder,
|
||||
videoPlayViewModel: VideoPlayViewModel = hiltViewModel()
|
||||
) {
|
||||
val state = videoPlayViewModel.state.value
|
||||
val context = LocalContext.current
|
||||
|
||||
var descriptionVisible by remember { mutableStateOf(false) }
|
||||
var moreVisible by remember { mutableStateOf(false) }
|
||||
|
||||
// Show toasts
|
||||
LaunchedEffect(key1 = true) {
|
||||
videoPlayViewModel.eventFlow.collectLatest { event ->
|
||||
when(event) {
|
||||
is VideoPlayViewModel.UiEvent.ShowToast -> {
|
||||
Toast.makeText(
|
||||
context,
|
||||
event.message,
|
||||
event.length
|
||||
).show()
|
||||
}
|
||||
is VideoPlayViewModel.UiEvent.ShowDescription -> {
|
||||
descriptionVisible = true
|
||||
}
|
||||
is VideoPlayViewModel.UiEvent.HideDescription -> {
|
||||
descriptionVisible = false
|
||||
}
|
||||
is VideoPlayViewModel.UiEvent.ShowMore -> {
|
||||
moreVisible = true
|
||||
}
|
||||
is VideoPlayViewModel.UiEvent.HideMore -> {
|
||||
moreVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Related Videos
|
||||
val lazyRelatedVideoItems: LazyPagingItems<Video> = videoPlayViewModel.relatedVideos.collectAsLazyPagingItems()
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
|
||||
var animateToEnd by remember { mutableStateOf(false) }
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = if (animateToEnd) 1f else 0f,
|
||||
animationSpec = tween(250)
|
||||
)
|
||||
|
||||
state.video?.let { video ->
|
||||
|
||||
val systemUiController: SystemUiController = rememberSystemUiController()
|
||||
|
||||
when(configuration.orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
// Full screen
|
||||
// TODO: bottom min bar show on click of screen (Asus Zenfone / Pixel 4)
|
||||
systemUiController.isStatusBarVisible = false
|
||||
systemUiController.isNavigationBarVisible = false
|
||||
systemUiController.isSystemBarsVisible = false
|
||||
|
||||
VideoScreen(exoPlayerHolder, video, Modifier)
|
||||
|
||||
} else -> {
|
||||
systemUiController.isStatusBarVisible = true
|
||||
systemUiController.isNavigationBarVisible = true
|
||||
systemUiController.isSystemBarsVisible = true
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(Color.Transparent).fillMaxSize()
|
||||
) {
|
||||
MotionLayout(
|
||||
// Large
|
||||
start = ConstraintSet(
|
||||
""" {
|
||||
background: {
|
||||
width: "spread",
|
||||
height: 400,
|
||||
start: ['parent', 'start', 0],
|
||||
end: ['parent', 'end', 0],
|
||||
top: ['parent', 'top', 0]
|
||||
},
|
||||
v1: {
|
||||
width: "spread",
|
||||
height: 250,
|
||||
start: ['parent', 'start', 0],
|
||||
end: ['parent', 'end', 0],
|
||||
top: ['parent', 'top', 0]
|
||||
},
|
||||
title: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
start: ['parent', 'start', 16],
|
||||
top: ['v1', 'bottom', 16],
|
||||
end: ['parent', 'end', 16],
|
||||
custom: {
|
||||
textSize: 20
|
||||
}
|
||||
},
|
||||
description: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
start: ['parent', 'start', 16],
|
||||
top: ['title', 'bottom', 8],
|
||||
end: ['parent', 'end', 16],
|
||||
custom: {
|
||||
textSize: 16
|
||||
}
|
||||
},
|
||||
list: {
|
||||
width: "spread",
|
||||
height: 500,
|
||||
start: ['parent', 'start', 0],
|
||||
end: ['parent', 'end', 0],
|
||||
top: ['description', 'bottom', 0]
|
||||
},
|
||||
play: {
|
||||
start: ['parent', 'end', 8],
|
||||
top: ['v1', 'top', 0],
|
||||
bottom: ['v1', 'bottom', 0]
|
||||
},
|
||||
close: {
|
||||
start: ['parent', 'end', 8],
|
||||
top: ['v1', 'top', 0],
|
||||
bottom: ['v1', 'bottom', 0]
|
||||
}
|
||||
} """
|
||||
),
|
||||
// small
|
||||
end = ConstraintSet(
|
||||
""" {
|
||||
background: {
|
||||
width: "spread",
|
||||
height: 60,
|
||||
start: ['parent', 'start', 0],
|
||||
bottom: ['parent', 'bottom', 56],
|
||||
end: ['parent', 'end', 0]
|
||||
},
|
||||
v1: {
|
||||
width: 100,
|
||||
height: 60,
|
||||
start: ['parent', 'start', 0],
|
||||
bottom: ['parent', 'bottom', 56]
|
||||
},
|
||||
title: {
|
||||
width: "spread",
|
||||
start: ['v1', 'end', 8],
|
||||
top: ['v1', 'top', 8],
|
||||
end: ['parent', 'end', 8],
|
||||
custom: {
|
||||
textSize: 16
|
||||
}
|
||||
},
|
||||
description: {
|
||||
start: ['v1', 'end', 8],
|
||||
top: ['title', 'bottom', 0],
|
||||
custom: {
|
||||
textSize: 14
|
||||
}
|
||||
},
|
||||
list: {
|
||||
width: "spread",
|
||||
height: 0,
|
||||
start: ['parent', 'start', 0],
|
||||
end: ['parent', 'end', 0],
|
||||
top: ['parent', 'bottom', 0]
|
||||
},
|
||||
play: {
|
||||
end: ['close', 'start', 8],
|
||||
top: ['v1', 'top', 0],
|
||||
bottom: ['v1', 'bottom', 0]
|
||||
},
|
||||
close: {
|
||||
end: ['parent', 'end', 24],
|
||||
top: ['v1', 'top', 0],
|
||||
bottom: ['v1', 'bottom', 0]
|
||||
}
|
||||
} """
|
||||
),
|
||||
progress = progress,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId("background", "box")
|
||||
.background(Color.Blue)
|
||||
.clickable(onClick = { animateToEnd = !animateToEnd })
|
||||
)
|
||||
|
||||
VideoScreen(exoPlayerHolder, video,
|
||||
modifier = Modifier
|
||||
.layoutId("v1", "box")
|
||||
.background(Color.Black)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "MotionLayout in Compose",
|
||||
modifier = Modifier.layoutId("title")
|
||||
.clickable(onClick = { animateToEnd = !animateToEnd }),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontSize = motionProperties("title").value.fontSize("textSize")
|
||||
)
|
||||
Text(
|
||||
text = "Demo screen 17",
|
||||
modifier = Modifier.layoutId("description"),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontSize = motionProperties("description").value.fontSize("textSize")
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId("list", "box")
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
VideoMeta(video)
|
||||
}
|
||||
|
||||
itemsIndexed(lazyRelatedVideoItems) { _, video ->
|
||||
if (video != null) {
|
||||
VideoListItem(
|
||||
video = video,
|
||||
onItemClick = {
|
||||
videoPlayViewModel.onEvent(VideoPlayEvent.PlayVideo(video))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
AnimatedVisibility(
|
||||
visible = descriptionVisible,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { it }, // it == fullWidth
|
||||
animationSpec = tween(
|
||||
durationMillis = 150,
|
||||
easing = LinearEasing
|
||||
)
|
||||
),
|
||||
exit = slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(
|
||||
durationMillis = 150,
|
||||
easing = LinearEasing
|
||||
)
|
||||
)
|
||||
) {
|
||||
VideoDescriptionScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
Icons.Filled.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.layoutId("play")
|
||||
)
|
||||
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = "Close",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.layoutId("close").clickable(
|
||||
onClick = {
|
||||
videoPlayViewModel.onEvent(VideoPlayEvent.CloseVideo)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = moreVisible,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { it }, // it == fullWidth
|
||||
animationSpec = tween(
|
||||
durationMillis = 150,
|
||||
easing = LinearEasing
|
||||
)
|
||||
),
|
||||
exit = slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(
|
||||
durationMillis = 150,
|
||||
easing = LinearEasing
|
||||
)
|
||||
)
|
||||
) {
|
||||
VideoMoreScreen()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if(state.error.isNotBlank()) {
|
||||
Text(
|
||||
text = state.error,
|
||||
color = MaterialTheme.colors.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
if(state.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
// BackHandler(enabled = true) {
|
||||
// Log.v("back", "back pressed")
|
||||
//
|
||||
// enterPIPMode(activity)
|
||||
// }
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.components.videoPlay
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.android.exoplayer2.ui.PlayerView
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import net.schueller.peertube.feature_video.presentation.video.player.ExoPlayerHolder
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import net.schueller.peertube.R
|
||||
import net.schueller.peertube.feature_video.presentation.video.events.VideoPlayEvent
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoPlayViewModel
|
||||
import net.schueller.peertube.feature_video.presentation.video.player.PlayerViewPool
|
||||
|
||||
@Composable
|
||||
fun VideoScreen(
|
||||
exoPlayerHolder: ExoPlayerHolder,
|
||||
video: Video,
|
||||
modifier: Modifier,
|
||||
viewModel: VideoPlayViewModel = hiltViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val player = exoPlayerHolder.setVideo(video, context)
|
||||
|
||||
val activity = LocalContext.current as Activity
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
// modifier = Modifier.aspectRatio(video.width.toFloat() / video.height.toFloat()),
|
||||
factory = { fContext ->
|
||||
val frameLayout = FrameLayout(fContext)
|
||||
frameLayout
|
||||
},
|
||||
update = { frameLayout ->
|
||||
|
||||
Log.v("VideoScreen", "Android view update")
|
||||
|
||||
frameLayout.removeAllViews()
|
||||
|
||||
Log.v("VideoScreen", "frameLayout removal")
|
||||
|
||||
|
||||
val playerView = PlayerViewPool.get(frameLayout.context)
|
||||
playerView.player = player
|
||||
|
||||
Log.v("VideoScreen", "playerView assign")
|
||||
|
||||
|
||||
PlayerView.switchTargetView(
|
||||
player,
|
||||
PlayerViewPool.currentPlayerView,
|
||||
playerView
|
||||
)
|
||||
|
||||
Log.v("VideoScreen", "switchTargetView")
|
||||
|
||||
|
||||
PlayerViewPool.currentPlayerView = playerView
|
||||
frameLayout.addView(
|
||||
playerView,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
Log.v("VideoScreen", "currentPlayerView")
|
||||
|
||||
|
||||
// Video More Button
|
||||
val videoMoreButton = playerView.findViewById<FrameLayout>(R.id.exo_more_button)
|
||||
videoMoreButton.setOnClickListener {
|
||||
viewModel.onEvent(VideoPlayEvent.MoreButton)
|
||||
}
|
||||
|
||||
Log.v("VideoScreen", "videoMoreButton")
|
||||
|
||||
|
||||
// TODO: does not update on orientation gesture
|
||||
val enterFullscreenIcon = playerView.findViewById<ImageButton>(R.id.exo_fullscreen_enable)
|
||||
val exitFullscreenIcon = playerView.findViewById<ImageButton>(R.id.exo_fullscreen_disable)
|
||||
|
||||
if (configuration.orientation != Configuration.ORIENTATION_LANDSCAPE) {
|
||||
enterFullscreenIcon.visibility = View.GONE
|
||||
exitFullscreenIcon.visibility = View.VISIBLE
|
||||
} else {
|
||||
enterFullscreenIcon.visibility = View.VISIBLE
|
||||
exitFullscreenIcon.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
||||
// TODO: locks out orientation gesture
|
||||
val fullscreenButton = playerView.findViewById<FrameLayout>(R.id.exo_fullscreen_button)
|
||||
// fullscreenButton.setOnClickListener {
|
||||
// if (configuration.orientation != Configuration.ORIENTATION_LANDSCAPE) {
|
||||
// Log.v("VP", "Go full screen")
|
||||
// activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
// } else {
|
||||
// Log.v("VP", "Exit full screen")
|
||||
// activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
Log.v("VideoScreen", "end")
|
||||
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.events
|
||||
|
||||
const val SET_DISCOVER = "discover"
|
||||
const val SET_LOCAL = "local"
|
||||
const val SET_RECENT = "recent"
|
||||
const val SET_TRENDING = "trending"
|
||||
const val SET_SUBSCRIPTIONS = "subscriptions"
|
||||
|
||||
sealed class VideoListEvent {
|
||||
data class UpdateQuery(
|
||||
val set: String?
|
||||
): VideoListEvent()
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.events
|
||||
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
|
||||
sealed class VideoPlayEvent {
|
||||
data class ShareVideo(val video: Video): VideoPlayEvent()
|
||||
data class UpVoteVideo(val video: Video): VideoPlayEvent()
|
||||
data class DownVoteVideo(val video: Video): VideoPlayEvent()
|
||||
data class DownloadVideo(val video: Video): VideoPlayEvent()
|
||||
data class AddVideoToPlaylist(val video: Video): VideoPlayEvent()
|
||||
data class FlagVideo(val video: Video): VideoPlayEvent()
|
||||
data class BlockVideo(val video: Video): VideoPlayEvent()
|
||||
data class OpenDescription(val video: Video): VideoPlayEvent()
|
||||
object CloseDescription: VideoPlayEvent()
|
||||
object MoreButton: VideoPlayEvent()
|
||||
data class PlayVideo(val video: Video): VideoPlayEvent()
|
||||
object CloseVideo: VideoPlayEvent()
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.player
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
|
||||
object CacheHolder {
|
||||
private var cache: SimpleCache? = null
|
||||
private val lock = Object()
|
||||
|
||||
fun get(context: Context): SimpleCache {
|
||||
synchronized(lock) {
|
||||
if (cache == null) {
|
||||
val cacheSize = 20L * 1024 * 1024
|
||||
val exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
cache = SimpleCache(
|
||||
context.cacheDir,
|
||||
LeastRecentlyUsedCacheEvictor(cacheSize),
|
||||
exoDatabaseProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
return cache!!
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.player
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.FileDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSink
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
|
||||
object DataSourceHolder {
|
||||
private var cacheDataSourceFactory: CacheDataSource.Factory? = null
|
||||
private var defaultDataSourceFactory: DataSource.Factory? = null
|
||||
|
||||
fun getCacheFactory(context: Context): CacheDataSource.Factory {
|
||||
if (cacheDataSourceFactory == null) {
|
||||
val simpleCache = CacheHolder.get(context)
|
||||
val defaultFactory = getDefaultFactory(context)
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(defaultFactory)
|
||||
.setCacheReadDataSourceFactory(FileDataSource.Factory())
|
||||
.setCacheWriteDataSinkFactory(
|
||||
CacheDataSink.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setFragmentSize(CacheDataSink.DEFAULT_FRAGMENT_SIZE)
|
||||
)
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory!!
|
||||
}
|
||||
|
||||
private fun getDefaultFactory(context: Context): DataSource.Factory {
|
||||
if (defaultDataSourceFactory == null) {
|
||||
defaultDataSourceFactory = DefaultDataSourceFactory(
|
||||
context,
|
||||
Util.getUserAgent(context, context.packageName)
|
||||
)
|
||||
}
|
||||
return defaultDataSourceFactory!!
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.source.dash.DashMediaSource
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import net.schueller.peertube.common.VideoHelper
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
object ExoPlayerHolder {
|
||||
private var exoplayer: ExoPlayer? = null
|
||||
private var currentVideo: Video? = null
|
||||
private val videoHelper = VideoHelper()
|
||||
|
||||
|
||||
fun unPauseVideo() {
|
||||
exoplayer?.playWhenReady = true
|
||||
}
|
||||
|
||||
val isPaused: Boolean
|
||||
get() = !exoplayer!!.playWhenReady
|
||||
|
||||
fun destroyVideo() {
|
||||
exoplayer = null
|
||||
}
|
||||
|
||||
fun pauseVideo() {
|
||||
exoplayer?.playWhenReady = false
|
||||
}
|
||||
|
||||
|
||||
fun setVideo(video: Video, context: Context): ExoPlayer {
|
||||
|
||||
if (exoplayer == null) {
|
||||
exoplayer = createExoPlayer(context)
|
||||
}
|
||||
|
||||
// check if its the same video
|
||||
if (currentVideo === null || currentVideo?.uuid !== video.uuid) {
|
||||
|
||||
val videoUri = Uri.parse(videoHelper.pickPlaybackResolution(video))
|
||||
val dataSourceFactory = DataSourceHolder.getCacheFactory(context)
|
||||
val source = when (Util.inferContentType(videoUri)) {
|
||||
C.TYPE_DASH -> DashMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(videoUri))
|
||||
C.TYPE_HLS -> HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(videoUri))
|
||||
else -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(videoUri))
|
||||
}
|
||||
exoplayer!!.setMediaSource(source)
|
||||
exoplayer!!.prepare()
|
||||
exoplayer!!.playWhenReady = true
|
||||
|
||||
currentVideo = video
|
||||
}
|
||||
|
||||
return exoplayer!!
|
||||
}
|
||||
|
||||
private fun createExoPlayer(context: Context): ExoPlayer {
|
||||
return ExoPlayer.Builder(context)
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder().setBufferDurationsMs(
|
||||
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
|
||||
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS / 10,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS / 10
|
||||
).build()
|
||||
)
|
||||
.build()
|
||||
.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ONE
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.player
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import androidx.core.util.Pools
|
||||
import com.google.android.exoplayer2.ui.PlayerView
|
||||
import net.schueller.peertube.R
|
||||
|
||||
object PlayerViewPool {
|
||||
var currentPlayerView: PlayerView? = null
|
||||
|
||||
private val playerViewPool = Pools.SimplePool<PlayerView>(2)
|
||||
|
||||
fun get(context: Context): PlayerView {
|
||||
return playerViewPool.acquire() ?: createPlayerView(context)
|
||||
}
|
||||
|
||||
fun release(player: PlayerView) {
|
||||
playerViewPool.release(player)
|
||||
}
|
||||
|
||||
private fun createPlayerView(context: Context): PlayerView {
|
||||
return (LayoutInflater.from(context).inflate(R.layout.exoplayer_texture_view, null, false) as PlayerView)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.states
|
||||
|
||||
import net.schueller.peertube.feature_video.domain.model.Description
|
||||
|
||||
data class VideoDescriptionState(
|
||||
val isLoading: Boolean = false,
|
||||
val description: Description? = null,
|
||||
val error: String = ""
|
||||
)
|
|
@ -0,0 +1,11 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.states
|
||||
|
||||
data class VideoListState(
|
||||
val sort: String? = null,
|
||||
val filter: String? = null,
|
||||
val nsfw: String? = null,
|
||||
val languages: Set<String?>? = null,
|
||||
val explore: Boolean = false,
|
||||
val local: Boolean = false,
|
||||
val subscriptions: Boolean = false
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.states
|
||||
|
||||
import net.schueller.peertube.feature_video.domain.model.Video
|
||||
|
||||
data class VideoPlayState(
|
||||
val isLoading: Boolean = false,
|
||||
val video: Video? = null,
|
||||
val error: String = ""
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
package net.schueller.peertube.feature_video.presentation.video.states
|
||||
|
||||
import net.schueller.peertube.feature_video.domain.model.Rating
|
||||
|
||||
data class VideoRatingState(
|
||||
val isLoading: Boolean = false,
|
||||
val rating: Rating? = null,
|
||||
val error: String = ""
|
||||
)
|
|
@ -1,8 +1,18 @@
|
|||
package net.schueller.peertube.presentation
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
|
@ -20,26 +30,38 @@ import androidx.navigation.navArgument
|
|||
import coil.annotation.ExperimentalCoilApi
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import net.schueller.peertube.common.Constants.APP_BACKGROUND_AUDIO_INTENT
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_AUDIO_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_BEHAVIOR_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_FLOAT_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACKGROUND_STOP_KEY
|
||||
import net.schueller.peertube.common.Constants.PREF_BACK_PAUSE_KEY
|
||||
import net.schueller.peertube.common.VideoHelper
|
||||
import net.schueller.peertube.feature_server_address.presentation.address_add_edit.AddEditAddressScreen
|
||||
import net.schueller.peertube.feature_settings.settings.SettingsScreen
|
||||
import net.schueller.peertube.presentation.ui.theme.PeertubeTheme
|
||||
import net.schueller.peertube.feature_video.presentation.video_list.VideoListScreen
|
||||
import net.schueller.peertube.feature_video.presentation.video_play.VideoPlayScreen
|
||||
import net.schueller.peertube.feature_video.presentation.video.VideoListScreen
|
||||
import net.schueller.peertube.feature_server_address.presentation.ServerAddressScreen
|
||||
import net.schueller.peertube.feature_server_address.presentation.address_list.AddressListScreen
|
||||
import net.schueller.peertube.feature_server_address.presentation.server_list.ServerListScreen
|
||||
import net.schueller.peertube.feature_video.presentation.me.MeScreen
|
||||
import net.schueller.peertube.feature_video.presentation.video_play.components.VideoDescriptionScreen
|
||||
import net.schueller.peertube.feature_video.presentation.video_play.player.ExoPlayerHolder
|
||||
import net.schueller.peertube.feature_video.presentation.video.player.ExoPlayerHolder
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
var enteringPIPMode: Boolean = false
|
||||
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
|
||||
val exoPlayerHolder = ExoPlayerHolder
|
||||
|
||||
private val videoHelper = VideoHelper()
|
||||
|
||||
@ExperimentalCoilApi
|
||||
@ExperimentalPermissionsApi
|
||||
@ExperimentalComposeUiApi
|
||||
|
@ -82,18 +104,18 @@ class MainActivity : ComponentActivity() {
|
|||
composable(
|
||||
route = Screen.VideoListScreen.route
|
||||
) {
|
||||
VideoListScreen(navController)
|
||||
}
|
||||
composable(
|
||||
route = Screen.VideoPlayScreen.route + "/{uuid}"
|
||||
) {
|
||||
VideoPlayScreen(exoPlayerHolder, navController)
|
||||
}
|
||||
composable(
|
||||
route = Screen.VideoDescriptionScreen.route + "/{uuid}"
|
||||
) {
|
||||
VideoDescriptionScreen()
|
||||
VideoListScreen(navController, exoPlayerHolder)
|
||||
}
|
||||
// composable(
|
||||
// route = Screen.VideoPlayScreen.route + "/{uuid}"
|
||||
// ) {
|
||||
// VideoPlayScreen(exoPlayerHolder, navController)
|
||||
// }
|
||||
// composable(
|
||||
// route = Screen.VideoDescriptionScreen.route + "/{uuid}"
|
||||
// ) {
|
||||
// VideoDescriptionScreen()
|
||||
// }
|
||||
composable(
|
||||
route = Screen.SettingsScreen.route
|
||||
) {
|
||||
|
@ -136,4 +158,195 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// @RequiresApi(api = Build.VERSION_CODES.O)
|
||||
@SuppressLint("NewApi")
|
||||
override fun onBackPressed() {
|
||||
Log.v(TAG, "onBackPressed()...")
|
||||
val sharedPref = getSharedPreferences(packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
// val videoPlayerFragment =
|
||||
// (supportFragmentManager.findFragmentById(R.id.video_player_fragment) as VideoPlayerFragment?)!!
|
||||
|
||||
// copying Youtube behavior to have back button exit full screen.
|
||||
// TODO
|
||||
// if (videoPlayerFragment.getIsFullscreen()) {
|
||||
// Log.v(TAG, "exiting full screen")
|
||||
// videoPlayerFragment.fullScreenToggle()
|
||||
// return
|
||||
// }
|
||||
// pause video if pref is enabled
|
||||
if (sharedPref.getBoolean(PREF_BACK_PAUSE_KEY, true)) {
|
||||
exoPlayerHolder.pauseVideo()
|
||||
}
|
||||
val backgroundBehavior = sharedPref.getString(
|
||||
PREF_BACKGROUND_BEHAVIOR_KEY,
|
||||
PREF_BACKGROUND_STOP_KEY
|
||||
)!!
|
||||
if (backgroundBehavior == PREF_BACKGROUND_STOP_KEY) {
|
||||
Log.v(TAG, "stop the video")
|
||||
exoPlayerHolder.pauseVideo()
|
||||
stopService(Intent(this, MainActivity::class.java))
|
||||
super.onBackPressed()
|
||||
} else if (backgroundBehavior == PREF_BACKGROUND_AUDIO_KEY) {
|
||||
Log.v(TAG, "play the Audio")
|
||||
super.onBackPressed()
|
||||
} else if (backgroundBehavior == PREF_BACKGROUND_FLOAT_KEY) {
|
||||
Log.v(TAG, "play in floating video")
|
||||
//canEnterPIPMode makes sure API level is high enough
|
||||
if (videoHelper.canEnterPipMode(this)) {
|
||||
Log.v(TAG, "enabling pip")
|
||||
enterPIPMode()
|
||||
//fixes problem where back press doesn't bring up video list after returning from PIP mode
|
||||
val intentSettings = Intent(this, MainActivity::class.java)
|
||||
this.startActivity(intentSettings)
|
||||
} else {
|
||||
Log.v(TAG, "Unable to enter PIP mode")
|
||||
super.onBackPressed()
|
||||
}
|
||||
} else {
|
||||
// Deal with bad entries from older version
|
||||
Log.v(TAG, "No setting, fallback")
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun enterPIPMode(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (enteringPIPMode) {
|
||||
return true
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
) {
|
||||
enteringPIPMode = true
|
||||
val params = PictureInPictureParams.Builder().build()
|
||||
try {
|
||||
this.enterPictureInPictureMode(params)
|
||||
return true
|
||||
} catch (ex: IllegalStateException) {
|
||||
// pass
|
||||
enteringPIPMode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
//This can only be called when in entering pip mode which can't happen if the device doesn't support pip mode.
|
||||
@SuppressLint("NewApi")
|
||||
fun makePipControls() {
|
||||
// val fragmentManager = supportFragmentManager
|
||||
// val videoPlayerFragment =
|
||||
// fragmentManager.findFragmentById(R.id.video_player_fragment) as VideoPlayerFragment?
|
||||
val actions = ArrayList<RemoteAction>()
|
||||
var actionIntent = Intent(APP_BACKGROUND_AUDIO_INTENT)
|
||||
var pendingIntent =
|
||||
PendingIntent.getBroadcast(applicationContext, REQUEST_CODE, actionIntent, 0)
|
||||
@SuppressLint("NewApi", "LocalSuppress") var icon = Icon.createWithResource(
|
||||
applicationContext, android.R.drawable.stat_sys_speakerphone
|
||||
)
|
||||
@SuppressLint("NewApi", "LocalSuppress") var remoteAction =
|
||||
RemoteAction(icon!!, "close pip", "from pip window custom command", pendingIntent!!)
|
||||
actions.add(remoteAction)
|
||||
actionIntent = Intent(PlayerNotificationManager.ACTION_STOP)
|
||||
pendingIntent =
|
||||
PendingIntent.getBroadcast(applicationContext, REQUEST_CODE, actionIntent, 0)
|
||||
icon = Icon.createWithResource(
|
||||
applicationContext,
|
||||
com.google.android.exoplayer2.ui.R.drawable.exo_notification_stop
|
||||
)
|
||||
remoteAction = RemoteAction(icon, "play", "stop the media", pendingIntent)
|
||||
actions.add(remoteAction)
|
||||
// assert(videoPlayerFragment != null)
|
||||
if (exoPlayerHolder.isPaused) {
|
||||
Log.e(TAG, "setting actions with play button")
|
||||
actionIntent = Intent(PlayerNotificationManager.ACTION_PLAY)
|
||||
pendingIntent =
|
||||
PendingIntent.getBroadcast(applicationContext, REQUEST_CODE, actionIntent, 0)
|
||||
icon = Icon.createWithResource(
|
||||
applicationContext,
|
||||
com.google.android.exoplayer2.ui.R.drawable.exo_notification_play
|
||||
)
|
||||
remoteAction = RemoteAction(icon, "play", "play the media", pendingIntent)
|
||||
} else {
|
||||
Log.e(TAG, "setting actions with pause button")
|
||||
actionIntent = Intent(PlayerNotificationManager.ACTION_PAUSE)
|
||||
pendingIntent =
|
||||
PendingIntent.getBroadcast(applicationContext, REQUEST_CODE, actionIntent, 0)
|
||||
icon = Icon.createWithResource(
|
||||
applicationContext,
|
||||
com.google.android.exoplayer2.ui.R.drawable.exo_notification_pause
|
||||
)
|
||||
remoteAction = RemoteAction(icon, "pause", "pause the media", pendingIntent)
|
||||
}
|
||||
actions.add(remoteAction)
|
||||
|
||||
|
||||
//add custom actions to pip window
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setActions(actions)
|
||||
.build()
|
||||
setPictureInPictureParams(params)
|
||||
}
|
||||
|
||||
fun showControls(value: Boolean) {
|
||||
// TODO
|
||||
// exoPlayerView!!.useController = value
|
||||
}
|
||||
|
||||
|
||||
private fun changedToPipMode() {
|
||||
// val fragmentManager = supportFragmentManager
|
||||
// val videoPlayerFragment =
|
||||
// (fragmentManager.findFragmentById(R.id.video_player_fragment) as VideoPlayerFragment?)!!
|
||||
showControls(false)
|
||||
//create custom actions
|
||||
makePipControls()
|
||||
|
||||
//setup receiver to handle customer actions
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(PlayerNotificationManager.ACTION_STOP)
|
||||
filter.addAction(PlayerNotificationManager.ACTION_PAUSE)
|
||||
filter.addAction(PlayerNotificationManager.ACTION_PLAY)
|
||||
filter.addAction(APP_BACKGROUND_AUDIO_INTENT)
|
||||
receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action!!
|
||||
if (action == PlayerNotificationManager.ACTION_PAUSE) {
|
||||
exoPlayerHolder.pauseVideo()
|
||||
makePipControls()
|
||||
}
|
||||
if (action == PlayerNotificationManager.ACTION_PLAY) {
|
||||
exoPlayerHolder.unPauseVideo()
|
||||
makePipControls()
|
||||
}
|
||||
if (action == APP_BACKGROUND_AUDIO_INTENT) {
|
||||
unregisterReceiver(receiver)
|
||||
finish()
|
||||
}
|
||||
if (action == PlayerNotificationManager.ACTION_STOP) {
|
||||
unregisterReceiver(receiver)
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
registerReceiver(receiver, filter)
|
||||
Log.v(TAG, "switched to pip ")
|
||||
floatMode = true
|
||||
showControls(false)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
var floatMode = false
|
||||
private const val REQUEST_CODE = 101
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -2,8 +2,6 @@ package net.schueller.peertube.presentation
|
|||
|
||||
sealed class Screen(val route: String) {
|
||||
object VideoListScreen: Screen("video_list_screen")
|
||||
object VideoPlayScreen: Screen("video_play_screen")
|
||||
object VideoDescriptionScreen: Screen("video_description_screen")
|
||||
object SettingsScreen: Screen("settings_screen")
|
||||
object MeScreen: Screen("me_screen")
|
||||
}
|
|
@ -12,11 +12,12 @@
|
|||
|
||||
<FrameLayout
|
||||
android:id="@+id/exo_more_button"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="42dp"
|
||||
android:layout_gravity="end">
|
||||
|
||||
<ImageButton
|
||||
android:clickable="false"
|
||||
android:id="@+id/exo_more"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
|
@ -161,7 +162,8 @@
|
|||
android:layout_gravity="end">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/exo_fullscreen"
|
||||
android:clickable="false"
|
||||
android:id="@+id/exo_fullscreen_enable"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
|
@ -170,8 +172,24 @@
|
|||
android:background="@android:color/transparent"
|
||||
android:contentDescription="Fullscreen"
|
||||
android:src="@drawable/ic_maximize"
|
||||
android:visibility="gone"
|
||||
/>
|
||||
|
||||
<ImageButton
|
||||
android:clickable="false"
|
||||
android:id="@+id/exo_fullscreen_disable"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:adjustViewBounds="true"
|
||||
android:scaleType="fitCenter"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="Exit Fullscreen"
|
||||
android:src="@drawable/ic_maximize"
|
||||
android:visibility="gone"
|
||||
/>
|
||||
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
|
|
@ -263,18 +263,6 @@
|
|||
<string name="video_speed_10">Normal</string>
|
||||
<string name="video_speed_15">1,5×</string>
|
||||
<string name="video_speed_20">2×</string>
|
||||
<string name="video_option_speed_icon" translatable="false">{faw-play-circle}</string>
|
||||
<string name="video_option_quality_icon" translatable="false">{faw-cog}</string>
|
||||
<string name="video_speed_active_icon" translatable="false">{faw-check}</string>
|
||||
<string name="video_quality_active_icon" translatable="false">{faw-check}</string>
|
||||
<string name="video_expand_icon" translatable="false">{faw-expand}</string>
|
||||
<string name="video_compress_icon" translatable="false">{faw-compress}</string>
|
||||
<string name="video_more_icon" translatable="false">{faw-ellipsis-v}</string>
|
||||
<string name="video_thumbs_up_icon" translatable="false">{faw-thumbs-up}</string>
|
||||
<string name="video_thumbs_down_icon" translatable="false">{faw-thumbs-down}</string>
|
||||
<string name="video_share_icon" translatable="false">{faw-share}</string>
|
||||
<string name="video_download_icon" translatable="false">{faw-download}</string>
|
||||
<string name="video_save_icon" translatable="false">{faw-save}</string>
|
||||
<string name="pref_title_background_play">Arka Planda Oynatma</string>
|
||||
<string name="pref_description_background_play">Etkinleştirilirse, arka planda izleti oynatmaya devam eder.</string>
|
||||
<string name="bottom_nav_title_local">Yerel</string>
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
<string name="pref_language_app">Application language</string>
|
||||
<string name="pref_title_app_theme">App Theme</string>
|
||||
<string name="pref_title_dark_mode">Dark Mode</string>
|
||||
<string name="pref_description_dark_mode">Restart App for Dark Mode to take effect.</string>
|
||||
<string name="settings_activity_video_list_category_title">Video List</string>
|
||||
<string name="pref_title_show_nsfw">NSFW Content</string>
|
||||
<string name="pref_description_show_nsfw">Show NSFW content</string>
|
||||
|
@ -31,6 +30,10 @@
|
|||
<string name="pref_title_license">License</string>
|
||||
<string name="pref_description_license">\n<b>GNU Affero General Public License v3.0</b>\n\nPermissions of this strongest copyleft license are conditioned on making available complete source code of licensed works and modifications, which include larger works using a licensed work, under the same license. Copyright and license notices must be preserved. Contributors provide an express grant of patent rights. When a modified version is used to provide a service over a network, the complete source code of the modified version must be made available.</string>
|
||||
|
||||
<string name="pref_background_audio">Continue as a background audio stream</string>
|
||||
<string name="pref_background_stop">Stop all playback</string>
|
||||
<string name="pref_background_float">Continue playing video in floating window</string>
|
||||
|
||||
|
||||
<!-- languages -->
|
||||
<string name="iso_lang_ab">Abkhazian</string>
|
||||
|
@ -227,5 +230,364 @@
|
|||
<string name="iso_lang_za">Zhuang</string>
|
||||
<string name="iso_lang_zu">Zulu</string>
|
||||
|
||||
<string name="title_activity_settings">Settings</string>
|
||||
<string name="title_activity_login">Sign in</string>
|
||||
<!-- Strings related to login -->
|
||||
<string name="prompt_server">Server</string>
|
||||
<string name="prompt_email">Email / Username</string>
|
||||
<string name="prompt_password">Password</string>
|
||||
<string name="action_sign_in">Sign in</string>
|
||||
<string name="action_sign_in_short">Sign in</string>
|
||||
<string name="error_invalid_email">This email address is invalid</string>
|
||||
<string name="error_invalid_password">This password is too short</string>
|
||||
<string name="error_incorrect_password">This password is incorrect</string>
|
||||
<string name="error_field_required">This field is required</string>
|
||||
<string name="permission_rationale">Grant contact permission for e-mail completion.</string>
|
||||
<!-- Action bar -->
|
||||
<string name="action_bar_title_search">Search</string>
|
||||
<string name="action_bar_title_settings">Settings</string>
|
||||
<string name="action_bar_title_logout">Log out</string>
|
||||
<string name="action_bar_title_account">Account</string>
|
||||
<!-- Bottom navigation bar -->
|
||||
<string name="bottom_nav_title_discover">Discover</string>
|
||||
<string name="bottom_nav_title_trending">Trending</string>
|
||||
<string name="bottom_nav_title_recent">Recent</string>
|
||||
<string name="bottom_nav_title_local">Local</string>
|
||||
<string name="bottom_nav_title_subscriptions">Subscriptions</string>
|
||||
<string name="bottom_nav_title_account">Account</string>
|
||||
<!-- Strings related to Settings -->
|
||||
<!-- Strings related to Video meta data -->
|
||||
<string name="video_row_video_thumbnail">Video Thumbnail</string>
|
||||
<string name="video_row_account_avatar">Account Avatar</string>
|
||||
<string name="title_activity_url_video_play">UrlVideoPlayActivity</string>
|
||||
<string name="search_hint">Search PeerTube</string>
|
||||
<string name="title_activity_search">Search</string>
|
||||
<string name="no_data_available">No Results</string>
|
||||
<string name="descr_overflow_button">More</string>
|
||||
<string name="menu_share">Share</string>
|
||||
<string name="invalid_url">Invalid URL.</string>
|
||||
<!-- settings/preferences -->
|
||||
<string name="pref_description_dark_mode">Restart App for Dark Mode to take effect.</string>
|
||||
<string name="pref_description_app_theme">Restart app for theme to take effect.</string>
|
||||
<string name="pref_title_torrent_player">Torrent Video Player</string>
|
||||
<string name="pref_description_torrent_player">Video playback via a torrent stream. This requires Storage Permissions. (Alpha, not stable!)</string>
|
||||
<string name="pref_title_peertube_server">PeerTube Server</string>
|
||||
<string name="pref_title_background_play">Background Playback</string>
|
||||
<string name="pref_description_background_play">If enabled, continues to play video in background.</string>
|
||||
<string name="pref_description_video_speed">Select the global Video Playback Speed</string>
|
||||
<string name="pref_description_language_app">Select language for application interface. Restart app for change to take effect.</string>
|
||||
<string name="settings_api_error_float">Android version does not support floating video</string>
|
||||
<string name="settings_permissions_error_float">Picture in picture permission is disabled for this app in Android Settings</string>
|
||||
<string name="pref_background_behavior_summary">How a playing video responds when going to background</string>
|
||||
|
||||
<!-- languages -->
|
||||
<string name="ab">Abkhazian</string>
|
||||
<string name="aa">Afar</string>
|
||||
<string name="af">Afrikaans</string>
|
||||
<string name="ak">Akan</string>
|
||||
<string name="sq">Albanian</string>
|
||||
<string name="ase">American Sign Language</string>
|
||||
<string name="am">Amharic</string>
|
||||
<string name="ar">Arabic</string>
|
||||
<string name="an">Aragonese</string>
|
||||
<string name="hy">Armenian</string>
|
||||
<string name="as">Assamese</string>
|
||||
<string name="av">Avaric</string>
|
||||
<string name="ay">Aymara</string>
|
||||
<string name="az">Azerbaijani</string>
|
||||
<string name="bm">Bambara</string>
|
||||
<string name="ba">Bashkir</string>
|
||||
<string name="eu">Basque</string>
|
||||
<string name="be">Belarusian</string>
|
||||
<string name="bn">Bengali</string>
|
||||
<string name="bn_rBD">Bengali (Bangladesh)</string>
|
||||
<string name="bi">Bislama</string>
|
||||
<string name="bs">Bosnian</string>
|
||||
<string name="bzs">Brazilian Sign Language</string>
|
||||
<string name="br">Breton</string>
|
||||
<string name="bfi">British Sign Language</string>
|
||||
<string name="bg">Bulgarian</string>
|
||||
<string name="my">Burmese</string>
|
||||
<string name="ca">Catalan</string>
|
||||
<string name="ch">Chamorro</string>
|
||||
<string name="ce">Chechen</string>
|
||||
<string name="zh">Chinese</string>
|
||||
<string name="csl">Chinese Sign Language</string>
|
||||
<string name="cv">Chuvash</string>
|
||||
<string name="kw">Cornish</string>
|
||||
<string name="co">Corsican</string>
|
||||
<string name="cr">Cree</string>
|
||||
<string name="hr">Croatian</string>
|
||||
<string name="cs">Czech</string>
|
||||
<string name="cse">Czech Sign Language</string>
|
||||
<string name="da">Danish</string>
|
||||
<string name="dsl">Danish Sign Language</string>
|
||||
<string name="dv">Dhivehi</string>
|
||||
<string name="nl">Dutch</string>
|
||||
<string name="dz">Dzongkha</string>
|
||||
<string name="en">English</string>
|
||||
<string name="eo">Esperanto</string>
|
||||
<string name="et">Estonian</string>
|
||||
<string name="ee">Ewe</string>
|
||||
<string name="fo">Faroese</string>
|
||||
<string name="fj">Fijian</string>
|
||||
<string name="fi">Finnish</string>
|
||||
<string name="fr">French</string>
|
||||
<string name="fsl">French Sign Language</string>
|
||||
<string name="ff">Fulah</string>
|
||||
<string name="gl">Galician</string>
|
||||
<string name="lg">Ganda</string>
|
||||
<string name="ka">Georgian</string>
|
||||
<string name="de">German</string>
|
||||
<string name="gsg">German Sign Language</string>
|
||||
<string name="gn">Guarani</string>
|
||||
<string name="gu">Gujarati</string>
|
||||
<string name="ht">Haitian</string>
|
||||
<string name="ha">Hausa</string>
|
||||
<string name="he">Hebrew</string>
|
||||
<string name="hz">Herero</string>
|
||||
<string name="hi">Hindi</string>
|
||||
<string name="ho">Hiri Motu</string>
|
||||
<string name="hu">Hungarian</string>
|
||||
<string name="is">Icelandic</string>
|
||||
<string name="ig">Igbo</string>
|
||||
<string name="id">Indonesian</string>
|
||||
<string name="iu">Inuktitut</string>
|
||||
<string name="ik">Inupiaq</string>
|
||||
<string name="ga">Irish</string>
|
||||
<string name="it">Italian</string>
|
||||
<string name="ja">Japanese</string>
|
||||
<string name="jsl">Japanese Sign Language</string>
|
||||
<string name="jv">Javanese</string>
|
||||
<string name="kl">Kalaallisut</string>
|
||||
<string name="kn">Kannada</string>
|
||||
<string name="kr">Kanuri</string>
|
||||
<string name="ks">Kashmiri</string>
|
||||
<string name="kk">Kazakh</string>
|
||||
<string name="km">Khmer</string>
|
||||
<string name="ki">Kikuyu</string>
|
||||
<string name="rw">Kinyarwanda</string>
|
||||
<string name="ky">Kirghiz</string>
|
||||
<string name="tlh">Klingon</string>
|
||||
<string name="kv">Komi</string>
|
||||
<string name="kg">Kongo</string>
|
||||
<string name="ko">Korean</string>
|
||||
<string name="avk">Kotava</string>
|
||||
<string name="kj">Kuanyama</string>
|
||||
<string name="ku">Kurdish</string>
|
||||
<string name="lo">Lao</string>
|
||||
<string name="lv">Latvian</string>
|
||||
<string name="li">Limburgan</string>
|
||||
<string name="ln">Lingala</string>
|
||||
<string name="lt">Lithuanian</string>
|
||||
<string name="jbo">Lojban</string>
|
||||
<string name="lu">Luba-Katanga</string>
|
||||
<string name="lb">Luxembourgish</string>
|
||||
<string name="mk">Macedonian</string>
|
||||
<string name="mg">Malagasy</string>
|
||||
<string name="ms">Malay (macrolanguage)</string>
|
||||
<string name="ml">Malayalam</string>
|
||||
<string name="mt">Maltese</string>
|
||||
<string name="gv">Manx</string>
|
||||
<string name="mi">Maori</string>
|
||||
<string name="mr">Marathi</string>
|
||||
<string name="mh">Marshallese</string>
|
||||
<string name="el">Modern Greek (1453-)</string>
|
||||
<string name="mn">Mongolian</string>
|
||||
<string name="na">Nauru</string>
|
||||
<string name="nv">Navajo</string>
|
||||
<string name="ng">Ndonga</string>
|
||||
<string name="ne">Nepali (macrolanguage)</string>
|
||||
<string name="nd">North Ndebele</string>
|
||||
<string name="se">Northern Sami</string>
|
||||
<string name="no">Norwegian</string>
|
||||
<string name="nb">Norwegian Bokmål</string>
|
||||
<string name="nn">Norwegian Nynorsk</string>
|
||||
<string name="ny">Nyanja</string>
|
||||
<string name="oc">Occitan</string>
|
||||
<string name="oj">Ojibwa</string>
|
||||
<string name="or">Oriya (macrolanguage)</string>
|
||||
<string name="om">Oromo</string>
|
||||
<string name="os">Ossetian</string>
|
||||
<string name="pks">Pakistan Sign Language</string>
|
||||
<string name="pa">Panjabi</string>
|
||||
<string name="fa">Persian</string>
|
||||
<string name="pl">Polish</string>
|
||||
<string name="pt">Portuguese</string>
|
||||
<string name="ps">Pushto</string>
|
||||
<string name="qu">Quechua</string>
|
||||
<string name="ro">Romanian</string>
|
||||
<string name="rm">Romansh</string>
|
||||
<string name="rn">Rundi</string>
|
||||
<string name="ru">Russian</string>
|
||||
<string name="rsl">Russian Sign Language</string>
|
||||
<string name="sm">Samoan</string>
|
||||
<string name="sg">Sango</string>
|
||||
<string name="sc">Sardinian</string>
|
||||
<string name="sdl">Saudi Arabian Sign Language</string>
|
||||
<string name="gd">Scottish Gaelic</string>
|
||||
<string name="sr">Serbian</string>
|
||||
<string name="sh">Serbo-Croatian</string>
|
||||
<string name="sn">Shona</string>
|
||||
<string name="ii">Sichuan Yi</string>
|
||||
<string name="sd">Sindhi</string>
|
||||
<string name="si">Sinhala</string>
|
||||
<string name="sk">Slovak</string>
|
||||
<string name="sl">Slovenian</string>
|
||||
<string name="so">Somali</string>
|
||||
<string name="sfs">South African Sign Language</string>
|
||||
<string name="nr">South Ndebele</string>
|
||||
<string name="st">Southern Sotho</string>
|
||||
<string name="es">Spanish</string>
|
||||
<string name="su">Sundanese</string>
|
||||
<string name="sw">Swahili (macrolanguage)</string>
|
||||
<string name="ss">Swati</string>
|
||||
<string name="sv">Swedish</string>
|
||||
<string name="swl">Swedish Sign Language</string>
|
||||
<string name="tl">Tagalog</string>
|
||||
<string name="ty">Tahitian</string>
|
||||
<string name="tg">Tajik</string>
|
||||
<string name="ta">Tamil</string>
|
||||
<string name="tt">Tatar</string>
|
||||
<string name="te">Telugu</string>
|
||||
<string name="th">Thai</string>
|
||||
<string name="bo">Tibetan</string>
|
||||
<string name="ti">Tigrinya</string>
|
||||
<string name="to">Tonga (Tonga Islands)</string>
|
||||
<string name="ts">Tsonga</string>
|
||||
<string name="tn">Tswana</string>
|
||||
<string name="tr">Turkish</string>
|
||||
<string name="tk">Turkmen</string>
|
||||
<string name="tw">Twi</string>
|
||||
<string name="ug">Uighur</string>
|
||||
<string name="uk">Ukrainian</string>
|
||||
<string name="ur">Urdu</string>
|
||||
<string name="uz">Uzbek</string>
|
||||
<string name="ve">Venda</string>
|
||||
<string name="vi">Vietnamese</string>
|
||||
<string name="wa">Walloon</string>
|
||||
<string name="cy">Welsh</string>
|
||||
<string name="fy">Western Frisian</string>
|
||||
<string name="wo">Wolof</string>
|
||||
<string name="xh">Xhosa</string>
|
||||
<string name="yi">Yiddish</string>
|
||||
<string name="yo">Yoruba</string>
|
||||
<string name="za">Zhuang</string>
|
||||
<string name="zu">Zulu</string>
|
||||
<!-- colors -->
|
||||
<string name="red">Red</string>
|
||||
<string name="pink">Pink</string>
|
||||
<string name="purple">Purple</string>
|
||||
<string name="deeppurple">Deep Purple</string>
|
||||
<string name="indigo">Indigo</string>
|
||||
<string name="blue">Blue</string>
|
||||
<string name="lightblue">Light Blue</string>
|
||||
<string name="cyan">Cyan</string>
|
||||
<string name="teal">Teal</string>
|
||||
<string name="green">Green</string>
|
||||
<string name="lightgreen">Light Green</string>
|
||||
<string name="lime">Lime</string>
|
||||
<string name="yellow">Yellow</string>
|
||||
<string name="amber">Amber</string>
|
||||
<string name="orange">Orange</string>
|
||||
<string name="deeporange">Deep Orange</string>
|
||||
<string name="brown">Brown</string>
|
||||
<string name="gray">Gray</string>
|
||||
<string name="bluegray">Bluegray</string>
|
||||
<string name="video_speed_05">0.5x</string>
|
||||
<string name="video_speed_10">Normal</string>
|
||||
<string name="video_speed_15">1.5x</string>
|
||||
<string name="video_speed_20">2x</string>
|
||||
<string name="action_set_url">Select Server</string>
|
||||
<string name="server_selection_signup_allowed">Signup Allowed: %s</string>
|
||||
<string name="server_selection_signup_allowed_yes">Yes</string>
|
||||
<string name="server_selection_signup_allowed_no">No</string>
|
||||
<string name="server_selection_set_server">Server set to: %s</string>
|
||||
<string name="server_selection_select_a_server">Select a server from the list below or enter it directly.</string>
|
||||
<string name="server_selection_peertube_server_url">PeerTube Server URL</string>
|
||||
<string name="server_selection_filter_hint">Filter list</string>
|
||||
<string name="title_activity_account">Account</string>
|
||||
<string name="menu_video_more_report">Report</string>
|
||||
<string name="menu_video_more_blacklist">Blacklist</string>
|
||||
<string name="video_download_permission_error">Can not download video without write permission</string>
|
||||
<string name="video_rating_failed">Rating Failed</string>
|
||||
<string name="video_login_required_for_service">You must log in to use this service</string>
|
||||
<string name="video_meta_button_share">Share</string>
|
||||
<string name="video_meta_button_download">Download</string>
|
||||
<string name="video_meta_button_privacy">Privacy</string>
|
||||
<string name="video_meta_button_category">Category</string>
|
||||
<string name="video_meta_button_license">License</string>
|
||||
<string name="video_meta_button_language">Language</string>
|
||||
<string name="video_meta_button_tags">Tags</string>
|
||||
<string name="menu_video_options_playback_speed" formatted="true">Playback speed (%1$s)</string>
|
||||
<string name="menu_video_options_quality" formatted="true">Quality (%1$s)</string>
|
||||
<string name="account_bottom_menu_videos">Videos</string>
|
||||
<string name="account_bottom_menu_channels">Channels</string>
|
||||
<string name="account_bottom_menu_about">About</string>
|
||||
<string name="account_about_account">Account:</string>
|
||||
<string name="account_about_subscribers">Subscribers:</string>
|
||||
<string name="account_about_description">Description:</string>
|
||||
<string name="account_about_joined">Joined:</string>
|
||||
<string name="api_error">Something went wrong, please try later!</string>
|
||||
<string name="network_error">Network access error, please check your connectivity</string>
|
||||
<string name="action_bar_title_server_selection">Select Server</string>
|
||||
<string name="hello_blank_fragment">Hello blank fragment</string>
|
||||
<string name="action_bar_title_address_book">Address Book</string>
|
||||
<string name="authentication_login_success">Logged in</string>
|
||||
<string name="authentication_login_failed">Login Failed!</string>
|
||||
<string name="server_book_no_servers_found">Server Books is empty</string>
|
||||
<string name="server_book_label_is_required">Server label is required</string>
|
||||
<string name="server_book_valid_url_is_required">Valid URL is required</string>
|
||||
<string name="me_logout_button">Log Out</string>
|
||||
<string name="me_help_and_feedback_button"><![CDATA[Help & Feedback]]></string>
|
||||
<string name="server_book_add_label">Label</string>
|
||||
<string name="server_book_add_server_url">Server URL</string>
|
||||
<string name="server_book_add_pick_server_button">Search</string>
|
||||
<string name="server_book_add_username">Username</string>
|
||||
<string name="server_book_add_password">Password</string>
|
||||
<string name="server_book_add_add_button">Add</string>
|
||||
<string name="server_book_add_save_button">Save</string>
|
||||
<string name="server_book_list_has_login">Has Login</string>
|
||||
<string name="login_current_server_hint">Current Server</string>
|
||||
<string name="title_activity_server_address_book">Address Book</string>
|
||||
<string name="video_speed_075">0.75x</string>
|
||||
<string name="video_speed_125">1.25x</string>
|
||||
<string name="server_book_del_alert_title">Remove Server</string>
|
||||
<string name="server_book_del_alert_msg">Are you sure you want to remove this server from the address book?</string>
|
||||
<string name="title_activity_select_server">Search Server</string>
|
||||
<string name="title_activity_me">Account</string>
|
||||
<string name="title_activity_settings2">SettingsActivity2</string>
|
||||
<string name="server_selection_nsfw_instance">NSFW Instance</string>
|
||||
<string name="server_selection_video_totals" formatted="false">Videos: %s, Local Videos: %s</string>
|
||||
<string name="menu_video_options_quality_automated">Automated</string>
|
||||
<string name="authentication_token_refresh_failed">Could not refresh token</string>
|
||||
<string name="authentication_token_refresh_success">Token refreshed</string>
|
||||
<string name="pref_insecure_confirm_title">Warning!</string>
|
||||
<string name="pref_insecure_confirm_no">No</string>
|
||||
<string name="pref_insecure_confirm_yes">Yes</string>
|
||||
<string name="pref_insecure_confirm_message">You are about the disable all SSL Certification validation in Thorium. Disabling this can be very dangerous if the peertube server is not under your control, because a man-in-the-middle attack could direct traffic to another server without your knowledge. An attacker could record passwords and other personal data.</string>
|
||||
<string name="video_list_live_marker">LIVE</string>
|
||||
<string name="video_get_full_description_failed">Getting full video description failed</string>
|
||||
<string name="video_description_read_more">Read More</string>
|
||||
<string name="video_meta_show_description">Show Description</string>
|
||||
<string name="video_meta_title_description">Description</string>
|
||||
<string name="video_add_to_playlist">Save</string>
|
||||
<string name="video_block">Block</string>
|
||||
<string name="video_flag">Flag</string>
|
||||
<string name="video_feature_not_yet_implemented">This feature has not yet been implemented. Coming soon!</string>
|
||||
<string name="subscribe">Subscribe</string>
|
||||
<string name="unsubscribe">Unsubscribe</string>
|
||||
<string name="video_comments_title">Comments</string>
|
||||
<plurals name="video_channel_subscribers">
|
||||
<item quantity="one">%1$d subscriber</item>
|
||||
<item quantity="other">%1$d subscribers</item>
|
||||
</plurals>
|
||||
<string name="video_by_line">By %1$s</string>
|
||||
<string name="video_sub_del_alert_title">Unsubscribe</string>
|
||||
<string name="video_sub_del_alert_msg">Are you sure you would like to unsubscribe?</string>
|
||||
<string name="saved_to_playlist">Saved to playlist</string>
|
||||
<string name="remove_video">Remove Video</string>
|
||||
<string name="remove_video_warning_message">Are you sure you want to remove this video from playlist?</string>
|
||||
<string name="playlist">Playlist</string>
|
||||
|
||||
</resources>
|
Loading…
Reference in New Issue