feat: import update

This commit is contained in:
Stefan Schüller 2022-02-04 13:56:55 +01:00
parent efb18fc747
commit 390c2244e7
66 changed files with 3542 additions and 178 deletions

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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

View File

@ -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"
}

View File

@ -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()

View File

@ -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
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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() }
}
}
}
}
}
}

View File

@ -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
}
}

View File

@ -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
)

View File

@ -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,

View File

@ -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,

View File

@ -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)
)

View File

@ -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(

View File

@ -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()
}

View File

@ -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())
}

View File

@ -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")
}
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -1,5 +1,5 @@
package net.schueller.peertube.feature_video.domain.model
data class Description (
val description: String
val description: String? = ""
)

View File

@ -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

View File

@ -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

View File

@ -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."))
}
}
}

View File

@ -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."))
}
}

View File

@ -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,
)

View File

@ -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

View File

@ -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()
}

View File

@ -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)
)
}
}
}
// }

View File

@ -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()
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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")
}
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
)
}
}
}
}
}

View File

@ -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)
}

View File

@ -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!!)
}

View File

@ -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
}
}
)
}
}
}

View File

@ -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
)
}

View File

@ -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")
}
)
}
)
}

View File

@ -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
)
}
}

View File

@ -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)
)
}
}
}
}

View File

@ -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
//
// }
// }
//
// }
//}
//
//

View File

@ -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)
)
}
}

View File

@ -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)
)
}
}
}
}

View File

@ -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)
// }
}

View File

@ -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")
}
)
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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!!
}
}

View File

@ -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!!
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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 = ""
)

View File

@ -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
)

View File

@ -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 = ""
)

View File

@ -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 = ""
)

View File

@ -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
}
}

View File

@ -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")
}

View File

@ -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>

View File

@ -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>

View File

@ -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>