Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay

This commit is contained in:
Koen J
2025-11-19 14:48:44 +01:00
45 changed files with 764 additions and 140 deletions
+4
View File
@@ -245,5 +245,9 @@
android:name=".activities.PolycentricModerationActivity" android:name=".activities.PolycentricModerationActivity"
android:exported="false" android:exported="false"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity
android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application> </application>
</manifest> </manifest>
@@ -725,7 +725,7 @@ class Settings : FragmentedStorageFileJson() {
@AdvancedField @AdvancedField
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6) @FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var experimentalCasting: Boolean = false var experimentalCasting: Boolean = true
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -252,6 +252,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.toast(this, "Notification permission denied"); UIDialogs.toast(this, "Notification permission denied");
}; };
fun requestNotificationPermissions() { fun requestNotificationPermissions() {
_notificationPermissionLauncher?.launch(_notifPermission); _notificationPermissionLauncher?.launch(_notifPermission);
} }
@@ -1379,6 +1381,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
); );
} }
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionAudio?.invoke(isGranted);
});
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionVideo?.invoke(isGranted);
});
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionAudio = cb;
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
}
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionVideo = cb;
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
}
val notifPermission = "android.permission.POST_NOTIFICATIONS"; val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
@@ -13,15 +13,18 @@ import android.view.View
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateApp.Companion.withContext import com.futo.platformplayer.states.StateApp.Companion.withContext
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.SignedEvent import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeCRDTItem import com.futo.polycentric.core.StorageTypeCRDTItem
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.toBase64Url import com.futo.polycentric.core.toBase64Url
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
class PolycentricBackupActivity : AppCompatActivity() { class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _buttonShare: BigButton; private lateinit var _buttonShare: BigButton;
private lateinit var _buttonCopy: BigButton; private lateinit var _buttonCopy: BigButton;
private lateinit var _buttonExportFile: BigButton;
private lateinit var _imageQR: ImageView; private lateinit var _imageQR: ImageView;
private lateinit var _exportBundle: String; private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView; private lateinit var _textQR: TextView;
private lateinit var _textQRHint: TextView;
private lateinit var _loader: View private lateinit var _loader: View
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
uri?.let { fileUri ->
try {
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
outputStream.write(_exportBundle.toByteArray())
}
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
} catch (e: Exception) {
Logger.e(TAG, "Failed to write to document", e)
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
}
}
}
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
} }
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
_buttonShare = findViewById(R.id.button_share) _buttonShare = findViewById(R.id.button_share)
_buttonCopy = findViewById(R.id.button_copy) _buttonCopy = findViewById(R.id.button_copy)
_buttonExportFile = findViewById(R.id.button_export_file)
_imageQR = findViewById(R.id.image_qr) _imageQR = findViewById(R.id.image_qr)
_textQR = findViewById(R.id.text_qr) _textQR = findViewById(R.id.text_qr)
_textQRHint = findViewById(R.id.text_qr_hint)
_loader = findViewById(R.id.progress_loader) _loader = findViewById(R.id.progress_loader)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish(); finish();
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
_imageQR.visibility = View.INVISIBLE _imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE _textQR.visibility = View.INVISIBLE
_textQRHint.visibility = View.INVISIBLE
_loader.visibility = View.VISIBLE _loader.visibility = View.VISIBLE
_buttonShare.visibility = View.INVISIBLE _buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE _buttonCopy.visibility = View.INVISIBLE
_buttonExportFile.visibility = View.INVISIBLE
lifecycleScope.launch { lifecycleScope.launch {
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
_exportBundle = bundle
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
try { try {
val pair = withContext(Dispatchers.IO) { val pair = withContext(Dispatchers.IO) {
val bundle = createExportBundle() if (!isContentSuitableForQRCode(bundle)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension( val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
).toInt() ).toInt()
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
Pair(bundle, qr) Pair(bundle, qr)
} }
_exportBundle = pair.first
_imageQR.setImageBitmap(pair.second) _imageQR.setImageBitmap(pair.second)
_imageQR.visibility = View.VISIBLE _imageQR.visibility = View.VISIBLE
_textQR.visibility = View.VISIBLE _textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.VISIBLE
_buttonShare.visibility = View.VISIBLE _buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE _buttonCopy.visibility = View.VISIBLE
_imageQR.setOnClickListener {
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
startActivity(intent)
}
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e) val byteSize = bundle.toByteArray(Charsets.UTF_8).size
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
if (e.message?.contains("Data too big") == true) {
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
_buttonExportFile.visibility = View.VISIBLE
} else {
_textQR.text = getString(R.string.failed_to_generate_qr_code)
}
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.INVISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
// Hide QR image since generation failed
_imageQR.visibility = View.INVISIBLE _imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
} finally { } finally {
_loader.visibility = View.GONE _loader.visibility = View.GONE
} }
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle); val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip); clipboard.setPrimaryClip(clip);
}; };
_buttonExportFile.onClick.subscribe {
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
_createDocumentLauncher.launch(fileName)
};
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
} }
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap { private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height); if (!isContentSuitableForQRCode(content)) {
return bitMatrixToBitmap(bitMatrix); throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
} }
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap { private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
.setBody(exportBundle.toByteString()) .setBody(exportBundle.toByteString())
.build(); .build();
return "polycentric://" + urlInfo.toByteArray().toBase64Url() val data = urlInfo.toByteArray()
return "polycentric://" + data.toBase64Url()
} }
companion object { companion object {
@@ -32,100 +32,166 @@ import userpackage.Protocol
import userpackage.Protocol.ExportBundle import userpackage.Protocol.ExportBundle
class PolycentricImportProfileActivity : AppCompatActivity() { class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton; private lateinit var _buttonHelp: ImageButton
private lateinit var _buttonScanProfile: LinearLayout; private lateinit var _buttonScanProfile: LinearLayout
private lateinit var _buttonImportProfile: LinearLayout; private lateinit var _buttonImportFile: LinearLayout
private lateinit var _editProfile: EditText; private lateinit var _buttonImportProfile: LinearLayout
private lateinit var _loaderOverlay: LoaderOverlay; private lateinit var _editProfile: EditText
private lateinit var _loaderOverlay: LoaderOverlay
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _qrCodeResultLauncher =
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
scanResult?.let { val scanResult =
if (it.contents != null) { IntentIntegrator.parseActivityResult(result.resultCode, result.data)
val scannedUrl = it.contents scanResult?.let {
import(scannedUrl) if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
private val _filePickerLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { fileUri ->
try {
// Check file size before reading
val fileSize =
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
if (fileSize > maxFileSize) {
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
return@let
}
if (fileSize == 0L) {
UIDialogs.toast(this, "Selected file is empty.")
return@let
}
val content =
contentResolver
.openInputStream(fileUri)
?.bufferedReader()
?.readText()
content?.let { fileContent ->
val trimmedContent = fileContent.trim()
// Check if content is empty after trimming
if (trimmedContent.isEmpty()) {
UIDialogs.toast(this, "Selected file contains no data.")
return@let
}
// Check if content looks like a valid polycentric URL
if (!trimmedContent.startsWith("polycentric://")) {
UIDialogs.toast(
this,
"Selected file does not contain a valid polycentric profile URL."
)
return@let
}
import(trimmedContent)
}
?: run { UIDialogs.toast(this, "Could not read file content.") }
} catch (e: SecurityException) {
Logger.e(TAG, "Security exception reading file", e)
UIDialogs.toast(this, "Permission denied to read file.")
} catch (e: OutOfMemoryError) {
Logger.e(TAG, "Out of memory reading file", e)
UIDialogs.toast(this, "File too large to process.")
} catch (e: Exception) {
Logger.e(TAG, "Failed to read file", e)
UIDialogs.toast(this, "Failed to read file: ${e.message}")
}
}
} }
}
}
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_import_profile); setContentView(R.layout.activity_polycentric_import_profile)
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons()
_buttonHelp = findViewById(R.id.button_help); _buttonHelp = findViewById(R.id.button_help)
_buttonScanProfile = findViewById(R.id.button_scan_profile); _buttonScanProfile = findViewById(R.id.button_scan_profile)
_buttonImportProfile = findViewById(R.id.button_import_profile); _buttonImportFile = findViewById(R.id.button_import_file)
_loaderOverlay = findViewById(R.id.loader_overlay); _buttonImportProfile = findViewById(R.id.button_import_profile)
_editProfile = findViewById(R.id.edit_profile); _loaderOverlay = findViewById(R.id.loader_overlay)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { _editProfile = findViewById(R.id.edit_profile)
finish(); findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
};
_buttonHelp.setOnClickListener { _buttonHelp.setOnClickListener {
startActivity(Intent(this, PolycentricWhyActivity::class.java)); startActivity(Intent(this, PolycentricWhyActivity::class.java))
}; }
_buttonScanProfile.setOnClickListener { _buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this) val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code)) integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true); integrator.setOrientationLocked(true)
integrator.setCameraId(0) integrator.setCameraId(0)
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true) integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java); integrator.setCaptureActivity(QRCaptureActivity::class.java)
_qrCodeResultLauncher.launch(integrator.createScanIntent()) _qrCodeResultLauncher.launch(integrator.createScanIntent())
}; }
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
_buttonImportProfile.setOnClickListener { _buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) { if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data)); UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
return@setOnClickListener; return@setOnClickListener
} }
import(_editProfile.text.toString()); import(_editProfile.text.toString())
}; }
val url = intent.getStringExtra("url"); val url = intent.getStringExtra("url")
if (url != null) { if (url != null) {
import(url); import(url)
} }
} }
private fun import(url: String) { private fun import(url: String) {
if (!url.startsWith("polycentric://")) { if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, getString(R.string.not_a_valid_url)); UIDialogs.toast(this, getString(R.string.not_a_valid_url))
return; return
} }
_loaderOverlay.show() _loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val data = url.substring("polycentric://".length).base64UrlToByteArray(); val data = url.substring("polycentric://".length).base64UrlToByteArray()
val urlInfo = Protocol.URLInfo.parseFrom(data); val urlInfo = Protocol.URLInfo.parseFrom(data)
if (urlInfo.urlType != 3L) { if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle") throw Exception("Expected urlInfo struct of type ExportBundle")
} }
val exportBundle = ExportBundle.parseFrom(urlInfo.body); val exportBundle = ExportBundle.parseFrom(urlInfo.body)
val keyPair = KeyPair.fromProto(exportBundle.keyPair); val keyPair = KeyPair.fromProto(exportBundle.keyPair)
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
if (existingProcessSecret != null) { if (existingProcessSecret != null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported)); UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.this_profile_is_already_imported)
)
} }
return@launch; return@launch
} }
val processSecret = ProcessSecret(keyPair, Process.random()); val processSecret = ProcessSecret(keyPair, Process.random())
Store.instance.addProcessSecret(processSecret); Store.instance.addProcessSecret(processSecret)
try { try {
PolycentricStorage.instance.addProcessSecret(processSecret) PolycentricStorage.instance.addProcessSecret(processSecret)
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
Logger.e(TAG, "Failed to save process secret to secret storage.", e) Logger.e(TAG, "Failed to save process secret to secret storage.", e)
} }
val processHandle = processSecret.toProcessHandle(); val processHandle = processSecret.toProcessHandle()
for (e in exportBundle.events.eventsList) { for (e in exportBundle.events.eventsList) {
try { try {
val se = SignedEvent.fromProto(e); val se = SignedEvent.fromProto(e)
Store.instance.putSignedEvent(se); Store.instance.putSignedEvent(se)
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e); Logger.w(TAG, "Ignored invalid event", e)
} }
} }
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle)
processHandle.fullyBackfillClient(ApiMethods.SERVER); processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java)); startActivity(
finish(); Intent(
this@PolycentricImportProfileActivity,
PolycentricProfileActivity::class.java
)
)
finish()
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e); Logger.w(TAG, "Failed to import profile", e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'"); UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.failed_to_import_profile) + " '${e.message}'"
)
} }
} finally { } finally {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { _loaderOverlay.hide() }
_loaderOverlay.hide();
}
} }
} }
} }
companion object { companion object {
private const val TAG = "PolycentricImportProfileActivity"; private const val TAG = "PolycentricImportProfileActivity"
} }
} }
@@ -0,0 +1,109 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
class QRCodeFullscreenActivity : AppCompatActivity() {
companion object {
private const val EXTRA_QR_TEXT = "qr_text"
fun createIntent(context: Context, qrText: String): android.content.Intent {
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
putExtra(EXTRA_QR_TEXT, qrText)
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_qr_code_fullscreen)
setNavigationBarColorAndIcons()
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
// Generate QR code bitmap from text
qrText?.let { text ->
try {
if (!isContentSuitableForQRCode(text)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
).toInt()
val qrBitmap = generateQRCode(text, dimension, dimension)
imageQR.setImageBitmap(qrBitmap)
} catch (e: Exception) {
// If QR generation fails, show error or fallback
imageQR.setImageResource(R.drawable.ic_qr)
}
}
buttonBack.setOnClickListener {
finish()
}
buttonClose.setOnClickListener {
finish()
}
imageQR.setOnClickListener {
finish()
}
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
}
}
return bmp
}
}
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -39,7 +40,7 @@ import java.time.OffsetDateTime
import kotlin.math.max import kotlin.math.max
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment { abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
protected val _feedRoot: FrameLayout; protected val _feedRoot: ConstraintLayout;
protected val _recyclerResults: RecyclerView; protected val _recyclerResults: RecyclerView;
protected val _overlayContainer: FrameLayout; protected val _overlayContainer: FrameLayout;
protected val _swipeRefresh: SwipeRefreshLayout; protected val _swipeRefresh: SwipeRefreshLayout;
@@ -52,6 +53,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _emptyPagerContainer: FrameLayout; private val _emptyPagerContainer: FrameLayout;
protected val _toolbarContentView: LinearLayout; protected val _toolbarContentView: LinearLayout;
protected val _bottomContentView: LinearLayout;
private var _loading: Boolean = true; private var _loading: Boolean = true;
@@ -136,6 +138,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
setActiveTags(null); setActiveTags(null);
_toolbarContentView = findViewById(R.id.container_toolbar_content); _toolbarContentView = findViewById(R.id.container_toolbar_content);
_bottomContentView = findViewById(R.id.container_bottom);
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, { _nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>) if (it is IAsyncPager<*>)
@@ -319,8 +319,7 @@ class LibraryArtistFragment : MainFragment() {
_fragment.topBar?.onShown(channel) _fragment.topBar?.onShown(channel)
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { val buttons = arrayListOf<Pair<Int, ()->Unit>>();
})
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch) val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
@@ -337,8 +336,7 @@ class LibraryArtistFragment : MainFragment() {
} }
_buttonSubscribe.visibility = GONE; _buttonSubscribe.visibility = GONE;
_buttonSubscriptionSettings.visibility = _buttonSubscriptionSettings.visibility = View.GONE
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
_textChannel.text = channel.name _textChannel.text = channel.name
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums"; _textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
@@ -506,7 +504,10 @@ class LibraryArtistFragment : MainFragment() {
val playlist = _artist?.toPlaylist(); val playlist = _artist?.toPlaylist();
if (playlist != null) { if (playlist != null) {
val index = playlist.videos.indexOf(c); val sameVideo = playlist.videos.find { it.name == c.name };
val index = sameVideo?.let {
playlist.videos.indexOf(sameVideo)
} ?: -1;
if (index == -1) if (index == -1)
return@subscribe; return@subscribe;
@@ -8,25 +8,32 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.FileEntry import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateLibrary import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.buttons.ButtonsContainer
class LibraryFilesFragment : MainFragment() { class LibraryFilesFragment : MainFragment() {
override val isMainView : Boolean = true; override val isMainView : Boolean = true;
@@ -70,6 +77,7 @@ class LibraryFilesFragment : MainFragment() {
private var root: FileEntry? = null; private var root: FileEntry? = null;
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) { constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
disableRefreshLayout();
} }
fun onShown(parameter: Any? = null) { fun onShown(parameter: Any? = null) {
@@ -139,6 +147,27 @@ class LibraryFilesFragment : MainFragment() {
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files)); setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
setLoading(false); setLoading(false);
val allSongs = stack.files.filter { !it.isDirectory };
if(allSongs.any()) {
_bottomContentView.addView(ButtonsContainer(context,
listOf(
ButtonsContainer.Button("Play All", R.drawable.background_button_primary) {
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
}), focus = true, shuffle = false)
},
ButtonsContainer.Button("Shuffle", R.drawable.background_button_accent) {
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
}), focus = true, shuffle = true)
}
)).apply {
this.layoutParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
})
}
else
_bottomContentView.removeAllViews();
fragment.topBar?.let { fragment.topBar?.let {
if(it is FilesTopBarFragment) { if(it is FilesTopBarFragment) {
if(navStack.size > 1) if(navStack.size > 1)
@@ -93,14 +93,18 @@ class LibraryFragment : MainFragment() {
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, UIDialogs.showDialog(requireContext(), R.drawable.ic_library,
"Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1, "Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1,
UIDialogs.Action("Ok", { UIDialogs.Action("Ok", {
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO); StateApp?.instance?.activity?.requestPermissionAudio {
setPermissionResultAudio(it);
}
}, UIDialogs.ActionStyle.PRIMARY), }, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", { UIDialogs.Action("Cancel", {
}, UIDialogs.ActionStyle.NONE)); }, UIDialogs.ActionStyle.NONE));
} }
else -> { else -> {
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO); StateApp?.instance?.activity?.requestPermissionAudio {
setPermissionResultAudio(it);
}
} }
} }
} }
@@ -113,24 +117,22 @@ class LibraryFragment : MainFragment() {
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false, UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false,
"Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1, "Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1,
UIDialogs.Action("Ok", { UIDialogs.Action("Ok", {
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO); StateApp?.instance?.activity?.requestPermissionVideo {
setPermissionResultVideo(it);
}
}, UIDialogs.ActionStyle.PRIMARY), }, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", { UIDialogs.Action("Cancel", {
}, UIDialogs.ActionStyle.NONE)); }, UIDialogs.ActionStyle.NONE));
} }
else -> { else -> {
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO); StateApp?.instance?.activity?.requestPermissionVideo {
setPermissionResultVideo(it);
}
} }
} }
} }
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
setPermissionResultAudio(isGranted);
});
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
setPermissionResultVideo(isGranted);
});
companion object { companion object {
fun newInstance() = LibraryFragment().apply {} fun newInstance() = LibraryFragment().apply {}
@@ -292,6 +294,7 @@ class LibraryFragment : MainFragment() {
} }
fun onShown() { fun onShown() {
UIDialogs.appToast("Library is in alpha\nImprovements are coming to local media playback.")
} }
} }
} }
@@ -55,6 +55,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.chapters.IChapter
@@ -77,6 +78,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -175,6 +177,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import userpackage.Protocol import userpackage.Protocol
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.Locale import java.util.Locale
@@ -563,6 +566,18 @@ class VideoDetailView : ConstraintLayout {
if (video is TutorialFragment.TutorialVideo) { if (video is TutorialFragment.TutorialVideo) {
return@setOnClickListener return@setOnClickListener
} }
if(video is LocalVideoDetails) {
video?.author?.let {
if(it.url.startsWith("content://media/external/audio/artists")) {
fragment.navigate<LibraryArtistFragment>(it.url);
fragment.lifecycleScope.launch {
delay(100);
fragment.minimizeVideoDetail();
};
}
}
return@setOnClickListener;
}
(video?.author ?: _searchVideo?.author)?.let { (video?.author ?: _searchVideo?.author)?.let {
fragment.navigate<ChannelFragment>(it); fragment.navigate<ChannelFragment>(it);
@@ -1035,7 +1050,7 @@ class VideoDetailView : ConstraintLayout {
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
} }
else null, else null,
if(!isLimitedVersion && !(video?.isLive ?: false)) if(!isLimitedVersion && !(video?.isLive ?: false) && !(video is LocalVideoDetails))
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let { video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
@@ -1058,15 +1073,16 @@ class VideoDetailView : ConstraintLayout {
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
} }
else null, else null,
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) { if(!(video is LocalVideoDetails))
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let { video?.let {
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url; val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
fragment.navigate<BrowserFragment>(url); fragment.navigate<BrowserFragment>(url);
fragment.minimizeVideoDetail(); fragment.minimizeVideoDetail();
}; };
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
}, } else null,
if (StateSync.instance.hasAuthorizedDevice()) { if (StateSync.instance.hasAuthorizedDevice() && !(video is LocalVideoDetails)) {
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) { RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
val devices = StateSync.instance.getAuthorizedSessions(); val devices = StateSync.instance.getAuthorizedSessions();
val videoToSend = video ?: return@RoundButton; val videoToSend = video ?: return@RoundButton;
@@ -1089,10 +1105,11 @@ class VideoDetailView : ConstraintLayout {
}) })
} }
}} else null, }} else null,
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") { if(!(video is LocalVideoDetails))
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo(); reloadVideo();
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
}).filterNotNull(); } else null).filterNotNull();
if(!_buttonPinStore.getAllValues().any()) if(!_buttonPinStore.getAllValues().any())
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray()); _buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
else { else {
@@ -1624,7 +1641,9 @@ class VideoDetailView : ConstraintLayout {
_buttonSubscribe.setSubscribeChannel(video.author.url); _buttonSubscribe.setSubscribeChannel(video.author.url);
setDescription(video.description.fixHtmlLinks()); setDescription(video.description.fixHtmlLinks());
_creatorThumbnail.setThumbnail(video.author.thumbnail, false); _creatorThumbnail.setThumbnail(video.author.thumbnail, false,
video is LocalVideoDetails
);
setPolycentricProfile(null, animate = false); setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id); _taskLoadPolycentricProfile.run(video.author.id);
@@ -1652,7 +1671,7 @@ class VideoDetailView : ConstraintLayout {
_rating.visibility = View.GONE; _rating.visibility = View.GONE;
if (StatePolycentric.instance.enabled) { if (StatePolycentric.instance.enabled && !(video is LocalVideoDetails)) {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val queryReferencesResponse = ApiMethods.getQueryReferences( val queryReferencesResponse = ApiMethods.getQueryReferences(
@@ -1811,17 +1830,19 @@ class VideoDetailView : ConstraintLayout {
_player.updateNextPrevious(); _player.updateNextPrevious();
updateMoreButtons(); updateMoreButtons();
if (videoDetail is TutorialFragment.TutorialVideo) { if (videoDetail is TutorialFragment.TutorialVideo || videoDetail is LocalVideoDetails) {
_buttonSubscribe.visibility = View.GONE _buttonSubscribe.visibility = View.GONE
_buttonMore.visibility = View.GONE _buttonMore.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
_buttonPins.visibility = View.GONE _buttonPins.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
_layoutRating.visibility = View.GONE _layoutRating.visibility = View.GONE
_rating.visibility = View.GONE;
_layoutChangeBottomSection.visibility = View.GONE _layoutChangeBottomSection.visibility = View.GONE
} else { } else {
_buttonSubscribe.visibility = View.VISIBLE _buttonSubscribe.visibility = View.VISIBLE
_buttonMore.visibility = View.VISIBLE _buttonMore.visibility = View.VISIBLE
_buttonPins.visibility = View.VISIBLE _buttonPins.visibility = View.VISIBLE
_layoutRating.visibility = View.VISIBLE _layoutRating.visibility = View.VISIBLE
_rating.visibility = View.VISIBLE;
_layoutChangeBottomSection.visibility = View.VISIBLE _layoutChangeBottomSection.visibility = View.VISIBLE
} }
@@ -2685,7 +2706,11 @@ class VideoDetailView : ConstraintLayout {
private fun fetchComments() { private fun fetchComments() {
Logger.i(TAG, "fetchComments") Logger.i(TAG, "fetchComments")
video?.let { video?.let {
_commentsList.load(true) { StatePlatform.instance.getComments(it); }; if(video is LocalVideoDetails) {
_commentsList.clearComments();
}
else
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
} }
} }
private fun fetchPolycentricComments() { private fun fetchPolycentricComments() {
@@ -2972,6 +2997,7 @@ class VideoDetailView : ConstraintLayout {
} }
onChannelClicked.subscribe { onChannelClicked.subscribe {
Logger.i(TAG, "Opening channel url: ${it.url}");
if(it.url.isNotBlank()) { if(it.url.isNotBlank()) {
fragment.minimizeVideoDetail() fragment.minimizeVideoDetail()
fragment.navigate<ChannelFragment>(it) fragment.navigate<ChannelFragment>(it)
@@ -12,5 +12,6 @@ data class Telemetry(
val brand: String, val brand: String,
val manufacturer: String, val manufacturer: String,
val model: String, val model: String,
val sdkVersion: Int val sdkVersion: Int,
val plugins: List<String>? = null
) { } ) { }
@@ -7,6 +7,7 @@ import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.Audio.Artists import android.provider.MediaStore.Audio.Artists
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.collection.emptyLongSet
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
@@ -173,6 +174,7 @@ class StateLibrary {
list.add(videoFromCursor(cursor)); list.add(videoFromCursor(cursor));
cursor.moveToNext(); cursor.moveToNext();
} }
Logger.i(TAG, "Videos nextPage: ${list.size}")
return@AdhocPager list; return@AdhocPager list;
}, list); }, list);
//} //}
@@ -243,11 +245,12 @@ class StateLibrary {
MediaStore.Audio.Media._ID, //0 MediaStore.Audio.Media._ID, //0
MediaStore.Audio.Media.DISPLAY_NAME, //1 MediaStore.Audio.Media.DISPLAY_NAME, //1
MediaStore.Audio.Media.ARTIST, //2 MediaStore.Audio.Media.ARTIST, //2
MediaStore.Audio.Media.ALBUM_ID, //3 MediaStore.Audio.Media.ARTIST_ID, //3
MediaStore.Audio.Media.DURATION, //4 MediaStore.Audio.Media.ALBUM_ID, //4
MediaStore.Audio.Media.DATE_ADDED, //5 MediaStore.Audio.Media.DURATION, //5
MediaStore.Audio.Media.MIME_TYPE, //6 MediaStore.Audio.Media.DATE_ADDED, //6
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7 MediaStore.Audio.Media.MIME_TYPE, //7
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //8
); );
fun getDocumentTrack(url: String): IPlatformContentDetails? { fun getDocumentTrack(url: String): IPlatformContentDetails? {
@@ -359,11 +362,12 @@ class StateLibrary {
val id = cursor.getString(0); val id = cursor.getString(0);
val displayName = cursor.getString(1); val displayName = cursor.getString(1);
val author = cursor.getString(2); val author = cursor.getString(2);
val albumId = cursor.getLong(3); val authorId = cursor.getStringOrNull(3);
val duration = cursor.getLong(4).let { if(it > 0) it / 1000 else 0 }; val albumId = cursor.getLong(4);
val date = cursor.getLong(5); val duration = cursor.getLong(5).let { if(it > 0) it / 1000 else 0 };
val contentType = cursor.getString(6); val date = cursor.getLong(6);
val category = cursor.getString(7); val contentType = cursor.getString(7);
val category = cursor.getString(8);
val idLong = id.toLongOrNull(); val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null ) val contentUrl = if(idLong != null )
@@ -371,6 +375,13 @@ class StateLibrary {
else else
""; "";
val authorIdLong = authorId?.toLongOrNull();
val authorUrl = if(authorIdLong != null)
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, authorIdLong).toString();
else
"";
val albumContentUrl = if(albumId > 0) val albumContentUrl = if(albumId > 0)
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
else null; else null;
@@ -380,7 +391,10 @@ class StateLibrary {
else null; else null;
val authorObj = if(!author.isNullOrBlank()) val authorObj = if(!author.isNullOrBlank())
PlatformAuthorLink(PlatformID.NONE, author, "", null, null) PlatformAuthorLink(
if(authorId != null) PlatformID("LOCAL", authorId) else PlatformID.NONE,
author,
authorUrl, null, null)
else PlatformAuthorLink.UNKNOWN; else PlatformAuthorLink.UNKNOWN;
return LocalVideoDetails( return LocalVideoDetails(
@@ -39,7 +39,8 @@ class StateTelemetry {
Build.BRAND, Build.BRAND,
Build.MANUFACTURER, Build.MANUFACTURER,
Build.MODEL, Build.MODEL,
Build.VERSION.SDK_INT Build.VERSION.SDK_INT,
StatePlatform.instance.getEnabledClients().map { it.id }.toList()
); );
val headers = hashMapOf( val headers = hashMapOf(
@@ -40,10 +40,10 @@ class ArtistTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder
if (artist.thumbnail != null) if (artist.thumbnail != null)
Glide.with(it) Glide.with(it)
.load(artist.thumbnail) .load(artist.thumbnail)
.placeholder(R.drawable.unknown_music) .placeholder(R.drawable.ic_artist)
.into(it) .into(it)
else else
Glide.with(it).load(R.drawable.unknown_music).into(it); Glide.with(it).load(R.drawable.ic_artist).into(it);
}; };
_textName.text = artist.name; _textName.text = artist.name;
@@ -42,11 +42,11 @@ class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHold
_file = file; _file = file;
_imageThumbnail?.let { _imageThumbnail?.let {
if(file.isDirectory) if(file.isDirectory)
it.setImageResource(R.drawable.ic_library); it.setImageResource(R.drawable.ic_folder);
else { else {
Glide.with(it) Glide.with(it)
.load(file.thumbnail) .load(file.thumbnail)
.placeholder(R.drawable.ic_music) .placeholder(R.drawable.ic_song)
.into(it) .into(it)
} }
}; };
@@ -0,0 +1,47 @@
package com.futo.platformplayer.views.buttons
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.collection.emptyLongSet
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.views.pills.PillButton
class ButtonsContainer : LinearLayout {
val container_buttons: LinearLayout
var currentButtons: List<Button> = listOf();
constructor(context: Context, buttons: List<Button>) : super(context) {
inflate(context, R.layout.view_buttons, this)
container_buttons = findViewById(R.id.container_buttons);
setButtons(buttons);
}
fun setButtons(buttons: List<Button>) {
this.currentButtons = buttons;
container_buttons.removeAllViews();
for(button in buttons) {
container_buttons.addView(StandardButton(context, button.name) {
button?.handler?.invoke();
}.apply {
this.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
this.weight = 1f;
};
if(button.background != null)
this.withBackground(button.background);
})
}
}
class Button(
val name: String,
val background: Int?,
val handler: (()->Unit),
);
}
@@ -0,0 +1,34 @@
package com.futo.platformplayer.views.buttons
import android.content.Context
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
class StandardButton : LinearLayout {
private val _root: LinearLayout;
private val _text: TextView;
constructor(context: Context, text: String, onClick: ()->Unit) : super(context) {
inflate(context, R.layout.view_button_standard, this);
_root = findViewById(R.id.root);
_text = findViewById(R.id.text_button);
_text.text = text;
_root.setOnClickListener {
onClick.invoke();
}
}
fun withPrimaryBackground(): StandardButton {
_root.setBackgroundResource(R.drawable.background_button_primary)
return this;
}
fun withAccentBackground(): StandardButton {
_root.setBackgroundResource(R.drawable.background_button_accent)
return this;
}
fun withBackground(id: Int): StandardButton {
_root.setBackgroundResource(id);
return this;
}
}
@@ -54,9 +54,14 @@ class CreatorThumbnail : ConstraintLayout {
setNewActivity(false); setNewActivity(false);
} }
fun setThumbnail(url: String?, animate: Boolean) { fun setThumbnail(url: String?, animate: Boolean, isArtist: Boolean = false) {
if (url == null) { if (url == null) {
clear(); if(isArtist) {
_imageChannelThumbnail.setImageResource(R.drawable.ic_artist);
_imageChannelThumbnail.visibility = View.VISIBLE;
}
else
clear();
return; return;
} }
@@ -78,18 +83,21 @@ class CreatorThumbnail : ConstraintLayout {
} else { } else {
setHarborAvailable(false, animate, null); setHarborAvailable(false, animate, null);
} }
var placeholder = R.drawable.placeholder_channel_thumbnail;
if(url.startsWith("content://") || isArtist)
placeholder = R.drawable.ic_artist;
if (animate) { if (animate) {
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
.load(url) .load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail) .placeholder(placeholder)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.crossfade() .crossfade()
.into(_imageChannelThumbnail) .into(_imageChannelThumbnail)
} else { } else {
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
.load(url) .load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail) .placeholder(placeholder)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(_imageChannelThumbnail); .into(_imageChannelThumbnail);
} }
@@ -18,7 +18,7 @@ class PillButton : LinearLayout {
val onClick = Event0(); val onClick = Event0();
private var _isLoading = false; private var _isLoading = false;
constructor(context : Context, attrs : AttributeSet) : super(context, attrs) { constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.pill_button, this, true); LayoutInflater.from(context).inflate(R.layout.pill_button, this, true);
icon = findViewById(R.id.pill_icon); icon = findViewById(R.id.pill_icon);
text = findViewById(R.id.pill_text); text = findViewById(R.id.pill_text);
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.LazyComment import com.futo.platformplayer.api.media.models.comments.LazyComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IAsyncPager import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -224,6 +225,12 @@ class CommentsList : ConstraintLayout {
_commentsPager = pager; _commentsPager = pager;
onCommentsLoaded.emit(_comments.size); onCommentsLoaded.emit(_comments.size);
} }
fun clearComments() {
_comments.clear();
_adapterComments.notifyDataSetChanged();
_commentsPager = EmptyPager();
onCommentsLoaded.emit(0);
}
fun load(readonly: Boolean, loader: suspend () -> IPager<IPlatformComment>) { fun load(readonly: Boolean, loader: suspend () -> IPager<IPlatformComment>) {
cancel(); cancel();
+1 -2
View File
@@ -2,8 +2,7 @@
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M450,780L450,294.92L222.15,522.77L180,480L480,180L780,480L737.85,522.77L510,294.92L510,780L450,780Z"/> android:pathData="M450,780L450,294.92L222.15,522.77L180,480L480,180L780,480L737.85,522.77L510,294.92L510,780L450,780Z"/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

+2 -3
View File
@@ -2,9 +2,8 @@
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M493.85,593.85Q531.61,593.85 557.73,567.73Q583.84,541.62 583.84,503.85L583.84,279.23L698.46,279.23L698.46,208.46L548.46,208.46L548.46,434.62Q537,424.23 523.35,419.04Q509.69,413.85 493.85,413.85Q456.08,413.85 429.96,439.96Q403.85,466.08 403.85,503.85Q403.85,541.62 429.96,567.73Q456.08,593.85 493.85,593.85ZM322.31,700Q292,700 271,679Q250,658 250,627.69L250,172.31Q250,142 271,121Q292,100 322.31,100L777.69,100Q808,100 829,121Q850,142 850,172.31L850,627.69Q850,658 829,679Q808,700 777.69,700L322.31,700ZM322.31,640L777.69,640Q782.31,640 786.15,636.15Q790,632.31 790,627.69L790,172.31Q790,167.69 786.15,163.85Q782.31,160 777.69,160L322.31,160Q317.69,160 313.85,163.85Q310,167.69 310,172.31L310,627.69Q310,632.31 313.85,636.15Q317.69,640 322.31,640ZM182.31,840Q152,840 131,819Q110,798 110,767.69L110,252.31L170,252.31L170,767.69Q170,772.31 173.85,776.15Q177.69,780 182.31,780L697.69,780L697.69,840L182.31,840ZM310,160L310,160Q310,160 310,163.46Q310,166.92 310,172.31L310,627.69Q310,633.08 310,636.54Q310,640 310,640L310,640Q310,640 310,636.54Q310,633.08 310,627.69L310,172.31Q310,166.92 310,163.46Q310,160 310,160Z"/> android:pathData="M493.85,593.85Q531.61,593.85 557.73,567.73Q583.84,541.62 583.84,503.85L583.84,279.23L698.46,279.23L698.46,208.46L548.46,208.46L548.46,434.62Q537,424.23 523.35,419.04Q509.69,413.85 493.85,413.85Q456.08,413.85 429.96,439.96Q403.85,466.08 403.85,503.85Q403.85,541.62 429.96,567.73Q456.08,593.85 493.85,593.85ZM322.31,700Q292,700 271,679Q250,658 250,627.69L250,172.31Q250,142 271,121Q292,100 322.31,100L777.69,100Q808,100 829,121Q850,142 850,172.31L850,627.69Q850,658 829,679Q808,700 777.69,700L322.31,700ZM182.31,840Q152,840 131,819Q110,798 110,767.69L110,252.31L170,252.31L170,767.69Q170,772.31 173.85,776.15Q177.69,780 182.31,780L697.69,780L697.69,840L182.31,840Z"/>
</vector> </vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

@@ -29,6 +29,8 @@
android:id="@+id/image_qr" android:id="@+id/image_qr"
android:layout_width="200dp" android:layout_width="200dp"
android:layout_height="200dp" android:layout_height="200dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="8dp"
app:srcCompat="@drawable/ic_qr" app:srcCompat="@drawable/ic_qr"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
@@ -37,15 +39,31 @@
<TextView <TextView
android:id="@+id/text_qr" android:id="@+id/text_qr"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/scan_to_import" android:text="@string/scan_to_import"
android:fontFamily="@font/inter_light" android:fontFamily="@font/inter_light"
android:textSize="32dp" android:textSize="32dp"
android:textAlignment="center"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
app:layout_constraintTop_toBottomOf="@id/image_qr" app:layout_constraintTop_toBottomOf="@id/image_qr"
app:layout_constraintLeft_toLeftOf="@id/image_qr" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="@id/image_qr" /> app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/text_qr_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tap_qr_code_for_fullscreen"
android:fontFamily="@font/inter_light"
android:textSize="14dp"
android:textColor="@color/gray_400"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/text_qr"
app:layout_constraintLeft_toLeftOf="@id/text_qr"
app:layout_constraintRight_toRightOf="@id/text_qr" />
<LinearLayout <LinearLayout
android:id="@+id/layout_buttons" android:id="@+id/layout_buttons"
@@ -55,7 +73,7 @@
android:layout_marginStart="20dp" android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" android:layout_marginEnd="20dp"
android:layout_marginTop="30dp" android:layout_marginTop="30dp"
app:layout_constraintTop_toBottomOf="@id/text_qr" app:layout_constraintTop_toBottomOf="@id/text_qr_hint"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"> app:layout_constraintRight_toRightOf="parent">
@@ -75,6 +93,15 @@
app:buttonSubText="@string/copy_your_identity_to_clipboard" app:buttonSubText="@string/copy_your_identity_to_clipboard"
app:buttonIcon="@drawable/ic_copy" app:buttonIcon="@drawable/ic_copy"
android:layout_marginTop="8dp" /> android:layout_marginTop="8dp" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_export_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/export_to_file"
app:buttonSubText="@string/save_profile_to_file_for_sharing"
app:buttonIcon="@drawable/ic_download"
android:layout_marginTop="8dp" />
</LinearLayout> </LinearLayout>
<ProgressBar <ProgressBar
@@ -47,6 +47,28 @@
android:text="@string/scan_qr" /> android:text="@string/scan_qr" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/button_import_file"
android:layout_width="140dp"
android:layout_height="40dp"
android:background="@drawable/background_button_primary_round"
android:gravity="center"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:orientation="horizontal"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_scan_profile">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_light"
android:textColor="@color/white"
android:textSize="16dp"
android:text="@string/import_from_file" />
</LinearLayout>
<TextView <TextView
android:id="@+id/text_or" android:id="@+id/text_or"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -55,7 +77,7 @@
android:fontFamily="@font/inter_light" android:fontFamily="@font/inter_light"
android:textSize="28dp" android:textSize="28dp"
android:layout_marginTop="30dp" android:layout_marginTop="30dp"
app:layout_constraintTop_toBottomOf="@id/button_scan_profile" app:layout_constraintTop_toBottomOf="@id/button_import_file"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" /> app:layout_constraintRight_toRightOf="parent" />
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<!-- Top navigation bar -->
<ImageButton
android:id="@+id/button_back_fullscreen"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
android:background="?android:attr/selectableItemBackgroundBorderless"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp" />
<ImageButton
android:id="@+id/button_close_fullscreen"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_close"
android:padding="10dp"
android:scaleType="fitCenter"
android:background="?android:attr/selectableItemBackgroundBorderless"
app:srcCompat="@drawable/ic_close"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" />
<!-- Full screen QR code -->
<ImageView
android:id="@+id/image_qr_fullscreen"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="fitCenter"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="40dp"
app:layout_constraintTop_toBottomOf="@id/button_back_fullscreen"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
+13 -5
View File
@@ -1,16 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/feed_root"
android:id="@+id/feed_root"
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/container_bottom"
android:orientation="vertical" android:orientation="vertical"
tools:context=".fragment.mainactivity.main.FeedFragment"> tools:context=".fragment.mainactivity.main.FeedFragment">
@@ -124,6 +126,12 @@
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
<LinearLayout
android:id="@+id/container_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" />
<FrameLayout <FrameLayout
android:id="@+id/overlay_container" android:id="@+id/overlay_container"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -131,4 +139,4 @@
android:elevation="100dp" android:elevation="100dp"
android:visibility="gone" /> android:visibility="gone" />
</FrameLayout> </androidx.constraintlayout.widget.ConstraintLayout>
+2 -4
View File
@@ -10,7 +10,7 @@
android:layout_marginBottom="5dp" android:layout_marginBottom="5dp"
android:layout_marginLeft="10dp" android:layout_marginLeft="10dp"
android:layout_marginRight="10dp" android:layout_marginRight="10dp"
android:background="@drawable/background_16_round_4dp" android:background="@drawable/background_1b_round_6dp"
android:id="@+id/root" android:id="@+id/root"
android:clickable="true"> android:clickable="true">
@@ -23,12 +23,10 @@
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent">
android:background="@drawable/background_1b_round_6dp">
<ImageView <ImageView
android:id="@+id/image_thumbnail" android:id="@+id/image_thumbnail"
android:alpha="0.4"
android:layout_height="34dp" android:layout_height="34dp"
android:layout_width="34dp" android:layout_width="34dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_gravity="bottom"
android:orientation="vertical"
android:background="@drawable/background_button_accent"
android:gravity="center"
android:id="@+id/root">
<TextView
android:id="@+id/text_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:autoSizeTextType="uniform"
android:padding="12dp"
android:text="Play all" />
</LinearLayout>
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="bottom"
android:orientation="vertical"
android:id="@+id/root">
<LinearLayout
android:id="@+id/container_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp"
android:layout_marginTop="14dp"
android:layout_marginBottom="14dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:showDividers="middle"
android:divider="@drawable/divider_transparent_4dp">
</LinearLayout>
</LinearLayout>
+1
View File
@@ -388,6 +388,7 @@
<string name="unhandled_exception_in_vs">استثناء غير معالج في VS</string> <string name="unhandled_exception_in_vs">استثناء غير معالج في VS</string>
<string name="send_exception_to_developers">إرسال الاستثناء للمطورين…</string> <string name="send_exception_to_developers">إرسال الاستثناء للمطورين…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">تم تعيين مفتاح الترخيص الخاص بك!\nقد يكون هناك حاجة لإعادة تشغيل التطبيق.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">تم تعيين مفتاح الترخيص الخاص بك!\nقد يكون هناك حاجة لإعادة تشغيل التطبيق.</string>
<string name="qr_code_too_large_use_text_below">رمز الاستجابة السريعة كبير جدًا. استخدم النص أدناه لمشاركة ملفك الشخصي.</string>
<string name="invalid_license_format">تنسيق الترخيص غير صالح</string> <string name="invalid_license_format">تنسيق الترخيص غير صالح</string>
<string name="unknown_content_format">تنسيق المحتوى غير معروف</string> <string name="unknown_content_format">تنسيق المحتوى غير معروف</string>
<string name="unknown_file_format">تنسيق الملف غير معروف</string> <string name="unknown_file_format">تنسيق الملف غير معروف</string>
+1
View File
@@ -395,6 +395,7 @@
<string name="unhandled_exception_in_vs">Unbehandelte Ausnahme in VS</string> <string name="unhandled_exception_in_vs">Unbehandelte Ausnahme in VS</string>
<string name="send_exception_to_developers">Ausnahme an Entwickler senden…</string> <string name="send_exception_to_developers">Ausnahme an Entwickler senden…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Ihr Lizenzschlüssel wurde festgelegt!\nEin Neustart der App könnte erforderlich sein.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">Ihr Lizenzschlüssel wurde festgelegt!\nEin Neustart der App könnte erforderlich sein.</string>
<string name="qr_code_too_large_use_text_below">QR-Code zu groß. Verwenden Sie den Text unten, um Ihr Profil zu teilen.</string>
<string name="invalid_license_format">Ungültiges Lizenzformat</string> <string name="invalid_license_format">Ungültiges Lizenzformat</string>
<string name="unknown_content_format">Unbekanntes Inhaltsformat</string> <string name="unknown_content_format">Unbekanntes Inhaltsformat</string>
<string name="unknown_file_format">Unbekanntes Dateiformat</string> <string name="unknown_file_format">Unbekanntes Dateiformat</string>
+1
View File
@@ -372,6 +372,7 @@
<string name="unhandled_exception_in_vs">Excepción no manejada en VS</string> <string name="unhandled_exception_in_vs">Excepción no manejada en VS</string>
<string name="send_exception_to_developers">Enviar excepción a los desarrolladores...</string> <string name="send_exception_to_developers">Enviar excepción a los desarrolladores...</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">¡Se ha configurado tu clave de licencia!\nPuede ser necesario reiniciar la aplicación.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">¡Se ha configurado tu clave de licencia!\nPuede ser necesario reiniciar la aplicación.</string>
<string name="qr_code_too_large_use_text_below">Código QR demasiado grande. Use el texto de abajo para compartir su perfil.</string>
<string name="invalid_license_format">Formato de licencia no válido</string> <string name="invalid_license_format">Formato de licencia no válido</string>
<string name="unknown_content_format">Formato de contenido desconocido</string> <string name="unknown_content_format">Formato de contenido desconocido</string>
<string name="unknown_file_format">Formato de archivo desconocido</string> <string name="unknown_file_format">Formato de archivo desconocido</string>
+1
View File
@@ -411,6 +411,7 @@
<string name="unhandled_exception_in_vs">Exception non gérée dans VS</string> <string name="unhandled_exception_in_vs">Exception non gérée dans VS</string>
<string name="send_exception_to_developers">Envoyer l\'exception aux développeurs…</string> <string name="send_exception_to_developers">Envoyer l\'exception aux développeurs…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Votre clé de licence a été définie !\nUn redémarrage de l\'application peut être nécessaire.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">Votre clé de licence a été définie !\nUn redémarrage de l\'application peut être nécessaire.</string>
<string name="qr_code_too_large_use_text_below">Code QR trop volumineux. Utilisez le texte ci-dessous pour partager votre profil.</string>
<string name="invalid_license_format">Format de licence invalide</string> <string name="invalid_license_format">Format de licence invalide</string>
<string name="unknown_content_format">Format de contenu inconnu</string> <string name="unknown_content_format">Format de contenu inconnu</string>
<string name="unknown_file_format">Format de fichier inconnu</string> <string name="unknown_file_format">Format de fichier inconnu</string>
+1
View File
@@ -617,6 +617,7 @@
<string name="unhandled_exception_in_vs">Eccezione non gestita in VS</string> <string name="unhandled_exception_in_vs">Eccezione non gestita in VS</string>
<string name="send_exception_to_developers">Invio eccezione agli sviluppatori…</string> <string name="send_exception_to_developers">Invio eccezione agli sviluppatori…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">La tua chiave di licenza è stata impostata!\nIl riavvio dell\'app potrebbe essere richiesto.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">La tua chiave di licenza è stata impostata!\nIl riavvio dell\'app potrebbe essere richiesto.</string>
<string name="qr_code_too_large_use_text_below">Codice QR troppo grande. Usa il testo qui sotto per condividere il tuo profilo.</string>
<string name="invalid_license_format">Formato licenza non valido</string> <string name="invalid_license_format">Formato licenza non valido</string>
<string name="unknown_content_format">Formato contenuto sconosciuto</string> <string name="unknown_content_format">Formato contenuto sconosciuto</string>
<string name="unknown_file_format">Formato file sconosciuto</string> <string name="unknown_file_format">Formato file sconosciuto</string>
+1
View File
@@ -374,6 +374,7 @@
<string name="unhandled_exception_in_vs">VSで未処理の例外</string> <string name="unhandled_exception_in_vs">VSで未処理の例外</string>
<string name="send_exception_to_developers">開発者に例外を送信…</string> <string name="send_exception_to_developers">開発者に例外を送信…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">ライセンスキーが設定されました!\nアプリを再起動する可能性があります。</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">ライセンスキーが設定されました!\nアプリを再起動する可能性があります。</string>
<string name="qr_code_too_large_use_text_below">QRコードが大きすぎます。下のテキストを使用してプロフィールを共有してください。</string>
<string name="invalid_license_format">無効なライセンス形式</string> <string name="invalid_license_format">無効なライセンス形式</string>
<string name="unknown_content_format">不明なコンテンツ形式</string> <string name="unknown_content_format">不明なコンテンツ形式</string>
<string name="unknown_file_format">不明なファイル形式</string> <string name="unknown_file_format">不明なファイル形式</string>
+1
View File
@@ -410,6 +410,7 @@
<string name="unhandled_exception_in_vs">VS에서 처리되지 않은 예외</string> <string name="unhandled_exception_in_vs">VS에서 처리되지 않은 예외</string>
<string name="send_exception_to_developers">개발자에게 예외를 보냅니다…</string> <string name="send_exception_to_developers">개발자에게 예외를 보냅니다…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">라이선스 키가 설정되었습니다!\n앱을 다시 시작해야 할 수 있습니다.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">라이선스 키가 설정되었습니다!\n앱을 다시 시작해야 할 수 있습니다.</string>
<string name="qr_code_too_large_use_text_below">QR 코드가 너무 큽니다. 아래 텍스트를 사용하여 프로필을 공유하세요.</string>
<string name="invalid_license_format">잘못된 라이선스 형식</string> <string name="invalid_license_format">잘못된 라이선스 형식</string>
<string name="unknown_content_format">알 수 없는 콘텐츠 형식</string> <string name="unknown_content_format">알 수 없는 콘텐츠 형식</string>
<string name="unknown_file_format">알 수 없는 파일 형식</string> <string name="unknown_file_format">알 수 없는 파일 형식</string>
+1
View File
@@ -407,6 +407,7 @@
<string name="unhandled_exception_in_vs">Exceção não tratada no VS</string> <string name="unhandled_exception_in_vs">Exceção não tratada no VS</string>
<string name="send_exception_to_developers">Enviar exceção aos desenvolvedores…</string> <string name="send_exception_to_developers">Enviar exceção aos desenvolvedores…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Sua chave de licença foi definida!\nUma reinicialização do aplicativo pode ser necessária.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">Sua chave de licença foi definida!\nUma reinicialização do aplicativo pode ser necessária.</string>
<string name="qr_code_too_large_use_text_below">Código QR muito grande. Use o texto abaixo para compartilhar seu perfil.</string>
<string name="invalid_license_format">Formato de licença inválido</string> <string name="invalid_license_format">Formato de licença inválido</string>
<string name="unknown_content_format">Formato de conteúdo desconhecido</string> <string name="unknown_content_format">Formato de conteúdo desconhecido</string>
<string name="unknown_file_format">Formato de arquivo desconhecido</string> <string name="unknown_file_format">Formato de arquivo desconhecido</string>
+1
View File
@@ -407,6 +407,7 @@
<string name="unhandled_exception_in_vs">Необработанное исключение в VS</string> <string name="unhandled_exception_in_vs">Необработанное исключение в VS</string>
<string name="send_exception_to_developers">Отправить исключение разработчикам…</string> <string name="send_exception_to_developers">Отправить исключение разработчикам…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Ваш лицензионный ключ установлен!\nМожет потребоваться перезагрузка приложения.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">Ваш лицензионный ключ установлен!\nМожет потребоваться перезагрузка приложения.</string>
<string name="qr_code_too_large_use_text_below">QR-код слишком большой. Используйте текст ниже, чтобы поделиться своим профилем.</string>
<string name="invalid_license_format">Неверный формат лицензии</string> <string name="invalid_license_format">Неверный формат лицензии</string>
<string name="unknown_content_format">Неизвестный формат содержимого</string> <string name="unknown_content_format">Неизвестный формат содержимого</string>
<string name="unknown_file_format">Неизвестный формат файла</string> <string name="unknown_file_format">Неизвестный формат файла</string>
+1
View File
@@ -581,6 +581,7 @@
<string name="unhandled_exception_in_vs">VS\'de bilinmeyen hata (exception)</string> <string name="unhandled_exception_in_vs">VS\'de bilinmeyen hata (exception)</string>
<string name="send_exception_to_developers">Geliştiricilere exception\'ı gönder…</string> <string name="send_exception_to_developers">Geliştiricilere exception\'ı gönder…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Lisans anahtarınız ayarlandı!\nUygulamayı yeniden başlatmanız gerekebilir.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">Lisans anahtarınız ayarlandı!\nUygulamayı yeniden başlatmanız gerekebilir.</string>
<string name="qr_code_too_large_use_text_below">QR kodu çok büyük. Profilinizi paylaşmak için aşağıdaki metni kullanın.</string>
<string name="invalid_license_format">Geçersiz lisans formatı</string> <string name="invalid_license_format">Geçersiz lisans formatı</string>
<string name="unknown_content_format">Bilinmeyen içerik formatı</string> <string name="unknown_content_format">Bilinmeyen içerik formatı</string>
<string name="unknown_file_format">Bilinmeyen dosya formatı</string> <string name="unknown_file_format">Bilinmeyen dosya formatı</string>
+1
View File
@@ -411,6 +411,7 @@
<string name="unhandled_exception_in_vs">VS 中的未处理异常</string> <string name="unhandled_exception_in_vs">VS 中的未处理异常</string>
<string name="send_exception_to_developers">向开发者发送异常…</string> <string name="send_exception_to_developers">向开发者发送异常…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">您的许可证密钥已设置!\n可能需要重新启动应用程序。</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">您的许可证密钥已设置!\n可能需要重新启动应用程序。</string>
<string name="qr_code_too_large_use_text_below">二维码太大。请使用下方文字分享您的个人资料。</string>
<string name="invalid_license_format">无效的许可证格式</string> <string name="invalid_license_format">无效的许可证格式</string>
<string name="unknown_content_format">未知的内容格式</string> <string name="unknown_content_format">未知的内容格式</string>
<string name="unknown_file_format">未知的文件格式</string> <string name="unknown_file_format">未知的文件格式</string>
+7
View File
@@ -646,6 +646,7 @@
<string name="unhandled_exception_in_vs">Unhandled exception in VS</string> <string name="unhandled_exception_in_vs">Unhandled exception in VS</string>
<string name="send_exception_to_developers">Send exception to developers…</string> <string name="send_exception_to_developers">Send exception to developers…</string>
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Your license key has been set!\nAn app restart might be required.</string> <string name="your_license_key_has_been_set_an_app_restart_might_be_required">Your license key has been set!\nAn app restart might be required.</string>
<string name="qr_code_too_large_use_text_below">QR code too large. Use the text below to share your profile.</string>
<string name="invalid_license_format">Invalid license format</string> <string name="invalid_license_format">Invalid license format</string>
<string name="unknown_content_format">Unknown content format</string> <string name="unknown_content_format">Unknown content format</string>
<string name="unknown_file_format">Unknown file format</string> <string name="unknown_file_format">Unknown file format</string>
@@ -1175,4 +1176,10 @@
<item>1500</item> <item>1500</item>
<item>2000</item> <item>2000</item>
</string-array> </string-array>
<string name="qr_code_too_large_use_file_export">Export to file or copy backup code</string>
<string name="tap_qr_code_for_fullscreen">Tap QR code for fullscreen view</string>
<string name="export_to_file">Export to File</string>
<string name="import_from_file">Import from File</string>
<string name="save_profile_to_file_for_sharing">Save profile to file for sharing</string>
<string name="profile_saved_successfully">Profile saved successfully</string>
</resources> </resources>