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

This commit is contained in:
Kelvin 2023-12-04 20:07:32 +01:00
commit bef8fc682c
97 changed files with 3223 additions and 742 deletions

View File

@ -61,6 +61,14 @@
<data android:scheme="grayjay" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fcast" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -210,5 +218,9 @@
android:name=".activities.QRCaptureActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.FCastGuideActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>

View File

@ -111,7 +111,15 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -1)
@FormFieldButton(R.drawable.ic_link)
fun manageLinks() {
try {
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show url handling prompt", e)
}
}
@FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings();
@ -377,6 +385,14 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterLoss: Int = 1;
@FormField(R.string.restart_after_connectivity_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_connectivity_after_a_loss, 12)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterConnectivityLoss: Int = 1;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)

View File

@ -1,8 +1,11 @@
package com.futo.platformplayer
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
@ -15,7 +18,6 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.dialogs.*
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
@ -91,6 +93,50 @@ class UIDialogs {
}.toTypedArray());
}
fun showUrlHandlingPrompt(context: Context, onYes: (() -> Unit)? = null) {
val builder = AlertDialog.Builder(context)
val view = LayoutInflater.from(context).inflate(R.layout.dialog_url_handling, null)
builder.setView(view)
val dialog = builder.create()
registerDialogOpened(dialog)
view.findViewById<TextView>(R.id.button_no).apply {
this.setOnClickListener {
dialog.dismiss()
}
}
view.findViewById<LinearLayout>(R.id.button_yes).apply {
this.setOnClickListener {
if (BuildConfig.IS_PLAYSTORE_BUILD) {
dialog.dismiss()
showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.play_store_version_does_not_support_default_url_handling)) {
onYes?.invoke()
}
} else {
try {
val intent =
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", context.packageName, null)
intent.data = uri
context.startActivity(intent)
} catch (e: Throwable) {
toast(context, context.getString(R.string.failed_to_show_settings))
}
onYes?.invoke()
dialog.dismiss()
}
}
}
dialog.setOnDismissListener {
registerDialogClosed(dialog)
}
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
@ -107,7 +153,8 @@ class UIDialogs {
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY));
}, UIDialogs.ActionStyle.PRIMARY)
);
else {
dialogAction();
}
@ -291,11 +338,22 @@ class UIDialogs {
} else {
val dialog = ConnectCastingDialog(context);
registerDialogOpened(dialog);
val c = context
if (c is Activity) {
dialog.setOwnerActivity(c);
}
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
}
fun showCastingTutorialDialog(context: Context) {
val dialog = CastingHelpDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showCastingAddDialog(context: Context) {
val dialog = CastingAddDialog(context);
registerDialogOpened(dialog);

View File

@ -12,20 +12,27 @@ import android.widget.TextView
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
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.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@ -33,10 +40,12 @@ import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup
import com.futo.platformplayer.views.overlays.slideup.*
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.internal.notifyAll
import java.lang.IllegalStateException
class UISlideOverlays {
@ -127,6 +136,101 @@ class UISlideOverlays {
}
}
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty")
val videoButtons = arrayListOf<SlideUpMenuItem>()
val audioButtons = arrayListOf<SlideUpMenuItem>()
//TODO: Implement subtitles
//val subtitleButtons = arrayListOf<SlideUpMenuItem>()
var selectedVideoVariant: HLSVariantVideoUrlSource? = null
var selectedAudioVariant: HLSVariantAudioUrlSource? = null
//TODO: Implement subtitles
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
val masterPlaylist: HLS.MasterPlaylist
try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
masterPlaylist.getAudioSources().forEach { it ->
audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}
val newItems = arrayListOf<View>()
if (videoButtons.isNotEmpty()) {
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoButtons, videoButtons))
}
if (audioButtons.isNotEmpty()) {
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioButtons, audioButtons))
}
//TODO: Implement subtitles
/*if (subtitleButtons.isNotEmpty()) {
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleButtons, subtitleButtons))
}*/
slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
slideUpMenuOverlay.hide()
}
withContext(Dispatchers.Main) {
slideUpMenuOverlay.setItems(newItems)
}
} catch (e: Throwable) {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
throw NotImplementedError()
}
}
} else {
throw e
}
}
}
return slideUpMenuOverlay.apply { show() }
}
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null;
@ -166,30 +270,49 @@ class UISlideOverlays {
videoSources
.filter { it.isDownloadable() }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it as IVideoUrlSource;
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
}, false)
if (it is IVideoUrlSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
}, false)
} else if (it is IHLSManifestSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
showHlsPicker(video, it, it.url, container)
}, false)
} else {
throw Exception("Unhandled source type")
}
}).flatten().toList()
));
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
//TODO: Add HLS support here
selectedVideo = VideoHelper.selectBestVideoSource(
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource;
}
audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
.filter { VideoHelper.isDownloadable(it) }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource;
menu?.selectOption(audioSources, it);
menu?.setOk(container.context.getString(R.string.download));
}, false);
if (it is IAudioUrlSource) {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it
menu?.selectOption(audioSources, it);
menu?.setOk(container.context.getString(R.string.download));
}, false);
} else if (it is IHLSManifestAudioSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
showHlsPicker(video, it, it.url, container)
}, false)
} else {
throw Exception("Unhandled source type")
}
}));
val asources = audioSources;
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
@ -198,15 +321,15 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
menu?.selectOption(asources, preferredAudioSource);
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
//TODO: Add HLS support here
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
}
//ContentResolver is required for subtitles..
if(contentResolver != null) {
if(contentResolver != null && subtitleSources.isNotEmpty()) {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
.map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
@ -378,7 +501,7 @@ class UISlideOverlays {
val dp70 = 70.dp(container.context.resources);
val dp15 = 15.dp(container.context.resources);
val overlay = SlideUpMenuOverlay(container.context, container, text, null, true, listOf(
Loader(container.context, true, dp70).apply {
LoaderView(container.context, true, dp70).apply {
this.setPadding(0, dp15, 0, dp15);
}
), true);

View File

@ -0,0 +1,108 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dialogs.CastingHelpDialog
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
class FCastGuideActivity : AppCompatActivity() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fcast_guide);
setNavigationBarColorAndIcons();
findViewById<TextView>(R.id.text_explanation).apply {
val guideText = """
<h3>1. Install FCast Receiver:</h3>
<p>- Open Play Store, FireStore, or FCast website on your TV/desktop.<br>
- Search for "FCast Receiver", install and open it.</p>
<br>
<h3>2. Prepare the Grayjay App:</h3>
<p>- Ensure it's connected to the same network as the FCast Receiver.</p>
<br>
<h3>3. Initiate Casting from Grayjay:</h3>
<p>- Click the cast button in Grayjay.</p>
<br>
<h3>4. Connect to FCast Receiver:</h3>
<p>- Wait for your device to show in the list or add it manually with its IP address.</p>
<br>
<h3>5. Confirm Connection:</h3>
<p>- Click "OK" to confirm your device selection.</p>
<br>
<h3>6. Start Casting:</h3>
<p>- Press "start" next to the device you've added.</p>
<br>
<h3>7. Play Your Video:</h3>
<p>- Start any video in Grayjay to cast.</p>
<br>
<h3>Finding Your IP Address:</h3>
<p><b>On FCast Receiver (Android):</b> Displayed on the main screen.<br>
<b>On Windows:</b> Use 'ipconfig' in Command Prompt.<br>
<b>On Linux:</b> Use 'hostname -I' or 'ip addr' in Terminal.<br>
<b>On MacOS:</b> System Preferences > Network.</p>
""".trimIndent()
text = Html.fromHtml(guideText, Html.FROM_HTML_MODE_COMPACT)
}
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
UIDialogs.showCastingTutorialDialog(this)
finish()
}
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
UIDialogs.showCastingTutorialDialog(this)
finish()
}
findViewById<BigButton>(R.id.button_website).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_technical).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
}
override fun onBackPressed() {
UIDialogs.showCastingTutorialDialog(this)
finish()
}
companion object {
private const val TAG = "FCastGuideActivity";
}
}

View File

@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.preference.PreferenceManager
import android.util.Log
import android.util.TypedValue
import android.view.View
@ -25,11 +24,9 @@ import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.dialogs.ConnectCastingDialog
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@ -45,6 +42,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@ -90,6 +88,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
lateinit var _fragMainSuggestions: SuggestionsFragment;
lateinit var _fragMainSubscriptions: CreatorsFragment;
lateinit var _fragMainComments: CommentsFragment;
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
lateinit var _fragMainChannel: ChannelFragment;
lateinit var _fragMainSources: SourcesFragment;
@ -123,6 +122,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _isVisible = true;
private var _wasStopped = false;
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let
}
try {
handleUrlAll(content)
} catch (e: Throwable) {
Logger.i(TAG, "Failed to handle URL.", e)
UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
}
}
}
constructor() : super() {
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
val writer = StringWriter();
@ -205,6 +222,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
_fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance();
_fragMainSubscriptions = CreatorsFragment.newInstance();
_fragMainComments = CommentsFragment.newInstance();
_fragMainChannel = ChannelFragment.newInstance();
_fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance();
_fragMainSources = SourcesFragment.newInstance();
@ -282,6 +300,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Set top bars
_fragMainHome.topBar = _fragTopBarGeneral;
_fragMainSubscriptions.topBar = _fragTopBarGeneral;
_fragMainComments.topBar = _fragTopBarGeneral;
_fragMainSuggestions.topBar = _fragTopBarSearch;
_fragMainVideoSearchResults.topBar = _fragTopBarSearch;
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
@ -406,6 +425,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
}*/
fun showUrlQrCodeScanner() {
try {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.captureActivity = QRCaptureActivity::class.java
_urlQrCodeResultLauncher.launch(integrator.createScanIntent())
} catch (e: Throwable) {
Logger.i(TAG, "Failed to handle show QR scanner.", e)
UIDialogs.toast(this, "Failed to show QR scanner: ${e.message}")
}
}
override fun onResume() {
super.onResume();
Logger.v(TAG, "onResume")
@ -493,76 +529,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try {
if (targetData != null) {
when(intent.scheme) {
"grayjay" -> {
if(targetData.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(targetData))
{
UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
if(fragCurrent is BuyFragment)
closeSegment(fragCurrent);
}
else
UIDialogs.toast(getString(R.string.invalid_license_format));
}
else if(targetData.startsWith("grayjay://plugin/")) {
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(targetData.substring("grayjay://plugin/".length));
};
startActivity(intent);
}
else if(targetData.startsWith("grayjay://video/")) {
val videoUrl = targetData.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(targetData.startsWith("grayjay://channel/")) {
val channelUrl = targetData.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
}
"content" -> {
if(!handleContent(targetData, intent.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${targetData}]",
"Ok",
{ });
}
}
"file" -> {
if(!handleFile(targetData)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_file_format) + " [${targetData}]",
"Ok",
{ });
}
}
"polycentric" -> {
if(!handlePolycentric(targetData)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_polycentric_format) + " [${targetData}]",
"Ok",
{ });
}
}
else -> {
if (!handleUrl(targetData)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_url_format) + " [${targetData}]",
"Ok",
{ });
}
}
}
handleUrlAll(targetData)
}
}
catch(ex: Throwable) {
@ -570,6 +537,90 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
fun handleUrlAll(url: String) {
val uri = Uri.parse(url)
when (uri.scheme) {
"grayjay" -> {
if(url.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(url))
{
UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
if(fragCurrent is BuyFragment)
closeSegment(fragCurrent);
}
else
UIDialogs.toast(getString(R.string.invalid_license_format));
}
else if(url.startsWith("grayjay://plugin/")) {
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url.substring("grayjay://plugin/".length));
};
startActivity(intent);
}
else if(url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
}
"content" -> {
if(!handleContent(url, intent.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${url}]",
"Ok",
{ });
}
}
"file" -> {
if(!handleFile(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_file_format) + " [${url}]",
"Ok",
{ });
}
}
"polycentric" -> {
if(!handlePolycentric(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_polycentric_format) + " [${url}]",
"Ok",
{ });
}
}
"fcast" -> {
if(!handleFCast(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_cast,
"Unknown FCast format [${url}]",
"Ok",
{ });
}
}
else -> {
if (!handleUrl(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_url_format) + " [${url}]",
"Ok",
{ });
}
}
}
}
fun handleUrl(url: String): Boolean {
Logger.i(TAG, "handleUrl(url=$url)")
@ -716,6 +767,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) })
return true;
}
fun handleFCast(url: String): Boolean {
Logger.i(TAG, "handleFCast");
try {
StateCasting.instance.handleUrl(this, url)
return true;
} catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
}
return false
}
private fun readSharedContent(contentPath: String): ByteArray {
return contentResolver.openInputStream(Uri.parse(contentPath))?.use {
return it.readBytes();
@ -916,6 +981,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
SearchTopBarFragment::class -> _fragTopBarSearch as T;
CreatorsFragment::class -> _fragMainSubscriptions as T;
CommentsFragment::class -> _fragMainComments as T;
SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T;
PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T;
ChannelFragment::class -> _fragMainChannel as T;

View File

@ -15,7 +15,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
@ -23,7 +23,7 @@ import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private lateinit var _loader: Loader;
private lateinit var _loaderView: LoaderView;
private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton;
@ -43,7 +43,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
_loader = findViewById(R.id.loader);
_loaderView = findViewById(R.id.loader);
_form.onChanged.subscribe { field, value ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
@ -70,9 +70,9 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
fun reloadSettings() {
_form.setSearchVisible(false);
_loader.start();
_loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loader.stop();
_loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;

View File

@ -4,10 +4,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.Pointer
import com.futo.polycentric.core.SignedEvent
import userpackage.Protocol.Reference
import java.time.OffsetDateTime
@ -20,16 +17,18 @@ class PolycentricPlatformComment : IPlatformComment {
override val replyCount: Int?;
val eventPointer: Pointer;
val reference: Reference;
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, reference: Reference, replyCount: Int? = null) {
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) {
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
this.reference = reference;
this.eventPointer = eventPointer;
this.reference = eventPointer.toReference();
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
@ -37,7 +36,7 @@ class PolycentricPlatformComment : IPlatformComment {
}
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount);
}
companion object {

View File

@ -0,0 +1,51 @@
package com.futo.platformplayer.api.media.models.streams.sources
import android.net.Uri
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
class HLSVariantVideoUrlSource(
override val name: String,
override val width: Int,
override val height: Int,
override val container: String,
override val codec: String,
override val bitrate: Int?,
override val duration: Long,
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override fun getVideoUrl(): String {
return url
}
}
class HLSVariantAudioUrlSource(
override val name: String,
override val bitrate: Int,
override val container: String,
override val codec: String,
override val language: String,
override val duration: Long?,
override val priority: Boolean,
val url: String
) : IAudioUrlSource {
override fun getAudioUrl(): String {
return url
}
}
class HLSVariantSubtitleUrlSource(
override val name: String,
override val url: String,
override val format: String,
) : ISubtitleSource {
override val hasFetch: Boolean = false
override fun getSubtitles(): String? {
return null
}
override suspend fun getSubtitlesURI(): Uri? {
return Uri.parse(url)
}
}

View File

@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
import android.os.Looper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
@ -49,7 +48,7 @@ class AirPlayCastingDevice : CastingDevice {
return;
}
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
if (resumePosition > 0.0) {

View File

@ -1,10 +1,15 @@
package com.futo.platformplayer.casting
import android.content.Context
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.net.InetAddress
import java.time.OffsetDateTime
@ -14,10 +19,27 @@ enum class CastConnectionState {
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FASTCAST
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDevice {

View File

@ -2,18 +2,16 @@ package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.protos.DeviceAuthMessageOuterClass
import com.futo.platformplayer.protos.ChromeCast
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.*
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.net.InetAddress
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
@ -376,7 +374,7 @@ class ChromecastCastingDevice : CastingDevice {
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = DeviceAuthMessageOuterClass.CastMessage.parseFrom(messageBytes);
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
@ -429,12 +427,12 @@ class ChromecastCastingDevice : CastingDevice {
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
try {
val castMessage = DeviceAuthMessageOuterClass.CastMessage.newBuilder()
.setProtocolVersion(DeviceAuthMessageOuterClass.CastMessage.ProtocolVersion.CASTV2_1_0)
val castMessage = ChromeCast.CastMessage.newBuilder()
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
.setPayloadType(DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING)
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
@ -448,8 +446,8 @@ class ChromecastCastingDevice : CastingDevice {
}
}
private fun handleMessage(message: DeviceAuthMessageOuterClass.CastMessage) {
if (message.payloadType == DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING) {
private fun handleMessage(message: ChromeCast.CastMessage) {
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {

View File

@ -30,10 +30,10 @@ enum class Opcode(val value: Byte) {
SET_VOLUME(8)
}
class FastCastCastingDevice : CastingDevice {
class FCastCastingDevice : CastingDevice {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FASTCAST;
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
@ -72,7 +72,7 @@ class FastCastCastingDevice : CastingDevice {
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
sendMessage(Opcode.PLAY, FastCastPlayMessage(
sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition.toInt()
@ -87,7 +87,7 @@ class FastCastCastingDevice : CastingDevice {
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
sendMessage(Opcode.PLAY, FastCastPlayMessage(
sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition.toInt()
@ -100,7 +100,7 @@ class FastCastCastingDevice : CastingDevice {
}
this.volume = volume
sendMessage(Opcode.SET_VOLUME, FastCastSetVolumeMessage(volume))
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
}
override fun seekVideo(timeSeconds: Double) {
@ -108,7 +108,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
sendMessage(Opcode.SEEK, FastCastSeekMessage(
sendMessage(Opcode.SEEK, FCastSeekMessage(
time = timeSeconds.toInt()
));
}
@ -282,7 +282,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
val playbackUpdate = Json.decodeFromString<FastCastPlaybackUpdateMessage>(json);
val playbackUpdate = Json.decodeFromString<FCastPlaybackUpdateMessage>(json);
time = playbackUpdate.time.toDouble();
isPlaying = when (playbackUpdate.state) {
1 -> true
@ -295,7 +295,7 @@ class FastCastCastingDevice : CastingDevice {
return;
}
val volumeUpdate = Json.decodeFromString<FastCastVolumeUpdateMessage>(json);
val volumeUpdate = Json.decodeFromString<FCastVolumeUpdateMessage>(json);
volume = volumeUpdate.volume;
}
else -> { }
@ -398,7 +398,7 @@ class FastCastCastingDevice : CastingDevice {
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.FASTCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {

View File

@ -2,8 +2,11 @@ package com.futo.platformplayer.casting
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Looper
import android.util.Base64
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.*
@ -27,6 +30,9 @@ import javax.jmdns.ServiceListener
import kotlin.collections.HashMap
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import javax.jmdns.ServiceTypeListener
class StateCasting {
@ -147,6 +153,32 @@ class StateCasting {
}
}
fun handleUrl(context: Context, url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json = Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
val tcpService = networkConfig.services.first { v -> v.type == 0 }
addRememberedDevice(CastingDeviceInfo(
name = networkConfig.name,
type = CastProtocolType.FCAST,
addresses = networkConfig.addresses.toTypedArray(),
port = tcpService.port
))
UIDialogs.toast(context,"FCast device '${networkConfig.name}' added")
}
fun onStop() {
val ad = activeDevice ?: return;
Logger.i(TAG, "Stopping active device because of onStop.");
@ -345,7 +377,7 @@ class StateCasting {
} else {
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
if (ad is FastCastCastingDevice) {
if (ad is FCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} else if (ad is AirPlayCastingDevice) {
@ -961,7 +993,7 @@ class StateCasting {
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf();
val proxyStreams = ad !is FastCastCastingDevice;
val proxyStreams = ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
@ -1042,8 +1074,8 @@ class StateCasting {
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FASTCAST -> {
FastCastCastingDevice(deviceInfo);
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
else -> throw Exception("${deviceInfo.type} is not a valid casting protocol")
}
@ -1090,8 +1122,8 @@ class StateCasting {
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice<FastCastCastingDevice>(name,
deviceFactory = { FastCastCastingDevice(name, addresses, port) },
return addOrUpdateCastDevice<FCastCastingDevice>(name,
deviceFactory = { FCastCastingDevice(name, addresses, port) },
deviceUpdater = { d ->
if (d.isReady) {
return@addOrUpdateCastDevice false;
@ -1167,6 +1199,19 @@ class StateCasting {
}
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
val instance: StateCasting = StateCasting();

View File

@ -3,7 +3,7 @@ package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@kotlinx.serialization.Serializable
data class FastCastPlayMessage(
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
@ -11,23 +11,23 @@ data class FastCastPlayMessage(
) { }
@kotlinx.serialization.Serializable
data class FastCastSeekMessage(
data class FCastSeekMessage(
val time: Int
) { }
@kotlinx.serialization.Serializable
data class FastCastPlaybackUpdateMessage(
data class FCastPlaybackUpdateMessage(
val time: Int,
val state: Int
) { }
@Serializable
data class FastCastVolumeUpdateMessage(
data class FCastVolumeUpdateMessage(
val volume: Double
)
@Serializable
data class FastCastSetVolumeMessage(
data class FCastSetVolumeMessage(
val volume: Double
)

View File

@ -12,10 +12,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class CastingAddDialog(context: Context?) : AlertDialog(context) {
@ -26,6 +23,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
private lateinit var _textError: TextView;
private lateinit var _buttonCancel: Button;
private lateinit var _buttonConfirm: LinearLayout;
private lateinit var _buttonTutorial: TextView;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@ -38,6 +36,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError = findViewById(R.id.text_error);
_buttonCancel = findViewById(R.id.button_cancel);
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
@ -62,7 +61,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm.setOnClickListener {
val castProtocolType: CastProtocolType = when (_spinnerType.selectedItemPosition) {
0 -> CastProtocolType.FASTCAST
0 -> CastProtocolType.FCAST
1 -> CastProtocolType.CHROMECAST
2 -> CastProtocolType.AIRPLAY
else -> {
@ -105,6 +104,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
performDismiss();
};
_buttonTutorial.setOnClickListener {
UIDialogs.showCastingTutorialDialog(context)
dismiss()
}
}
override fun show() {

View File

@ -0,0 +1,63 @@
package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.FCastGuideActivity
import com.futo.platformplayer.activities.PolycentricWhyActivity
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.buttons.BigButton
class CastingHelpDialog(context: Context?) : AlertDialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_casting_help, null));
findViewById<BigButton>(R.id.button_guide).onClick.subscribe {
context.startActivity(Intent(context, FCastGuideActivity::class.java))
}
findViewById<BigButton>(R.id.button_video).onClick.subscribe {
try {
//TODO: Replace the URL with the casting video URL
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
context.startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_website).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
context.startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_technical).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
context.startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
dismiss()
UIDialogs.showCastingAddDialog(context)
}
}
companion object {
private val TAG = "CastingTutorialDialog";
}
}

View File

@ -118,7 +118,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
msg = comment,
rating = RatingLikeDislikes(0, 0),
date = OffsetDateTime.now(),
reference = eventPointer.toReference()
eventPointer = eventPointer
));
dismiss();

View File

@ -1,24 +1,33 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.AddSourceActivity
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.QRCaptureActivity
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@ -28,6 +37,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: Button;
private lateinit var _buttonScanQR: Button;
private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView;
@ -44,6 +54,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_imageLoader = findViewById(R.id.image_loader);
_buttonClose = findViewById(R.id.button_close);
_buttonAdd = findViewById(R.id.button_add);
_buttonScanQR = findViewById(R.id.button_scan_qr);
_recyclerDevices = findViewById(R.id.recycler_devices);
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
@ -77,6 +88,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
UIDialogs.showCastingAddDialog(context);
dismiss();
};
val c = ownerActivity
if (c is MainActivity) {
_buttonScanQR.visibility = View.VISIBLE
_buttonScanQR.setOnClickListener {
c.showUrlQrCodeScanner()
dismiss()
};
} else {
_buttonScanQR.visibility = View.GONE
}
}
override fun show() {

View File

@ -16,9 +16,7 @@ import com.futo.platformplayer.casting.*
import com.futo.platformplayer.states.StateApp
import com.google.android.material.slider.Slider
import com.google.android.material.slider.Slider.OnChangeListener
import com.google.android.material.slider.Slider.OnSliderTouchListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
@ -105,7 +103,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
} else if (d is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
} else if (d is FastCastCastingDevice) {
} else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FastCast";
}

View File

@ -1,11 +1,17 @@
package com.futo.platformplayer.downloads
import android.content.Context
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@ -18,22 +24,28 @@ import com.futo.platformplayer.hasAnySource
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.isDownloadable
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.OffsetDateTime
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom
import kotlin.coroutines.resumeWithException
@kotlinx.serialization.Serializable
class VideoDownload {
@ -137,7 +149,7 @@ class VideoDownload {
return items.joinToString("");
}
suspend fun prepare() {
suspend fun prepare(client: ManagedHttpClient) {
Logger.i(TAG, "VideoDownload Prepare [${name}]");
if(video == null && videoDetails == null)
throw IllegalStateException("Missing information for download to complete");
@ -157,24 +169,65 @@ class VideoDownload {
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
if(videoSource == null && targetPixelCount != null) {
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) {
if (source is IHLSManifestSource) {
try {
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) {
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
}
}
} catch (e: Throwable) {
Log.i(TAG, "Failed to get HLS video sources", e)
}
} else {
videoSources.add(source)
}
}
val vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
// ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource != null) {
if (vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource);
videoSource = VideoUrlSource.fromUrlSource(vsource)
else
throw DownloadException("Video source is not supported for downloading (yet)", false);
}
}
if(audioSource == null && targetBitrate != null) {
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
val audioSources = arrayListOf<IAudioSource>()
val video = original.video
if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) {
if (source is IHLSManifestSource) {
try {
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) {
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
}
}
} catch (e: Throwable) {
Log.i(TAG, "Failed to get HLS audio sources", e)
}
} else {
audioSources.add(source)
}
}
}
val asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
?: if(videoSource != null ) null
else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null)
audioSource = null;
else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource);
audioSource = AudioUrlSource.fromUrlSource(asource)
else
throw DownloadException("Audio source is not supported for downloading (yet)", false);
}
@ -183,7 +236,8 @@ class VideoDownload {
throw DownloadException("No valid sources found for video/audio");
}
}
suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
suspend fun download(context: Context, client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
Logger.i(TAG, "VideoDownload Download [${name}]");
if(videoDetails == null || (videoSource == null && audioSource == null))
throw IllegalStateException("Missing information for download to complete");
@ -199,7 +253,7 @@ class VideoDownload {
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
}
if(audioSource != null) {
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.language}-${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
if(subtitleSource != null) {
@ -217,7 +271,8 @@ class VideoDownload {
if(videoSource != null) {
sourcesToDownload.add(async {
Logger.i(TAG, "Started downloading video");
videoFileSize = downloadSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!)) { length, totalRead, speed ->
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
synchronized(progressLock) {
lastVideoLength = length;
lastVideoRead = totalRead;
@ -235,12 +290,18 @@ class VideoDownload {
}
}
}
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
});
}
if(audioSource != null) {
sourcesToDownload.add(async {
Logger.i(TAG, "Started downloading audio");
audioFileSize = downloadSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!)) { length, totalRead, speed ->
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
synchronized(progressLock) {
lastAudioLength = length;
lastAudioRead = totalRead;
@ -258,6 +319,11 @@ class VideoDownload {
}
}
}
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
});
}
if (subtitleSource != null) {
@ -279,7 +345,105 @@ class VideoDownload {
throw ex;
}
}
private fun downloadSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
var downloadedTotalLength = 0L
val segmentFiles = arrayListOf<File>()
try {
val response = client.get(hlsUrl)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) {
return@forEachIndexed
}
Logger.i(TAG, "Download '$name' segment $index Sequential");
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, segmentFile.outputStream(), segment.uri) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
}
downloadedTotalLength += segmentLength
}
Logger.i(TAG, "Combining segments into $targetFile");
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "${name} downloadSource Finished");
}
catch(ioex: IOException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
throw ioex;
}
catch(ex: Throwable) {
if(targetFile.exists() ?: false)
targetFile.delete();
throw ex;
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength;
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { statistics ->
//TODO: Show progress?
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ Logger.v(TAG, it.message) },
statisticsCallback,
executorService
)
continuation.invokeOnCancellation {
session.cancel()
}
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@ -472,8 +636,10 @@ class VideoDownload {
val expectedFile = File(videoFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Video file missing after download");
if(expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
if (videoSource?.container != "application/vnd.apple.mpegurl") {
if (expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
}
if(audioSource != null) {
if(audioFilePath == null)
@ -481,8 +647,10 @@ class VideoDownload {
val expectedFile = File(audioFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Audio file missing after download");
if(expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
if (audioSource?.container != "application/vnd.apple.mpegurl") {
if (expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
}
}
if(subtitleSource != null) {
if(subtitleFilePath == null)
@ -560,7 +728,7 @@ class VideoDownload {
const val GROUP_PLAYLIST = "Playlist";
fun videoContainerToExtension(container: String): String? {
if (container.contains("video/mp4"))
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4";
else if (container.contains("application/x-mpegURL"))
return "m3u8";
@ -585,6 +753,8 @@ class VideoDownload {
return "mp3";
else if (container.contains("audio/webm"))
return "webma";
else if (container == "application/vnd.apple.mpegurl")
return "mp4";
else
return "audio";
}

View File

@ -351,6 +351,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");

View File

@ -35,6 +35,11 @@ class BuyFragment : MainFragment() {
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView()
_view = null
}
class BuyView: LinearLayout {
private val _fragment: BuyFragment;

View File

@ -0,0 +1,316 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.adapters.CommentWithReferenceViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.polycentric.core.PublicKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.net.UnknownHostException
import java.util.IdentityHashMap
class CommentsFragment : MainFragment() {
override val isMainView : Boolean = true
override val isTab: Boolean = true
override val hasBottomBar: Boolean get() = true
private var _view: CommentsView? = null
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack)
_view?.onShown()
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = CommentsView(this, inflater)
_view = view
return view
}
override fun onDestroyMainView() {
super.onDestroyMainView()
_view = null
}
override fun onBackPressed(): Boolean {
return _view?.onBackPressed() ?: false
}
override fun onResume() {
super.onResume()
_view?.onShown()
}
companion object {
fun newInstance() = CommentsFragment().apply {}
private const val TAG = "CommentsFragment"
}
class CommentsView : FrameLayout {
private val _fragment: CommentsFragment
private val _recyclerComments: RecyclerView;
private val _adapterComments: InsertedViewAdapterWithLoader<CommentWithReferenceViewHolder>;
private val _textCommentCount: TextView
private val _comments: ArrayList<IPlatformComment> = arrayListOf();
private val _llmReplies: LinearLayoutManager;
private val _spinnerSortBy: Spinner;
private val _layoutNotLoggedIn: LinearLayout;
private val _buttonLogin: LinearLayout;
private var _loading = false;
private val _repliesOverlay: RepliesOverlay;
private var _repliesAnimator: ViewPropertyAnimator? = null;
private val _cache: IdentityHashMap<IPlatformComment, StatePolycentric.LikesDislikesReplies> = IdentityHashMap()
private val _taskLoadComments = if(!isInEditMode) TaskHandler<PublicKey, List<IPlatformComment>>(
StateApp.instance.scopeGetter, { StatePolycentric.instance.getSystemComments(context, it) })
.success { pager -> onCommentsLoaded(pager); }
.exception<UnknownHostException> {
UIDialogs.toast("Failed to load comments");
setLoading(false);
}
.exception<Throwable> {
Logger.e(TAG, "Failed to load comments.", it);
UIDialogs.toast(context, context.getString(R.string.failed_to_load_comments) + "\n" + (it.message ?: ""));
setLoading(false);
} else TaskHandler(IPlatformVideoDetails::class.java, StateApp.instance.scopeGetter);
constructor(fragment: CommentsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment
inflater.inflate(R.layout.fragment_comments, this)
val commentHeader = findViewById<LinearLayout>(R.id.layout_header)
(commentHeader.parent as ViewGroup).removeView(commentHeader)
_textCommentCount = commentHeader.findViewById(R.id.text_comment_count)
_recyclerComments = findViewById(R.id.recycler_comments)
_adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(commentHeader), arrayListOf(),
childCountGetter = { _comments.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_comments[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = CommentWithReferenceViewHolder(viewGroup, _cache);
holder.onDelete.subscribe(::onDelete);
holder.onRepliesClick.subscribe(::onRepliesClick);
return@InsertedViewAdapterWithLoader holder;
}
);
_spinnerSortBy = commentHeader.findViewById(R.id.spinner_sortby);
_spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.comments_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
_spinnerSortBy.setSelection(0);
_spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
if (_spinnerSortBy.selectedItemPosition == 0) {
_comments.sortByDescending { it.date!! }
} else if (_spinnerSortBy.selectedItemPosition == 1) {
_comments.sortBy { it.date!! }
}
_adapterComments.notifyDataSetChanged()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
_llmReplies = LinearLayoutManager(context);
_recyclerComments.layoutManager = _llmReplies;
_recyclerComments.adapter = _adapterComments;
updateCommentCountString();
_layoutNotLoggedIn = findViewById(R.id.layout_not_logged_in)
_layoutNotLoggedIn.visibility = View.GONE
_buttonLogin = findViewById(R.id.button_login)
_buttonLogin.setOnClickListener {
context.startActivity(Intent(context, PolycentricHomeActivity::class.java));
}
_repliesOverlay = findViewById(R.id.replies_overlay);
_repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
}
private fun onDelete(comment: IPlatformComment) {
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete this comment?", {
val processHandle = StatePolycentric.instance.processHandle ?: return@showConfirmationDialog
if (comment !is PolycentricPlatformComment) {
return@showConfirmationDialog
}
val index = _comments.indexOf(comment)
if (index != -1) {
_comments.removeAt(index)
_adapterComments.notifyItemRemoved(_adapterComments.childToParentPosition(index))
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
processHandle.delete(comment.eventPointer.process, comment.eventPointer.logicalClock)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to delete event.", e);
return@launch
}
try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to fully backfill servers.", e);
}
}
}
})
}
fun onBackPressed(): Boolean {
if (_repliesOverlay.visibility == View.VISIBLE) {
setRepliesOverlayVisible(isVisible = false, animate = true);
return true
}
return false
}
private fun onRepliesClick(c: IPlatformComment) {
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
metadata += "$replyCount " + context.getString(R.string.replies);
}
if (c is PolycentricPlatformComment) {
_repliesOverlay.load(false, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{ newComment ->
synchronized(_cache) {
_cache.remove(c)
}
val newCommentIndex = if (_spinnerSortBy.selectedItemPosition == 0) {
_comments.indexOfFirst { it.date!! < newComment.date!! }.takeIf { it != -1 } ?: _comments.size
} else {
_comments.indexOfFirst { it.date!! > newComment.date!! }.takeIf { it != -1 } ?: _comments.size
}
_comments.add(newCommentIndex, newComment)
_adapterComments.notifyItemInserted(_adapterComments.childToParentPosition(newCommentIndex))
});
} else {
_repliesOverlay.load(true, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
setRepliesOverlayVisible(isVisible = true, animate = true);
}
private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
val desiredVisibility = if (isVisible) View.VISIBLE else View.GONE
if (_repliesOverlay.visibility == desiredVisibility) {
return;
}
_repliesAnimator?.cancel();
if (isVisible) {
_repliesOverlay.visibility = View.VISIBLE;
if (animate) {
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(0f)
.withEndAction {
_repliesAnimator = null;
}.apply { start() };
}
} else {
if (animate) {
_repliesOverlay.translationY = 0f;
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(_repliesOverlay.height.toFloat())
.withEndAction {
_repliesOverlay.visibility = GONE;
_repliesAnimator = null;
}.apply { start(); }
} else {
_repliesOverlay.visibility = View.GONE;
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
}
}
}
private fun updateCommentCountString() {
_textCommentCount.text = context.getString(R.string.these_are_all_commentcount_comments_you_have_made_in_grayjay).replace("{commentCount}", _comments.size.toString())
}
private fun setLoading(loading: Boolean) {
if (_loading == loading) {
return;
}
_loading = loading;
_adapterComments.setLoading(loading);
}
private fun fetchComments() {
val system = StatePolycentric.instance.processHandle?.system ?: return
_comments.clear()
_adapterComments.notifyDataSetChanged()
setLoading(true)
_taskLoadComments.run(system)
}
private fun onCommentsLoaded(comments: List<IPlatformComment>) {
setLoading(false)
_comments.addAll(comments)
if (_spinnerSortBy.selectedItemPosition == 0) {
_comments.sortByDescending { it.date!! }
} else if (_spinnerSortBy.selectedItemPosition == 1) {
_comments.sortBy { it.date!! }
}
_adapterComments.notifyDataSetChanged()
updateCommentCountString()
}
fun onShown() {
val processHandle = StatePolycentric.instance.processHandle
if (processHandle != null) {
_layoutNotLoggedIn.visibility = View.GONE
_recyclerComments.visibility = View.VISIBLE
fetchComments()
} else {
_layoutNotLoggedIn.visibility = View.VISIBLE
_recyclerComments.visibility= View.GONE
}
}
}
}

View File

@ -13,7 +13,6 @@ import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.constructs.Event1
@ -21,7 +20,6 @@ import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
@ -64,6 +62,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val fragment: TFragment;
private val _scrollListener: RecyclerView.OnScrollListener;
private var _automaticNextPageCounter = 0;
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
this.fragment = fragment;
@ -122,7 +121,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content);
var filteredNextPageCounter = 0;
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
it.nextPageAsync();
@ -142,15 +140,8 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it);
if(filteredResults.isEmpty()) {
filteredNextPageCounter++
if(filteredNextPageCounter <= 4)
loadNextPage()
}
else {
filteredNextPageCounter = 0;
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
}
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
ensureEnoughContentVisible(filteredResults)
}.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@ -170,8 +161,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition();
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
//Logger.i(TAG, "loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold _results.size=${_results.size}")
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
loadNextPage();
}
}
@ -180,6 +173,33 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_recyclerResults.addOnScrollListener(_scrollListener);
}
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else {
val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
val itemHeight = firstVisibleView?.height ?: 0
val occupiedSpace = recyclerData.results.size * itemHeight
val recyclerViewHeight = _recyclerResults.height
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
occupiedSpace >= recyclerViewHeight
} else {
false
}
}
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if(_automaticNextPageCounter <= 4)
loadNextPage()
} else {
_automaticNextPageCounter = 0;
}
}
protected fun setTextCentered(text: String?) {
_textCentered.text = text;
}
@ -369,6 +389,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle;
ensureEnoughContentVisible(filteredResults)
}
private fun detachPagerEvents() {

View File

@ -224,7 +224,7 @@ class PostDetailFragment : MainFragment {
updateCommentType(false);
};
_commentsList.onClick.subscribe { c ->
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
@ -233,7 +233,7 @@ class PostDetailFragment : MainFragment {
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
_repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference,
_repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
@ -241,7 +241,7 @@ class PostDetailFragment : MainFragment {
parentComment = newComment;
});
} else {
_repliesOverlay.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
_repliesOverlay.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
setRepliesOverlayVisible(isVisible = true, animate = true);

View File

@ -37,7 +37,6 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
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.chapters.ChapterType
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@ -52,7 +51,6 @@ 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.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState
@ -60,7 +58,6 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
@ -109,7 +106,6 @@ import java.time.OffsetDateTime
import kotlin.collections.ArrayList
import kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.streams.toList
class VideoDetailView : ConstraintLayout {
@ -578,7 +574,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_current = _container_content_main;
_commentsList.onClick.subscribe { c ->
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
@ -587,7 +583,7 @@ class VideoDetailView : ConstraintLayout {
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
_container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference,
_container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
@ -595,7 +591,7 @@ class VideoDetailView : ConstraintLayout {
parentComment = newComment;
});
} else {
_container_content_replies.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
_container_content_replies.load(_toggleCommentType.value, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
switchContentView(_container_content_replies);
};

View File

@ -3,8 +3,11 @@ package com.futo.platformplayer.helpers
import android.net.Uri
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.HLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
@ -20,11 +23,23 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
class VideoHelper {
companion object {
fun isDownloadable(detail: IPlatformVideoDetails) =
(detail.video.videoSources.any { isDownloadable(it) }) ||
(if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false);
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource;
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
fun isDownloadable(detail: IPlatformVideoDetails): Boolean {
if (detail.video.videoSources.any { isDownloadable(it) }) {
return true
}
val descriptor = detail.video
if (descriptor is VideoUnMuxedSourceDescriptor) {
if (descriptor.audioSources.any { isDownloadable(it) }) {
return true
}
}
return false
}
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource || source is IHLSManifestAudioSource;
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {

View File

@ -1,8 +1,22 @@
package com.futo.platformplayer.parsers
import android.view.View
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.yesNoToBoolean
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@ -85,6 +99,48 @@ class HLS {
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
}
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getVideoSources()
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
listOf(HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url))
} else if (source is IHLSManifestAudioSource) {
listOf()
} else {
throw NotImplementedError()
}
} else {
throw e
}
}
}
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getAudioSources()
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
listOf()
} else if (source is IHLSManifestAudioSource) {
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url))
} else {
throw NotImplementedError()
}
} else {
throw e
}
}
}
//TODO: getSubtitleSources
private fun resolveUrl(baseUrl: String, url: String): String {
val baseUri = URI(baseUrl)
val urlUri = URI(url)
@ -269,6 +325,49 @@ class HLS {
return builder.toString()
}
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map {
var width: Int? = null
var height: Int? = null
val resolutionTokens = it.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
}
}
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
else -> null
}
}
}
fun getSubtitleSources(): List<HLSVariantSubtitleUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"SUBTITLE" -> HLSVariantSubtitleUrlSource(it.name?.ifEmpty { "Subtitle (${suffix})" } ?: "Subtitle (${suffix})", it.uri, "application/vnd.apple.mpegurl")
else -> null
}
}
}
}
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {

View File

@ -7,7 +7,6 @@ import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.resolveChannelUrls
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
@ -19,17 +18,21 @@ import java.nio.ByteBuffer
import java.time.OffsetDateTime
class PolycentricCache {
data class CachedOwnedClaims(val ownedClaims: List<OwnedClaim>?, val creationTime: OffsetDateTime = OffsetDateTime.now());
data class CachedOwnedClaims(val ownedClaims: List<OwnedClaim>?, val creationTime: OffsetDateTime = OffsetDateTime.now()) {
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
}
@Serializable
data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now());
data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now()) {
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
}
private val _cacheExpirationSeconds = 60 * 60 * 3;
private val _cache = hashMapOf<PlatformID, CachedOwnedClaims>()
private val _profileCache = hashMapOf<PublicKey, CachedPolycentricProfile>()
private val _profileUrlCache = FragmentedStorage.get<CachedPolycentricProfileStorage>("profileUrlCache")
private val _scope = CoroutineScope(Dispatchers.IO);
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope, { system ->
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
{ system ->
val signedProfileEvents = ApiMethods.getQueryLatest(
SERVER,
system.toProto(),
@ -150,7 +153,7 @@ class PolycentricCache {
return null
}
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
if (!ignoreExpired && cached.expired) {
return null;
}
@ -188,7 +191,7 @@ class PolycentricCache {
fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
synchronized (_profileCache) {
val cached = _profileUrlCache.get(url) ?: return null;
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
if (!ignoreExpired && cached.expired) {
return null;
}
@ -199,7 +202,7 @@ class PolycentricCache {
fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
synchronized(_profileCache) {
val cached = _profileCache[system] ?: return null;
if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
if (!ignoreExpired && cached.expired) {
return null;
}
@ -281,6 +284,7 @@ class PolycentricCache {
private const val TAG = "PolycentricCache"
const val SERVER = "https://srv1-stg.polycentric.io"
private var _instance: PolycentricCache? = null;
private val CACHE_EXPIRATION_SECONDS = 60 * 60 * 3;
@JvmStatic
val instance: PolycentricCache

View File

@ -162,6 +162,8 @@ class DownloadService : Service() {
Logger.i(TAG, "doDownloading - Ending Downloads");
stopService(this);
}
private suspend fun doDownload(download: VideoDownload) {
if(!Settings.instance.downloads.shouldDownload())
throw IllegalStateException("Downloading disabled on current network");
@ -183,14 +185,14 @@ class DownloadService : Service() {
Logger.i(TAG, "Preparing [${download.name}] started");
if(download.state == VideoDownload.State.PREPARING)
download.prepare();
download.prepare(_client);
download.changeState(VideoDownload.State.DOWNLOADING);
notifyDownload(download);
var lastNotifyTime: Long = 0L;
Logger.i(TAG, "Downloading [${download.name}] started");
//TODO: Use plugin client?
download.download(_client) { progress ->
download.download(applicationContext, _client) { progress ->
download.progress = progress;
val currentTime = System.currentTimeMillis();

View File

@ -23,6 +23,7 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.activities.MainActivity
@ -49,6 +50,7 @@ class MediaPlaybackService : Service() {
private var _mediaSession: MediaSessionCompat? = null;
private var _hasFocus: Boolean = false;
private var _focusRequest: AudioFocusRequest? = null;
private var _audioFocusLossTime_ms: Long? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Logger.v(TAG, "onStartCommand");
@ -335,16 +337,32 @@ class MediaPlaybackService : Service() {
//Do not start playing on gaining audo focus
//MediaControlReceiver.onPlayReceived.emit();
_hasFocus = true;
Log.i(TAG, "Audio focus gained");
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms)");
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
val lossTime_ms = _audioFocusLossTime_ms
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 30) {
MediaControlReceiver.onPlayReceived.emit()
}
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
val lossTime_ms = _audioFocusLossTime_ms
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 10) {
MediaControlReceiver.onPlayReceived.emit()
}
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
MediaControlReceiver.onPlayReceived.emit()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
MediaControlReceiver.onPauseReceived.emit();
_audioFocusLossTime_ms = System.currentTimeMillis()
Log.i(TAG, "Audio focus transient loss");
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Log.i(TAG, "Audio focus transient loss, can duck");
}
AudioManager.AUDIOFOCUS_LOSS -> {
_audioFocusLossTime_ms = System.currentTimeMillis()
_hasFocus = false;
MediaControlReceiver.onPauseReceived.emit();
Log.i(TAG, "Audio focus lost");

View File

@ -1,6 +1,7 @@
package com.futo.platformplayer.states
import android.content.Context
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
@ -256,9 +257,6 @@ class StateAnnouncement {
}
fun registerDidYouKnow() {
val random = Random();
val message: String? = when (random.nextInt(4 * 18 + 1)) {
@ -294,6 +292,23 @@ class StateAnnouncement {
}
}
fun registerDefaultHandlerAnnouncement() {
registerAnnouncement(
"default-url-handler",
"Allow Grayjay to open URLs",
"Click here to allow Grayjay to open URLs",
AnnouncementType.SESSION_RECURRING,
null,
null,
"Allow"
) {
UIDialogs.showUrlHandlingPrompt(StateApp.instance.context) {
instance.neverAnnouncement("default-url-handler")
instance.onAnnouncementChanged.emit()
}
}
}
companion object {
private var _instance: StateAnnouncement? = null;
val instance: StateAnnouncement

View File

@ -543,6 +543,7 @@ class StateApp {
);
}
StateAnnouncement.instance.registerDefaultHandlerAnnouncement();
StateAnnouncement.instance.registerDidYouKnow();
Logger.i(TAG, "MainApp Started: Finished");
}

View File

@ -11,17 +11,12 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.api.media.structures.PlaceholderPager
import com.futo.platformplayer.api.media.structures.RefreshChronoContentPager
import com.futo.platformplayer.api.media.structures.RefreshDedupContentPager
import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager
import com.futo.platformplayer.awaitFirstDeferred
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
@ -218,6 +213,67 @@ class StatePolycentric {
}
}
fun getSystemComments(context: Context, system: PublicKey): List<IPlatformComment> {
val dp_25 = 25.dp(context.resources)
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
val author = system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable())
val posts = arrayListOf<PolycentricPlatformComment>()
Store.instance.enumerateSignedEvents(system, ContentType.POST) { se ->
val ev = se.event
val post = Protocol.Post.parseFrom(ev.content)
posts.add(PolycentricPlatformComment(
contextUrl = author,
author = PlatformAuthorLink(
id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()),
name = systemState.username,
url = author,
thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
subscribers = null
),
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
rating = RatingLikeDislikes(0, 0),
date = if (ev.unixMilliseconds != null) Instant.ofEpochMilli(ev.unixMilliseconds!!).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = 0,
eventPointer = se.toPointer()
))
}
return posts
}
data class LikesDislikesReplies(
var likes: Long,
var dislikes: Long,
var replyCount: Long
)
suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies {
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
null,
listOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value)
.setValue(ByteString.copyFrom(Opinion.like.data))
.build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value)
.setValue(ByteString.copyFrom(Opinion.dislike.data))
.build()
),
listOf(
Protocol.QueryReferencesRequestCountReferences.newBuilder()
.setFromType(ContentType.POST.value)
.build()
)
);
val likes = response.countsList[0];
val dislikes = response.countsList[1];
val replyCount = response.countsList[2];
return LikesDislikesReplies(likes, dislikes, replyCount)
}
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager<IPlatformComment> {
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
Protocol.QueryReferencesRequestEvents.newBuilder()
@ -284,7 +340,7 @@ class StatePolycentric {
};
}
private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List<IPlatformComment> {
private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List<PolycentricPlatformComment> {
return response.itemsList.mapNotNull {
val sev = SignedEvent.fromProto(it.event);
val ev = sev.event;
@ -294,7 +350,6 @@ class StatePolycentric {
try {
val post = Protocol.Post.parseFrom(ev.content);
val id = ev.system.toProto().key.toByteArray().toBase64();
val likes = it.countsList[0];
val dislikes = it.countsList[1];
val replies = it.countsList[2];
@ -338,7 +393,7 @@ class StatePolycentric {
rating = RatingLikeDislikes(likes, dislikes),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(),
reference = sev.toPointer().toReference()
eventPointer = sev.toPointer()
);
} catch (e: Throwable) {
return@mapNotNull null;

View File

@ -1,6 +1,7 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
@ -11,9 +12,10 @@ import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import com.futo.platformplayer.R
class Loader : LinearLayout {
class LoaderView : LinearLayout {
private val _imageLoader: ImageView;
private val _automatic: Boolean;
private var _isWhite: Boolean;
private val _animatable: Animatable;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@ -24,18 +26,25 @@ class Loader : LinearLayout {
if (attrs != null) {
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderView, 0, 0);
_automatic = attrArr.getBoolean(R.styleable.LoaderView_automatic, false);
_isWhite = attrArr.getBoolean(R.styleable.LoaderView_isWhite, false);
attrArr.recycle();
} else {
_automatic = false;
_isWhite = false;
}
visibility = View.GONE;
if (_isWhite) {
_imageLoader.setColorFilter(Color.WHITE)
}
}
constructor(context: Context, automatic: Boolean, height: Int = -1) : super(context) {
constructor(context: Context, automatic: Boolean, height: Int = -1, isWhite: Boolean = false) : super(context) {
inflate(context, R.layout.view_loader, this);
_imageLoader = findViewById(R.id.image_loader);
_animatable = _imageLoader.drawable as Animatable;
_automatic = automatic;
_isWhite = isWhite;
if(height > 0) {
layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, height);

View File

@ -41,7 +41,7 @@ class MonetizationView : LinearLayout {
private val _textMerchandise: TextView;
private val _recyclerMerchandise: RecyclerView;
private val _loaderMerchandise: Loader;
private val _loaderViewMerchandise: LoaderView;
private val _layoutMerchandise: FrameLayout;
private var _merchandiseAdapterView: AnyAdapterView<StoreItem, StoreItemViewHolder>? = null;
@ -81,7 +81,7 @@ class MonetizationView : LinearLayout {
_textMerchandise = findViewById(R.id.text_merchandise);
_recyclerMerchandise = findViewById(R.id.recycler_merchandise);
_loaderMerchandise = findViewById(R.id.loader_merchandise);
_loaderViewMerchandise = findViewById(R.id.loader_merchandise);
_layoutMerchandise = findViewById(R.id.layout_merchandise);
_root = findViewById(R.id.root);
@ -108,7 +108,7 @@ class MonetizationView : LinearLayout {
}
private fun setMerchandise(items: List<StoreItem>?) {
_loaderMerchandise.stop();
_loaderViewMerchandise.stop();
if (items == null) {
_textMerchandise.visibility = View.GONE;
@ -147,7 +147,7 @@ class MonetizationView : LinearLayout {
val uri = Uri.parse(storeData);
if (uri.isAbsolute) {
_taskLoadMerchandise.run(storeData);
_loaderMerchandise.start();
_loaderViewMerchandise.start();
} else {
Logger.i(TAG, "Merchandise not loaded, not URL nor JSON")
}

View File

@ -3,6 +3,7 @@ package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@ -37,8 +38,10 @@ class CommentViewHolder : ViewHolder {
private val _layoutRating: LinearLayout;
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
private val _layoutComment: ConstraintLayout;
private val _buttonDelete: FrameLayout;
var onClick = Event1<IPlatformComment>();
var onRepliesClick = Event1<IPlatformComment>();
var onDelete = Event1<IPlatformComment>();
var comment: IPlatformComment? = null
private set;
@ -55,6 +58,7 @@ class CommentViewHolder : ViewHolder {
_buttonReplies = itemView.findViewById(R.id.button_replies);
_layoutRating = itemView.findViewById(R.id.layout_rating);
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
_buttonDelete = itemView.findViewById(R.id.button_delete);
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
val c = comment
@ -87,7 +91,12 @@ class CommentViewHolder : ViewHolder {
_buttonReplies.onClick.subscribe {
val c = comment ?: return@subscribe;
onClick.emit(c);
onRepliesClick.emit(c);
}
_buttonDelete.setOnClickListener {
val c = comment ?: return@setOnClickListener;
onDelete.emit(c);
}
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
@ -167,6 +176,13 @@ class CommentViewHolder : ViewHolder {
_buttonReplies.visibility = View.GONE;
}
val processHandle = StatePolycentric.instance.processHandle
if (processHandle != null && comment is PolycentricPlatformComment && processHandle.system == comment.eventPointer.system) {
_buttonDelete.visibility = View.VISIBLE
} else {
_buttonDelete.visibility = View.GONE
}
this.comment = comment;
}

View File

@ -0,0 +1,195 @@
package com.futo.platformplayer.views.adapters
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.pills.PillButton
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.polycentric.core.Opinion
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import userpackage.Protocol
import java.util.IdentityHashMap
class CommentWithReferenceViewHolder : ViewHolder {
private val _creatorThumbnail: CreatorThumbnail;
private val _textAuthor: TextView;
private val _textMetadata: TextView;
private val _textBody: TextView;
private val _buttonReplies: PillButton;
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
private val _layoutComment: ConstraintLayout;
private val _buttonDelete: FrameLayout;
private val _cache: IdentityHashMap<IPlatformComment, StatePolycentric.LikesDislikesReplies>;
private var _likesDislikesReplies: StatePolycentric.LikesDislikesReplies? = null;
private val _taskGetLiveComment = TaskHandler(StateApp.instance.scopeGetter, ::getLikesDislikesReplies)
.success {
_likesDislikesReplies = it
updateLikesDislikesReplies()
}
.exception<Throwable> {
Logger.w(TAG, "Failed to get live comment.", it);
//TODO: Show error
hideLikesDislikesReplies()
}
var onRepliesClick = Event1<IPlatformComment>();
var onDelete = Event1<IPlatformComment>();
var comment: IPlatformComment? = null
private set;
constructor(viewGroup: ViewGroup, cache: IdentityHashMap<IPlatformComment, StatePolycentric.LikesDislikesReplies>) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_comment_with_reference, viewGroup, false)) {
_layoutComment = itemView.findViewById(R.id.layout_comment);
_creatorThumbnail = itemView.findViewById(R.id.image_thumbnail);
_textAuthor = itemView.findViewById(R.id.text_author);
_textMetadata = itemView.findViewById(R.id.text_metadata);
_textBody = itemView.findViewById(R.id.text_body);
_buttonReplies = itemView.findViewById(R.id.button_replies);
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
_buttonDelete = itemView.findViewById(R.id.button_delete)
_cache = cache
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
val c = comment
if (c !is PolycentricPlatformComment) {
throw Exception("Not implemented for non polycentric comments")
}
if (args.hasLiked) {
args.processHandle.opinion(c.reference, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(c.reference, Opinion.dislike);
} else {
args.processHandle.opinion(c.reference, Opinion.neutral);
}
_layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e)
}
}
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
};
_buttonReplies.onClick.subscribe {
val c = comment ?: return@subscribe;
onRepliesClick.emit(c);
}
_buttonDelete.setOnClickListener {
val c = comment ?: return@setOnClickListener;
onDelete.emit(c);
}
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
}
private suspend fun getLikesDislikesReplies(c: PolycentricPlatformComment): StatePolycentric.LikesDislikesReplies {
val likesDislikesReplies = StatePolycentric.instance.getLikesDislikesReplies(c.reference)
synchronized(_cache) {
_cache[c] = likesDislikesReplies
}
return likesDislikesReplies
}
fun bind(comment: IPlatformComment) {
Log.i(TAG, "bind")
_likesDislikesReplies = null;
_taskGetLiveComment.cancel()
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
_textAuthor.text = comment.author.name;
val date = comment.date;
if (date != null) {
_textMetadata.visibility = View.VISIBLE;
_textMetadata.text = "${date.toHumanNowDiffString()} ago";
} else {
_textMetadata.visibility = View.GONE;
}
val rating = comment.rating;
if (rating is RatingLikeDislikes) {
_layoutComment.alpha = if (rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
} else {
_layoutComment.alpha = 1.0f;
}
_textBody.text = comment.message.fixHtmlLinks();
this.comment = comment;
updateLikesDislikesReplies();
}
private fun updateLikesDislikesReplies() {
Log.i(TAG, "updateLikesDislikesReplies")
val c = comment ?: return
if (c is PolycentricPlatformComment) {
if (_likesDislikesReplies == null) {
Log.i(TAG, "updateLikesDislikesReplies retrieving from cache")
synchronized(_cache) {
_likesDislikesReplies = _cache[c]
}
}
val likesDislikesReplies = _likesDislikesReplies
if (likesDislikesReplies != null) {
Log.i(TAG, "updateLikesDislikesReplies set")
val hasLiked = StatePolycentric.instance.hasLiked(c.reference);
val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference);
_pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked);
_buttonReplies.setLoading(false)
val replies = likesDislikesReplies.replyCount ?: 0;
_buttonReplies.visibility = View.VISIBLE;
_buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies);
} else {
Log.i(TAG, "updateLikesDislikesReplies to load")
_pillRatingLikesDislikes.setLoading(true)
_buttonReplies.setLoading(true)
_taskGetLiveComment.run(c)
}
} else {
hideLikesDislikesReplies()
}
}
private fun hideLikesDislikesReplies() {
_pillRatingLikesDislikes.visibility = View.GONE
_buttonReplies.visibility = View.GONE
}
companion object {
private const val TAG = "CommentWithReferenceViewHolder";
}
}

View File

@ -74,9 +74,9 @@ class DeviceViewHolder : ViewHolder {
} else if (d is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
} else if (d is FastCastCastingDevice) {
} else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FastCast";
_textType.text = "FCast";
}
_textName.text = d.name;

View File

@ -17,8 +17,8 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
var onSettings = Event1<Subscription>();
var sortBy: Int = 3
set(value) {
field = value;
updateDataset();
field = value
updateDataset()
}
constructor(inflater: LayoutInflater, confirmationMessage: String) : super() {

View File

@ -84,6 +84,9 @@ class SubscriptionViewHolder : ViewHolder {
val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true);
if (cachedProfile != null) {
onProfileLoaded(sub, cachedProfile, false);
if (cachedProfile.expired) {
_taskLoadProfile.run(sub.channel.id);
}
} else {
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
_taskLoadProfile.run(sub.channel.id);

View File

@ -19,7 +19,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.platform.PlatformIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -28,7 +28,7 @@ class PreviewNestedVideoView : PreviewVideoView {
protected val _platformIndicatorNested: PlatformIndicator;
protected val _containerLoader: LinearLayout;
protected val _loader: Loader;
protected val _loaderView: LoaderView;
protected val _containerUnavailable: LinearLayout;
protected val _textNestedUrl: TextView;
@ -42,7 +42,7 @@ class PreviewNestedVideoView : PreviewVideoView {
constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) {
_platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested);
_containerLoader = findViewById(R.id.container_loader);
_loader = findViewById(R.id.loader);
_loaderView = findViewById(R.id.loader);
_containerUnavailable = findViewById(R.id.container_unavailable);
_textNestedUrl = findViewById(R.id.text_nested_url);
@ -116,7 +116,7 @@ class PreviewNestedVideoView : PreviewVideoView {
if(!_contentSupported) {
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
_loader.stop();
_loaderView.stop();
}
else {
if(_feedStyle == FeedStyle.THUMBNAIL)
@ -132,14 +132,14 @@ class PreviewNestedVideoView : PreviewVideoView {
_contentSupported = false;
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
_loader.stop();
_loaderView.stop();
}
}
private fun loadNested(content: IPlatformNestedContent, onCompleted: ((IPlatformContentDetails)->Unit)? = null) {
Logger.i(TAG, "Loading nested content [${content.contentUrl}]");
_containerLoader.visibility = View.VISIBLE;
_loader.start();
_loaderView.start();
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val def = StatePlatform.instance.getContentDetails(content.contentUrl);
def.invokeOnCompletion {
@ -150,13 +150,13 @@ class PreviewNestedVideoView : PreviewVideoView {
if(_content == content) {
_containerUnavailable.visibility = View.VISIBLE;
_containerLoader.visibility = View.GONE;
_loader.stop();
_loaderView.stop();
}
//TODO: Handle exception
}
else if(_content == content) {
_containerLoader.visibility = View.GONE;
_loader.stop();
_loaderView.stop();
val nestedContent = def.getCompleted();
_contentNested = nestedContent;
if(nestedContent is IPlatformVideoDetails) {

View File

@ -178,6 +178,9 @@ open class PreviewVideoView : LinearLayout {
val cachedProfile = PolycentricCache.instance.getCachedProfile(content.author.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
if (cachedProfile.expired) {
_taskLoadProfile.run(content.author.id);
}
} else {
_imageNeopassChannel?.visibility = View.GONE;
_creatorThumbnail?.setThumbnail(content.author.thumbnail, false);

View File

@ -68,6 +68,9 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
val cachedProfile = PolycentricCache.instance.getCachedProfile(authorLink.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
if (cachedProfile.expired) {
_taskLoadProfile.run(authorLink.id);
}
} else {
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
_taskLoadProfile.run(authorLink.id);

View File

@ -54,6 +54,9 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
val cachedProfile = PolycentricCache.instance.getCachedProfile(subscription.channel.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
if (cachedProfile.expired) {
_taskLoadProfile.run(subscription.channel.id);
}
} else {
_creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
_taskLoadProfile.run(subscription.channel.id);

View File

@ -6,7 +6,6 @@ import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
@ -63,11 +62,15 @@ class GestureControlView : LinearLayout {
private var _fullScreenFactorUp = 1.0f;
private var _fullScreenFactorDown = 1.0f;
private val _gestureController: GestureDetectorCompat;
val onSeek = Event1<Long>();
val onBrightnessAdjusted = Event1<Float>();
val onSoundAdjusted = Event1<Float>();
val onToggleFullscreen = Event0();
var fullScreenGestureEnabled = true
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_gesture_controls, this, true);
@ -82,13 +85,8 @@ class GestureControlView : LinearLayout {
_layoutControlsBrightness = findViewById(R.id.layout_controls_brightness);
_progressBrightness = findViewById(R.id.progress_brightness);
_layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen);
}
fun setupTouchArea(view: View, layoutControls: ViewGroup? = null, background: View? = null) {
_layoutControls = layoutControls;
_background = background;
val gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener {
_gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener {
override fun onDown(p0: MotionEvent): Boolean { return false; }
override fun onShowPress(p0: MotionEvent) = Unit;
override fun onSingleTapUp(p0: MotionEvent): Boolean { return false; }
@ -112,15 +110,14 @@ class GestureControlView : LinearLayout {
_fullScreenFactorDown = (_fullScreenFactorDown + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.alpha = _fullScreenFactorDown;
} else {
val rx = p0.x / width;
val ry = p0.y / height;
Logger.v(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen")
val rx = (p0.x + p1.x) / (2 * width);
val ry = (p0.y + p1.y) / (2 * height);
if (ry > 0.1 && ry < 0.9) {
if (_isFullScreen && rx < 0.4) {
if (_isFullScreen && rx < 0.2) {
startAdjustingBrightness();
} else if (_isFullScreen && rx > 0.6) {
} else if (_isFullScreen && rx > 0.8) {
startAdjustingSound();
} else if (rx >= 0.4 && rx <= 0.6) {
} else if (fullScreenGestureEnabled && rx in 0.3..0.7) {
if (_isFullScreen) {
startAdjustingFullscreenDown();
} else {
@ -136,7 +133,7 @@ class GestureControlView : LinearLayout {
override fun onFling(p0: MotionEvent, p1: MotionEvent, p2: Float, p3: Float): Boolean { return false; }
});
gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
_gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
if (_skipping) {
return false;
@ -166,52 +163,58 @@ class GestureControlView : LinearLayout {
}
});
val touchListener = object : OnTouchListener {
override fun onTouch(v: View?, ev: MotionEvent): Boolean {
cancelHideJob();
isClickable = true
}
if (_skipping) {
if (ev.action == MotionEvent.ACTION_UP) {
startExitFastForward();
stopAutoFastForward();
} else if (ev.action == MotionEvent.ACTION_DOWN) {
_jobExitFastForward?.cancel();
_jobExitFastForward = null;
fun setupTouchArea(layoutControls: ViewGroup? = null, background: View? = null) {
_layoutControls = layoutControls;
_background = background;
}
startAutoFastForward();
fastForwardTick();
}
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
val ev = event ?: return super.onTouchEvent(event);
if (_adjustingSound && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingSound();
}
cancelHideJob();
if (_adjustingBrightness && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingBrightness();
}
if (_skipping) {
if (ev.action == MotionEvent.ACTION_UP) {
startExitFastForward();
stopAutoFastForward();
} else if (ev.action == MotionEvent.ACTION_DOWN) {
_jobExitFastForward?.cancel();
_jobExitFastForward = null;
if (_adjustingFullscreenUp && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorUp > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenUp();
}
if (_adjustingFullscreenDown && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorDown > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenDown();
}
startHideJobIfNecessary();
return gestureController.onTouchEvent(ev);
startAutoFastForward();
fastForwardTick();
}
};
}
view.setOnTouchListener(touchListener);
view.isClickable = true;
if (_adjustingSound && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingSound();
}
if (_adjustingBrightness && ev.action == MotionEvent.ACTION_UP) {
stopAdjustingBrightness();
}
if (_adjustingFullscreenUp && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorUp > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenUp();
}
if (_adjustingFullscreenDown && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorDown > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenDown();
}
startHideJobIfNecessary();
_gestureController.onTouchEvent(ev)
return true;
}
fun cancelHideJob() {

View File

@ -58,7 +58,8 @@ class CastView : ConstraintLayout {
_timeBar = findViewById(R.id.time_progress);
_background = findViewById(R.id.layout_background);
_gestureControlView = findViewById(R.id.gesture_control);
_gestureControlView.setupTouchArea(_background);
_gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea();
_gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);

View File

@ -4,15 +4,21 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.views.behavior.NonScrollingTextView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.segments.CommentsList
import userpackage.Protocol
@ -22,6 +28,11 @@ class RepliesOverlay : LinearLayout {
private val _topbar: OverlayTopbar;
private val _commentsList: CommentsList;
private val _addCommentView: AddCommentView;
private val _textBody: NonScrollingTextView;
private val _textAuthor: TextView;
private val _textMetadata: TextView;
private val _creatorThumbnail: CreatorThumbnail;
private val _layoutParentComment: ConstraintLayout;
private var _readonly = false;
private var _onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null;
@ -30,6 +41,11 @@ class RepliesOverlay : LinearLayout {
_topbar = findViewById(R.id.topbar);
_commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view);
_textBody = findViewById(R.id.text_body)
_textMetadata = findViewById(R.id.text_metadata)
_textAuthor = findViewById(R.id.text_author)
_creatorThumbnail = findViewById(R.id.image_thumbnail)
_layoutParentComment = findViewById(R.id.layout_parent_comment)
_addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it);
@ -42,7 +58,7 @@ class RepliesOverlay : LinearLayout {
}
}
_commentsList.onClick.subscribe { c ->
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount;
var metadata = "";
if (replyCount != null && replyCount > 0) {
@ -50,9 +66,9 @@ class RepliesOverlay : LinearLayout {
}
if (c is PolycentricPlatformComment) {
load(false, metadata, c.contextUrl, c.reference, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) });
load(false, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) });
} else {
load(true, metadata, null, null, { StatePlatform.instance.getSubComments(c) });
load(true, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
};
@ -60,7 +76,7 @@ class RepliesOverlay : LinearLayout {
_topbar.setInfo(context.getString(R.string.Replies), "");
}
fun load(readonly: Boolean, metadata: String, contextUrl: String?, ref: Protocol.Reference?, loader: suspend () -> IPager<IPlatformComment>, onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null) {
fun load(readonly: Boolean, metadata: String, contextUrl: String?, ref: Protocol.Reference?, parentComment: IPlatformComment? = null, loader: suspend () -> IPager<IPlatformComment>, onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null) {
_readonly = readonly;
if (readonly) {
_addCommentView.visibility = View.GONE;
@ -69,6 +85,26 @@ class RepliesOverlay : LinearLayout {
_addCommentView.setContext(contextUrl, ref);
}
if (parentComment == null) {
_layoutParentComment.visibility = View.GONE
} else {
_layoutParentComment.visibility = View.VISIBLE
_textBody.text = parentComment.message.fixHtmlLinks()
_textAuthor.text = parentComment.author.name
val date = parentComment.date
if (date != null) {
_textMetadata.visibility = View.VISIBLE
_textMetadata.text = "${date.toHumanNowDiffString()} ago"
} else {
_textMetadata.visibility = View.GONE
}
_creatorThumbnail.setThumbnail(parentComment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(parentComment is PolycentricPlatformComment,false);
}
_topbar.setInfo(context.getString(R.string.Replies), metadata);
_commentsList.load(readonly, loader);
_onCommentAdded = onCommentAdded;

View File

@ -73,8 +73,9 @@ class SlideUpMenuOverlay : RelativeLayout {
item.setParentClickListener { hide() };
else if(item is SlideUpMenuItem)
item.setParentClickListener { hide() };
}
_groupItems = items;
}
private fun init(animated: Boolean, okText: String?){

View File

@ -9,16 +9,20 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.views.LoaderView
class PillButton : LinearLayout {
val icon: ImageView;
val text: TextView;
val loaderView: LoaderView;
val onClick = Event0();
private var _isLoading = false;
constructor(context : Context, attrs : AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.pill_button, this, true);
icon = findViewById(R.id.pill_icon);
text = findViewById(R.id.pill_text);
loaderView = findViewById(R.id.loader)
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.PillButton, 0, 0);
val attrIconRef = attrArr.getResourceId(R.styleable.PillButton_pillIcon, -1);
@ -31,7 +35,29 @@ class PillButton : LinearLayout {
text.text = attrText;
findViewById<LinearLayout>(R.id.root).setOnClickListener {
if (_isLoading) {
return@setOnClickListener
}
onClick.emit();
};
}
fun setLoading(loading: Boolean) {
if (loading == _isLoading) {
return
}
if (loading) {
text.visibility = View.GONE
loaderView.visibility = View.VISIBLE
loaderView.start()
} else {
loaderView.stop()
text.visibility = View.VISIBLE
loaderView.visibility = View.GONE
}
_isLoading = loading
}
}

View File

@ -16,6 +16,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.LoaderView
import com.futo.polycentric.core.ProcessHandle
data class OnLikeDislikeUpdatedArgs(
@ -29,9 +30,12 @@ data class OnLikeDislikeUpdatedArgs(
class PillRatingLikesDislikes : LinearLayout {
private val _textLikes: TextView;
private val _textDislikes: TextView;
private val _loaderViewLikes: LoaderView;
private val _loaderViewDislikes: LoaderView;
private val _seperator: View;
private val _iconLikes: ImageView;
private val _iconDislikes: ImageView;
private var _isLoading: Boolean = false;
private var _likes = 0L;
private var _hasLiked = false;
@ -47,14 +51,42 @@ class PillRatingLikesDislikes : LinearLayout {
_seperator = findViewById(R.id.pill_seperator);
_iconDislikes = findViewById(R.id.pill_dislike_icon);
_iconLikes = findViewById(R.id.pill_like_icon);
_loaderViewLikes = findViewById(R.id.loader_likes)
_loaderViewDislikes = findViewById(R.id.loader_dislikes)
_iconLikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
_textLikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
_iconDislikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
_textDislikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
_iconLikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
_textLikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { like(it) }; };
_iconDislikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
_textDislikes.setOnClickListener { if (!_isLoading) StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_dislike)) { dislike(it) }; };
}
fun setLoading(loading: Boolean) {
if (_isLoading == loading) {
return
}
if (loading) {
_textLikes.visibility = View.GONE
_loaderViewLikes.visibility = View.VISIBLE
_textDislikes.visibility = View.GONE
_loaderViewDislikes.visibility = View.VISIBLE
_loaderViewLikes.start()
_loaderViewDislikes.start()
} else {
_loaderViewLikes.stop()
_loaderViewDislikes.stop()
_textLikes.visibility = View.VISIBLE
_loaderViewLikes.visibility = View.GONE
_textDislikes.visibility = View.VISIBLE
_loaderViewDislikes.visibility = View.GONE
}
_isLoading = loading
}
fun setRating(rating: IRating, hasLiked: Boolean = false, hasDisliked: Boolean = false) {
setLoading(false)
when (rating) {
is RatingLikeDislikes -> {
setRating(rating, hasLiked, hasDisliked);
@ -127,6 +159,8 @@ class PillRatingLikesDislikes : LinearLayout {
}
fun setRating(rating: RatingLikeDislikes, hasLiked: Boolean = false, hasDisliked: Boolean = false) {
setLoading(false)
_textLikes.text = rating.likes.toHumanNumber();
_textDislikes.text = rating.dislikes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
@ -140,6 +174,8 @@ class PillRatingLikesDislikes : LinearLayout {
updateColors();
}
fun setRating(rating: RatingLikes, hasLiked: Boolean = false) {
setLoading(false)
_textLikes.text = rating.likes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
_textDislikes.visibility = View.GONE;

View File

@ -1,10 +1,14 @@
package com.futo.platformplayer.views.segments
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.view.Gravity
import android.view.KeyCharacterMap.UnavailableException
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -19,22 +23,33 @@ import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.adapters.CommentViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.net.UnknownHostException
class CommentsList : ConstraintLayout {
private val _llmReplies: LinearLayoutManager;
private val _textMessage: TextView;
private val _taskLoadComments = if(!isInEditMode) TaskHandler<suspend () -> IPager<IPlatformComment>, IPager<IPlatformComment>>(StateApp.instance.scopeGetter, { it(); })
.success { pager -> onCommentsLoaded(pager); }
.exception<UnknownHostException> {
UIDialogs.toast("Failed to load comments");
setMessage("UnknownHostException: " + it.message);
Logger.e(TAG, "Failed to load comments.", it);
setLoading(false);
}
.exception<ScriptUnavailableException> {
setMessage(it.message);
Logger.e(TAG, "Failed to load comments.", it);
setLoading(false);
}
.exception<Throwable> {
setMessage("Throwable: " + it.message);
Logger.e(TAG, "Failed to load comments.", it);
UIDialogs.toast(context, context.getString(R.string.failed_to_load_comments) + "\n" + (it.message ?: ""));
//UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_comments) + (it.message ?: ""), it, ::fetchComments);
setLoading(false);
} else TaskHandler(IPlatformVideoDetails::class.java, StateApp.instance.scopeGetter);
@ -69,23 +84,35 @@ class CommentsList : ConstraintLayout {
private val _prependedView: FrameLayout;
private var _readonly: Boolean = false;
var onClick = Event1<IPlatformComment>();
var onRepliesClick = Event1<IPlatformComment>();
var onCommentsLoaded = Event1<Int>();
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_comments_list, this, true);
_recyclerComments = findViewById(R.id.recycler_comments);
_textMessage = TextView(context).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 30, 0, 0)
}
textSize = 12.0f
setTextColor(Color.WHITE)
typeface = resources.getFont(R.font.inter_regular)
gravity = Gravity.CENTER_HORIZONTAL
}
_prependedView = FrameLayout(context);
_prependedView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
_adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView), arrayListOf(),
_adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView, _textMessage), arrayListOf(),
childCountGetter = { _comments.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_comments[position], _readonly); },
childViewHolderFactory = { viewGroup, _ ->
val holder = CommentViewHolder(viewGroup);
holder.onClick.subscribe { c -> onClick.emit(c) };
holder.onRepliesClick.subscribe { c -> onRepliesClick.emit(c) };
holder.onDelete.subscribe(::onDelete);
return@InsertedViewAdapterWithLoader holder;
}
);
@ -96,6 +123,16 @@ class CommentsList : ConstraintLayout {
_recyclerComments.addOnScrollListener(_scrollListener);
}
private fun setMessage(message: String?) {
Logger.i(TAG, "setMessage " + message)
if (message != null) {
_textMessage.visibility = View.VISIBLE
_textMessage.text = message
} else {
_textMessage.visibility = View.GONE
}
}
fun addComment(comment: IPlatformComment) {
_comments.add(0, comment);
_adapterComments.notifyItemRangeInserted(_adapterComments.childToParentPosition(0), 1);
@ -106,6 +143,38 @@ class CommentsList : ConstraintLayout {
_prependedView.addView(view);
}
private fun onDelete(comment: IPlatformComment) {
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete this comment?", {
val processHandle = StatePolycentric.instance.processHandle ?: return@showConfirmationDialog
if (comment !is PolycentricPlatformComment) {
return@showConfirmationDialog
}
val index = _comments.indexOf(comment)
if (index != -1) {
_comments.removeAt(index)
_adapterComments.notifyItemRemoved(_adapterComments.childToParentPosition(index))
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
processHandle.delete(comment.eventPointer.process, comment.eventPointer.logicalClock)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to delete event.", e);
return@launch;
}
try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to fully backfill servers.", e);
}
}
}
})
}
private fun onScrolled() {
val visibleItemCount = _recyclerComments.childCount;
val firstVisibleItem = _llmReplies.findFirstVisibleItemPosition();
@ -147,6 +216,7 @@ class CommentsList : ConstraintLayout {
fun load(readonly: Boolean, loader: suspend () -> IPager<IPlatformComment>) {
cancel();
setMessage(null);
_readonly = readonly;
setLoading(true);
@ -177,6 +247,7 @@ class CommentsList : ConstraintLayout {
_comments.clear();
_commentsPager = null;
_adapterComments.notifyDataSetChanged();
setMessage(null);
}
fun cancel() {

View File

@ -156,7 +156,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_layoutControls = findViewById(R.id.layout_controls);
gestureControl = findViewById(R.id.gesture_control);
_videoView?.videoSurfaceView?.let { gestureControl.setupTouchArea(it, _layoutControls, background); };
gestureControl.setupTouchArea(_layoutControls, background);
gestureControl.onSeek.subscribe { seekFromCurrent(it); };
gestureControl.onSoundAdjusted.subscribe { setVolume(it) };
gestureControl.onToggleFullscreen.subscribe { setFullScreen(!isFullScreen) };

View File

@ -1,10 +1,10 @@
package com.futo.platformplayer.views.video
import android.content.Context
import android.media.session.PlaybackState
import android.net.Uri
import android.util.AttributeSet
import android.widget.RelativeLayout
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
@ -16,6 +16,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlR
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.video.PlayerManager
import com.google.android.exoplayer2.*
@ -54,6 +55,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private var _lastSubtitleMediaSource: MediaSource? = null;
private var _shouldPlaybackRestartOnConnectivity: Boolean = false;
private val _referenceObject = Object();
private var _connectivityLossTime_ms: Long? = null
private var _chapters: List<IChapter>? = null;
@ -152,7 +154,24 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val pos = position;
val dur = duration;
var shouldRestartPlayback = false
if (_shouldPlaybackRestartOnConnectivity && abs(pos - dur) > 2000) {
if (Settings.instance.playback.restartPlaybackAfterConnectivityLoss == 1) {
val lossTime_ms = _connectivityLossTime_ms
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 30) {
shouldRestartPlayback = true
}
} else if (Settings.instance.playback.restartPlaybackAfterConnectivityLoss == 2) {
val lossTime_ms = _connectivityLossTime_ms
if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 10) {
shouldRestartPlayback = true
}
} else if (Settings.instance.playback.restartPlaybackAfterConnectivityLoss == 3) {
shouldRestartPlayback = true
}
}
if (shouldRestartPlayback) {
Logger.i(TAG, "Playback ended due to connection loss, resuming playback since connection is restored.");
exoPlayer?.player?.playWhenReady = true;
exoPlayer?.player?.prepare();
@ -509,16 +528,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
onDatasourceError.emit(error);
}
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
//PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
//PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
//PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
//PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
//PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
Logger.i(TAG, "IO error, set _shouldPlaybackRestartOnConnectivity=true");
_shouldPlaybackRestartOnConnectivity = true;
_connectivityLossTime_ms = System.currentTimeMillis()
}
}
}
@ -536,8 +556,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false");
_shouldPlaybackRestartOnConnectivity = false;
}
}
companion object {

View File

@ -0,0 +1,18 @@
syntax = "proto2";
option optimize_for = LITE_RUNTIME;
package com.futo.platformplayer.protos;
message CastMessage {
enum ProtocolVersion { CASTV2_1_0 = 0; }
required ProtocolVersion protocol_version = 1;
required string source_id = 2;
required string destination_id = 3;
required string namespace = 4;
enum PayloadType {
STRING = 0;
BINARY = 1;
}
required PayloadType payload_type = 5;
optional string payload_utf8 = 6;
optional bytes payload_binary = 7;
}

View File

@ -1,82 +0,0 @@
syntax = "proto2";
option optimize_for = LITE_RUNTIME;
package com.futo.platformplayer.protos;
message CastMessage {
// Always pass a version of the protocol for future compatibility
// requirements.
enum ProtocolVersion { CASTV2_1_0 = 0; }
required ProtocolVersion protocol_version = 1;
// source and destination ids identify the origin and destination of the
// message. They are used to route messages between endpoints that share a
// device-to-device channel.
//
// For messages between applications:
// - The sender application id is a unique identifier generated on behalf of
// the sender application.
// - The receiver id is always the the session id for the application.
//
// For messages to or from the sender or receiver platform, the special ids
// 'sender-0' and 'receiver-0' can be used.
//
// For messages intended for all endpoints using a given channel, the
// wildcard destination_id '*' can be used.
required string source_id = 2;
required string destination_id = 3;
// This is the core multiplexing key. All messages are sent on a namespace
// and endpoints sharing a channel listen on one or more namespaces. The
// namespace defines the protocol and semantics of the message.
required string namespace = 4;
// Encoding and payload info follows.
// What type of data do we have in this message.
enum PayloadType {
STRING = 0;
BINARY = 1;
}
required PayloadType payload_type = 5;
// Depending on payload_type, exactly one of the following optional fields
// will always be set.
optional string payload_utf8 = 6;
optional bytes payload_binary = 7;
}
enum SignatureAlgorithm {
UNSPECIFIED = 0;
RSASSA_PKCS1v15 = 1;
RSASSA_PSS = 2;
}
enum HashAlgorithm {
SHA1 = 0;
SHA256 = 1;
}
// Messages for authentication protocol between a sender and a receiver.
message AuthChallenge {
optional SignatureAlgorithm signature_algorithm = 1
[default = RSASSA_PKCS1v15];
optional bytes sender_nonce = 2;
optional HashAlgorithm hash_algorithm = 3 [default = SHA1];
}
message AuthResponse {
required bytes signature = 1;
required bytes client_auth_certificate = 2;
repeated bytes intermediate_certificate = 3;
optional SignatureAlgorithm signature_algorithm = 4
[default = RSASSA_PKCS1v15];
optional bytes sender_nonce = 5;
optional HashAlgorithm hash_algorithm = 6 [default = SHA1];
optional bytes crl = 7;
}
message AuthError {
enum ErrorType {
INTERNAL_ERROR = 0;
NO_TLS = 1; // The underlying connection is not TLS
SIGNATURE_ALGORITHM_UNAVAILABLE = 2;
}
required ErrorType error_type = 1;
}
message DeviceAuthMessage {
// Request fields
optional AuthChallenge challenge = 1;
// Response fields
optional AuthResponse response = 2;
optional AuthError error = 3;
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1A1A1A" />
<corners android:radius="4dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#A03D3D" />
<corners android:radius="500dp" />
<size android:height="20dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M289.23,649.23q-13.08,0 -21.92,-8.85 -8.85,-8.85 -8.85,-21.92v-40h470l25.38,25.38L753.85,240h40q13.08,0 21.92,8.85 8.85,8.85 8.85,21.92v501.54L701.54,649.23L289.23,649.23ZM135.38,621.54v-470.77q0,-13.08 8.85,-21.92Q153.08,120 166.15,120h476.92q13.08,0 21.92,8.85 8.85,8.85 8.85,21.92v316.92q0,13.08 -8.85,21.92 -8.85,8.85 -21.92,8.85L258.46,498.46L135.38,621.54Z"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="97dp"
android:height="97dp"
android:viewportWidth="97"
android:viewportHeight="97">
<path
android:pathData="M20,0L77,0A20,20 0,0 1,97 20L97,77A20,20 0,0 1,77 97L20,97A20,20 0,0 1,0 77L0,20A20,20 0,0 1,20 0z"
android:fillColor="#D9D9D9"/>
<path
android:pathData="M17.03,67V30.636H42.598V38.591H26.902V44.841H41.036V52.796H26.902V67H17.03ZM80.178,44.273H70.164C70.093,43.444 69.904,42.693 69.596,42.018C69.3,41.343 68.886,40.763 68.353,40.278C67.832,39.78 67.199,39.402 66.453,39.141C65.707,38.869 64.861,38.733 63.914,38.733C62.257,38.733 60.854,39.135 59.706,39.94C58.57,40.745 57.706,41.899 57.114,43.403C56.534,44.906 56.244,46.711 56.244,48.818C56.244,51.044 56.54,52.908 57.132,54.411C57.735,55.903 58.605,57.027 59.742,57.785C60.878,58.53 62.245,58.903 63.843,58.903C64.755,58.903 65.571,58.791 66.293,58.566C67.016,58.329 67.643,57.992 68.175,57.554C68.708,57.116 69.14,56.589 69.472,55.974C69.815,55.346 70.046,54.642 70.164,53.861L80.178,53.932C80.06,55.471 79.628,57.039 78.882,58.637C78.136,60.223 77.077,61.691 75.704,63.041C74.343,64.378 72.656,65.455 70.644,66.272C68.631,67.089 66.293,67.497 63.63,67.497C60.292,67.497 57.297,66.781 54.646,65.349C52.006,63.916 49.917,61.809 48.378,59.028C46.851,56.246 46.087,52.843 46.087,48.818C46.087,44.77 46.869,41.361 48.431,38.591C49.994,35.809 52.101,33.708 54.752,32.288C57.404,30.855 60.363,30.139 63.63,30.139C65.926,30.139 68.039,30.453 69.969,31.08C71.898,31.708 73.591,32.625 75.047,33.832C76.503,35.028 77.675,36.502 78.563,38.254C79.45,40.005 79.989,42.012 80.178,44.273Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,121 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/black">
<ImageButton
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:gravity="center_vertical"
android:layout_marginTop="4dp">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
app:srcCompat="@drawable/ic_fcast" />
<TextView
android:id="@+id/text_polycentric"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/fcast"
android:fontFamily="@font/inter_light"
android:includeFontPadding="false"
android:textSize="32dp"
android:layout_marginLeft="12dp"/>
</LinearLayout>
<LinearLayout
android:id="@+id/text_description"
android:layout_width="match_parent"
android:layout_height="0dp"
android:animateLayoutChanges="true"
android:orientation="vertical"
android:background="@drawable/background_videodetail_description"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp"
android:layout_marginTop="14dp"
android:layout_marginBottom="14dp"
android:paddingTop="3dp"
android:paddingBottom="5dp"
android:paddingLeft="12dp"
android:paddingRight="12dp"
app:layout_constraintTop_toBottomOf="@id/button_back"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@id/layout_buttons">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.futo.platformplayer.views.behavior.NonScrollingTextView
android:id="@+id/text_explanation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:background="@color/transparent"
android:textSize="14sp" />
</ScrollView>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_website"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/fcast_website"
app:buttonSubText="@string/open_the_fcast_website"
app:buttonIcon="@drawable/ic_link" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_technical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/fcast_technical_documentation"
app:buttonSubText="@string/view_the_fcast_technical_documentation"
app:buttonIcon="@drawable/ic_wrench"
android:layout_marginTop="8dp" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_close"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/close"
app:buttonSubText="@string/go_back_to_casting_add_dialog"
app:buttonIcon="@drawable/ic_close"
android:layout_marginTop="8dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -52,7 +52,7 @@
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.futo.platformplayer.views.Loader
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader"
android:layout_marginBottom="15dp"
android:layout_marginTop="15dp"

View File

@ -8,13 +8,31 @@
android:background="@color/gray_1d"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_casting_device"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/add_casting_device"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<TextView
android:id="@+id/button_tutorial"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:text="Help"
android:textSize="14dp"
android:padding="8dp"
android:gravity="end|center_vertical"
android:textColor="@color/primary"
android:fontFamily="@font/inter_regular" />
</LinearLayout>
<Spinner
android:id="@+id/spinner_type"

View File

@ -89,18 +89,32 @@
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<Button
android:id="@+id/button_scan_qr"
android:layout_width="0dp"
android:layout_weight="1.7"
android:layout_height="wrap_content"
android:text="@string/scan_qr"
android:textSize="14dp"
android:textAlignment="center"
android:layout_marginEnd="2dp"
android:ellipsize="end"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
<Button
android:id="@+id/button_add"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:text="@string/add"
android:textSize="14dp"
android:textAlignment="textEnd"
android:layout_marginEnd="2dp"
android:ellipsize="end"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/gray_1d"
android:padding="12dp">
<LinearLayout
android:id="@+id/layout_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_video"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/video"
app:buttonSubText="@string/view_a_video_about_how_to_cast"
app:buttonIcon="@drawable/ic_smart_display"
android:layout_marginTop="8dp"
app:buttonBackground="@drawable/background_big_button_black"
android:alpha="0.4" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_guide"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="FCast Guide"
app:buttonSubText="@string/how_to_use_fcast_guide"
app:buttonIcon="@drawable/ic_code"
android:layout_marginTop="8dp"
app:buttonBackground="@drawable/background_big_button_black" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_website"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/fcast_website"
app:buttonSubText="@string/open_the_fcast_website"
app:buttonIcon="@drawable/ic_link"
android:layout_marginTop="8dp"
app:buttonBackground="@drawable/background_big_button_black" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_technical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/fcast_technical_documentation"
app:buttonSubText="@string/view_the_fcast_technical_documentation"
app:buttonIcon="@drawable/ic_wrench"
android:layout_marginTop="8dp"
app:buttonBackground="@drawable/background_big_button_black" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_close"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/close"
app:buttonSubText="@string/go_back_to_casting_add_dialog"
app:buttonIcon="@drawable/ic_close"
android:layout_marginTop="8dp"
app:buttonBackground="@drawable/background_big_button_black"/>
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,86 @@
<?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:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:background="@color/gray_1d">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/dialog_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/allow_grayjay_to_handle_specific_urls"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:textAlignment="center"
android:layout_marginTop="25dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/dialog_text_details"
android:layout_width="match_parent"
android:textColor="#AAAAAA"
android:fontFamily="@font/inter_regular"
android:text="@string/allow_grayjay_to_handle_specific_urls_please_set_it_as_default_in_the_app_settings"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
android:layout_marginTop="12dp"
android:textSize="12dp"
android:layout_height="wrap_content" />
<LinearLayout
android:id="@+id/dialog_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical|end"
android:layout_marginTop="14dp"
android:layout_marginBottom="28dp">
<TextView
android:id="@+id/button_no"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no"
android:textSize="14dp"
android:textColor="@color/primary"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:layout_marginEnd="16dp"/>
<LinearLayout
android:id="@+id/button_yes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:layout_marginEnd="28dp"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/yes"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/layout_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="12dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24dp"
android:text="@string/comments"
android:fontFamily="@font/inter_extra_light"
android:textColor="@color/white" />
<TextView
android:id="@+id/text_comment_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:text="@string/these_are_all_commentcount_comments_you_have_made_in_grayjay"
android:fontFamily="@font/inter_regular"
android:textColor="#808080" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/gray_ac"
android:fontFamily="@font/inter_light"
android:text="@string/sort_by" />
<Spinner
android:id="@+id/spinner_sortby"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="12dp" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_comments"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.futo.platformplayer.views.overlays.RepliesOverlay
android:id="@+id/replies_overlay"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout android:id="@+id/layout_not_logged_in"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:background="@color/black">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Login to view your comments"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:layout_marginBottom="20dp"/>
<LinearLayout
android:id="@+id/button_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Login"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"/>
</LinearLayout>
</LinearLayout>
</FrameLayout>

View File

@ -70,7 +70,7 @@
<LinearLayout
android:orientation="horizontal"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_body"
@ -136,6 +136,30 @@
app:pillIcon="@drawable/ic_forum"
app:pillText="55 Replies"
android:layout_marginStart="15dp" />
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:id="@+id/button_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_pill_pred"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/pill_text"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:textColor="@color/white"
android:textSize="13dp"
android:gravity="center_vertical"
android:fontFamily="@font/inter_light"
android:text="@string/delete" />
</FrameLayout>
</LinearLayout>

View File

@ -0,0 +1,124 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout_comment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp"
android:orientation="vertical"
android:background="@drawable/background_comment"
android:padding="16dp">
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/image_thumbnail"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/channel_image"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/placeholder_channel_thumbnail" />
<TextView
android:id="@+id/text_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintTop_toTopOf="@id/image_thumbnail"
tools:text="ShortCircuit" />
<TextView
android:id="@+id/text_metadata"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/gray_ac"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/text_author"
app:layout_constraintLeft_toRightOf="@id/text_author"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/text_author"
tools:text=" • 3 years ago" />
<com.futo.platformplayer.views.behavior.NonScrollingTextView
android:id="@+id/text_body"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginTop="5dp"
android:layout_marginStart="10dp"
android:background="@color/transparent"
android:fontFamily="@font/inter_regular"
android:isScrollContainer="false"
android:textColor="#CCCCCC"
android:textSize="13sp"
android:maxLines="100"
app:layout_constraintTop_toBottomOf="@id/text_metadata"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintRight_toRightOf="parent"
tools:text="@string/lorem_ipsum" />
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_body"
android:layout_marginTop="8dp"
android:gravity="center_vertical">
<com.futo.platformplayer.views.pills.PillRatingLikesDislikes
android:id="@+id/rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="10dp" />
<com.futo.platformplayer.views.pills.PillButton
android:id="@+id/button_replies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:pillIcon="@drawable/ic_forum"
app:pillText="55 Replies"
android:layout_marginStart="15dp" />
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:id="@+id/button_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_pill_pred"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/pill_text"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:textColor="@color/white"
android:textSize="13dp"
android:gravity="center_vertical"
android:fontFamily="@font/inter_light"
android:text="@string/delete" />
</FrameLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -127,7 +127,7 @@
android:visibility="gone"
android:gravity="center"
android:orientation="vertical">
<com.futo.platformplayer.views.Loader
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader"
android:layout_width="50dp"
android:layout_height="50dp" />

View File

@ -2,6 +2,7 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/black"
xmlns:app="http://schemas.android.com/apk/res-auto">
@ -15,6 +16,78 @@
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_parent_comment"
android:layout_height="wrap_content"
android:layout_width="match_parent"
app:layout_constraintTop_toBottomOf="@id/topbar"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginTop="6dp"
android:padding="12dp"
android:background="@drawable/background_16_round_4dp">
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/image_thumbnail"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/channel_image"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/placeholder_channel_thumbnail" />
<TextView
android:id="@+id/text_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintTop_toTopOf="@id/image_thumbnail"
android:text="ShortCircuit" />
<TextView
android:id="@+id/text_metadata"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/gray_ac"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/text_author"
app:layout_constraintLeft_toRightOf="@id/text_author"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/text_author"
android:text=" • 3 years ago" />
<com.futo.platformplayer.views.behavior.NonScrollingTextView
android:id="@+id/text_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginStart="10dp"
android:background="@color/transparent"
android:fontFamily="@font/inter_regular"
android:isScrollContainer="false"
android:textColor="#CCCCCC"
android:textSize="13sp"
android:maxLines="3"
android:ellipsize="end"
app:layout_constraintTop_toBottomOf="@id/text_metadata"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintRight_toRightOf="parent"
android:text="@string/lorem_ipsum" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.futo.platformplayer.views.comments.AddCommentView
android:id="@+id/add_comment_view"
android:layout_width="match_parent"
@ -22,8 +95,7 @@
android:layout_marginTop="12dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:paddingBottom="12dp"
app:layout_constraintTop_toBottomOf="@id/topbar"
app:layout_constraintTop_toBottomOf="@id/layout_parent_comment"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
@ -32,6 +104,7 @@
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/add_comment_view"
app:layout_constraintBottom_toBottomOf="parent" />
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginTop="12dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -9,12 +9,13 @@
android:paddingStart="7dp"
android:paddingEnd="12dp"
android:background="@drawable/background_pill"
android:id="@+id/root">
android:id="@+id/root"
android:gravity="center_vertical">
<ImageView
android:id="@+id/pill_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginRight="5dp"
android:layout_marginLeft="5dp"
android:layout_marginTop="0dp"
@ -31,4 +32,10 @@
android:fontFamily="@font/inter_light"
tools:text="500K" />
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader"
android:layout_width="14dp"
android:layout_height="14dp"
app:isWhite="true" />
</LinearLayout>

View File

@ -8,7 +8,8 @@
android:paddingBottom="7dp"
android:paddingLeft="7dp"
android:paddingRight="12dp"
android:background="@drawable/background_pill">
android:background="@drawable/background_pill"
android:gravity="center_vertical">
<ImageView
android:id="@+id/pill_like_icon"
android:layout_width="30dp"
@ -22,6 +23,11 @@
android:textSize="13dp"
android:gravity="center_vertical"
tools:text="500K" />
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader_likes"
android:layout_width="14dp"
android:layout_height="14dp"
app:isWhite="true" />
<View
android:id="@+id/pill_seperator"
@ -44,5 +50,10 @@
android:gravity="center_vertical"
android:textSize="13dp"
tools:text="500K" />
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader_dislikes"
android:layout_width="14dp"
android:layout_height="14dp"
app:isWhite="true" />
</LinearLayout>

View File

@ -23,6 +23,11 @@
android:background="#cc000000"
android:layout_marginBottom="6dp" />
<com.futo.platformplayer.views.behavior.GestureControlView
android:id="@+id/gesture_control"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ImageButton
android:id="@+id/button_minimize"
android:layout_width="50dp"
@ -129,11 +134,6 @@
app:layout_constraintTop_toTopOf="@id/text_position"
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
<com.futo.platformplayer.views.behavior.GestureControlView
android:id="@+id/gesture_control"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.exoplayer2.ui.DefaultTimeBar
android:id="@+id/time_progress"
android:layout_width="match_parent"

View File

@ -1,13 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_comments"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@ -120,7 +120,7 @@
android:orientation="horizontal"
android:layout_gravity="center" />
<com.futo.platformplayer.views.Loader
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader_merchandise"
android:layout_width="64dp"
android:layout_height="64dp"

View File

@ -722,4 +722,8 @@
<item>معلومات</item>
<item>تفصيلي</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -722,4 +722,8 @@
<item>Information</item>
<item>Ausführlich</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -738,4 +738,8 @@
<item>Información</item>
<item>Detallado</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -722,4 +722,8 @@
<item>Information</item>
<item>Verbeux</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -722,4 +722,8 @@
<item>情報</item>
<item>詳細</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -722,4 +722,8 @@
<item>정보</item>
<item>상세</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -722,4 +722,8 @@
<item>Informação</item>
<item>Detalhado</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -722,4 +722,8 @@
<item>Информация</item>
<item>Подробно</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -722,4 +722,8 @@
<item>信息</item>
<item>详细</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
</resources>

View File

@ -2,5 +2,6 @@
<resources>
<declare-styleable name="LoaderView">
<attr name="automatic" format="boolean" />
<attr name="isWhite" format="boolean" />
</declare-styleable>
</resources>

View File

@ -292,6 +292,8 @@
<string name="clear_external_downloads_directory">Clear external Downloads directory</string>
<string name="change_external_general_directory">Change external General directory</string>
<string name="change_tabs_visible_on_the_home_screen">Change tabs visible on the home screen</string>
<string name="link_handling">Link Handling</string>
<string name="allow_grayjay_to_handle_links">Allow Grayjay to handle links</string>
<string name="change_the_external_directory_for_general_files">Change the external directory for general files</string>
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
@ -377,6 +379,10 @@
<string name="restore_a_previous_automatic_backup">Restore a previous automatic backup</string>
<string name="resume_after_preview">Resume After Preview</string>
<string name="review_the_current_and_past_changelogs">Review the current and past changelogs</string>
<string name="restart_after_audio_focus_loss">Restart after audio focus loss</string>
<string name="restart_playback_when_gaining_audio_focus_after_a_loss">Restart playback when gaining audio focus after a loss</string>
<string name="restart_after_connectivity_loss">Restart after connectivity loss</string>
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
<string name="chapter_update_fps_title">Chapter Update FPS</string>
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
<string name="set_automatic_backup">Set Automatic Backup</string>
@ -678,6 +684,22 @@
<string name="plus_tax">" + Tax"</string>
<string name="new_playlist">New playlist</string>
<string name="add_to_new_playlist">Add to new playlist</string>
<string name="url_handling">URL Handling</string>
<string name="allow_grayjay_to_handle_specific_urls">Allow Grayjay to handle specific URLs?</string>
<string name="allow_grayjay_to_handle_specific_urls_please_set_it_as_default_in_the_app_settings">When you click \'Yes\', the Grayjay app settings will open.\n\nThere, navigate to:\n1. "Open by default" or "Set as default" section.\nYou might find this option directly or under \'Advanced\' settings, depending on your device.\n\n2. Choose \'Open supported links\' for Grayjay.\n\n(some devices have this listed under \'Default Apps\' in the main settings followed by selecting Grayjay for relevant categories)</string>
<string name="failed_to_show_settings">Failed to show settings</string>
<string name="play_store_version_does_not_support_default_url_handling">Play store version does not support default URL handling.</string>
<string name="these_are_all_commentcount_comments_you_have_made_in_grayjay">These are all {commentCount} comments you have made in Grayjay.</string>
<string name="tutorial">Tutorial</string>
<string name="go_back_to_casting_add_dialog">Go back to casting add dialog</string>
<string name="view_a_video_about_how_to_cast">View a video about how to cast</string>
<string name="view_the_fcast_technical_documentation">View the FCast technical documentation</string>
<string name="guide">Guide</string>
<string name="how_to_use_fcast_guide">How to use FCast guide</string>
<string name="fcast">FCast</string>
<string name="open_the_fcast_website">Open the FCast website</string>
<string name="fcast_website">FCast Website</string>
<string name="fcast_technical_documentation">FCast Technical Documentation</string>
<string-array name="home_screen_array">
<item>Recommendations</item>
<item>Subscriptions</item>
@ -765,6 +787,10 @@
<item>Disabled</item>
<item>Enabled</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="subscriptions_sortby_array">
<item>Name Ascending</item>
<item>Name Descending</item>
@ -825,7 +851,7 @@
<item>Russian</item>
</string-array>
<string-array name="casting_device_type_array" translatable="false">
<item>FastCast</item>
<item>FCast</item>
<item>ChromeCast</item>
<item>AirPlay</item>
</string-array>
@ -836,4 +862,10 @@
<item>Information</item>
<item>Verbose</item>
</string-array>
<string-array name="restart_playback_after_loss">
<item>Never</item>
<item>Within 10 seconds of loss</item>
<item>Within 30 seconds of loss</item>
<item>Always</item>
</string-array>
</resources>

@ -1 +1 @@
Subproject commit 6ea204605d4a27867702d7b024237506904d53c7
Subproject commit a05feced804a5b664c75568162a7b3fa5562d8b3

@ -1 +1 @@
Subproject commit 60a7ee2ddf71b936d9c289a3343020cc20edfe56
Subproject commit b0e35a9b6631fb3279fb38619ecc3eba812e5ed6

View File

@ -24,6 +24,12 @@
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
<data android:host="rumble.com" />
<data android:host="kick.com" />
<data android:host="nebula.tv" />
<data android:host="odysee.com" />
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -33,11 +39,18 @@
<data android:mimeType="text/plain" />
<data android:host="youtube.com" />
<data android:host="m.youtube.com" />
<data android:host="you.be" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
<data android:host="rumble.com" />
<data android:host="kick.com" />
<data android:host="nebula.tv" />
<data android:host="odysee.com" />
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
</intent-filter>
</activity>
</application>

@ -1 +1 @@
Subproject commit 6ea204605d4a27867702d7b024237506904d53c7
Subproject commit a05feced804a5b664c75568162a7b3fa5562d8b3

@ -1 +1 @@
Subproject commit 60a7ee2ddf71b936d9c289a3343020cc20edfe56
Subproject commit b0e35a9b6631fb3279fb38619ecc3eba812e5ed6

@ -1 +1 @@
Subproject commit 839e4c4a4f5ed6cb6f68047f88b26c5831e6e703
Subproject commit faaa7a6d8efb3f92fc239e7d77ec2f9a46c3a958

244
docs/Example Plugin.md Normal file
View File

@ -0,0 +1,244 @@
# Example plugin
Note that this is just a starting point, plugins can also implement optional features such as login, importing playlists/subscriptions, etc. For full examples please see in-house developed plugins (click [here](https://gitlab.futo.org/videostreaming/plugins)).
```js
source.enable = function (conf) {
/**
* @param conf: SourceV8PluginConfig (the SomeConfig.js)
*/
}
source.getHome = function(continuationToken) {
/**
* @param continuationToken: any?
* @returns: VideoPager
*/
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { continuationToken: continuationToken }; // Relevant data for the next page
return new SomeHomeVideoPager(videos, hasMore, context);
}
source.searchSuggestions = function(query) {
/**
* @param query: string
* @returns: string[]
*/
const suggestions = []; //The suggestions for a specific search query
return suggestions;
}
source.getSearchCapabilities = function() {
//This is an example of how to return search capabilities like available sorts, filters and which feed types are available (see source.js for more details)
return {
types: [Type.Feed.Mixed],
sorts: [Type.Order.Chronological, "^release_time"],
filters: [
{
id: "date",
name: "Date",
isMultiSelect: false,
filters: [
{ id: Type.Date.Today, name: "Last 24 hours", value: "today" },
{ id: Type.Date.LastWeek, name: "Last week", value: "thisweek" },
{ id: Type.Date.LastMonth, name: "Last month", value: "thismonth" },
{ id: Type.Date.LastYear, name: "Last year", value: "thisyear" }
]
},
]
};
}
source.search = function (query, type, order, filters, continuationToken) {
/**
* @param query: string
* @param type: string
* @param order: string
* @param filters: Map<string, Array<string>>
* @param continuationToken: any?
* @returns: VideoPager
*/
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeSearchVideoPager(videos, hasMore, context);
}
source.getSearchChannelContentsCapabilities = function () {
//This is an example of how to return search capabilities on a channel like available sorts, filters and which feed types are available (see source.js for more details)
return {
types: [Type.Feed.Mixed],
sorts: [Type.Order.Chronological],
filters: []
};
}
source.searchChannelContents = function (url, query, type, order, filters, continuationToken) {
/**
* @param url: string
* @param query: string
* @param type: string
* @param order: string
* @param filters: Map<string, Array<string>>
* @param continuationToken: any?
* @returns: VideoPager
*/
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { channelUrl: channelUrl, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeSearchChannelVideoPager(videos, hasMore, context);
}
source.searchChannels = function (query, continuationToken) {
/**
* @param query: string
* @param continuationToken: any?
* @returns: ChannelPager
*/
const channels = []; // The results (PlatformChannel)
const hasMore = false; // Are there more pages?
const context = { query: query, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeChannelPager(channels, hasMore, context);
}
source.isChannelUrl = function(url) {
/**
* @param url: string
* @returns: boolean
*/
return REGEX_CHANNEL_URL.test(url);
}
source.getChannel = function(url) {
return new PlatformChannel({
//... see source.js for more details
});
}
source.getChannelContents = function(url, type, order, filters, continuationToken) {
/**
* @param url: string
* @param type: string
* @param order: string
* @param filters: Map<string, Array<string>>
* @param continuationToken: any?
* @returns: VideoPager
*/
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { url: url, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeChannelVideoPager(videos, hasMore, context);
}
source.isContentDetailsUrl = function(url) {
/**
* @param url: string
* @returns: boolean
*/
return REGEX_DETAILS_URL.test(url);
}
source.getContentDetails = function(url) {
/**
* @param url: string
* @returns: PlatformVideoDetails
*/
return new PlatformVideoDetails({
//... see source.js for more details
});
}
source.getComments = function (url, continuationToken) {
/**
* @param url: string
* @param continuationToken: any?
* @returns: CommentPager
*/
const comments = []; // The results (Comment)
const hasMore = false; // Are there more pages?
const context = { url: url, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeCommentPager(comments, hasMore, context);
}
source.getSubComments = function (comment) {
/**
* @param comment: Comment
* @returns: SomeCommentPager
*/
if (typeof comment === 'string') {
comment = JSON.parse(comment);
}
return getCommentsPager(comment.context.claimId, comment.context.claimId, 1, false, comment.context.commentId);
}
class SomeCommentPager extends CommentPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.getComments(this.context.url, this.context.continuationToken);
}
}
class SomeHomeVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.getHome(this.context.continuationToken);
}
}
class SomeSearchVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.search(this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken);
}
}
class SomeSearchChannelVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.searchChannelContents(this.context.channelUrl, this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken);
}
}
class SomeChannelPager extends ChannelPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.searchChannelContents(this.context.query, this.context.continuationToken);
}
}
class SomeChannelVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.getChannelContents(this.context.url, this.context.type, this.context.order, this.context.filters, this.context.continuationToken);
}
}
```

31
docs/Script Signing.md Normal file
View File

@ -0,0 +1,31 @@
# Script signing
The `scriptSignature` and `scriptPublicKey` should be set whenever you deploy your script (NOT REQUIRED DURING DEVELOPMENT). The purpose of these fields is to verify that a plugin update was made by the same individual that developed the original plugin. This prevents somebody from hijacking your plugin without having access to your public private keypair. When this value is not present, you can still use this plugin, however the user will be informed that these values are missing and that this is a security risk. Here is an example script showing you how to generate these values. See below for more details.
You can use this script to generate the `scriptSignature` and `scriptPublicKey` fields above:
`sign-script.sh`
```sh
#!/bin/sh
#Example usage:
#cat script.js | sign-script.sh
#sh sign-script.sh script.js
#Set your key paths here
PRIVATE_KEY_PATH=~/.ssh/id_rsa
PUBLIC_KEY_PATH=~/.ssh/id_rsa.pub
PUBLIC_KEY_PKCS8=$(ssh-keygen -f "$PUBLIC_KEY_PATH" -e -m pkcs8 | tail -n +2 | head -n -1 | tr -d '\n')
echo "This is your public key: '$PUBLIC_KEY_PKCS8'"
if [ $# -eq 0 ]; then
# No parameter provided, read from stdin
DATA=$(cat)
else
# Parameter provided, read from file
DATA=$(cat "$1")
fi
SIGNATURE=$(echo -n "$DATA" | openssl dgst -sha512 -sign ~/.ssh/id_rsa | base64 -w 0)
echo "This is your signature: '$SIGNATURE'"
```

View File

@ -3,363 +3,142 @@
## Table of Contents
- [Introduction](#introduction)
- [Grayjay App Overview](#grayjay-app-overview)
- [Plugin Development Overview](#plugin-development-overview)
- [Setting up the Development Environment](#setting-up-the-development-environment)
- [Using the Developer Interface](#using-the-developer-interface)
- [Quick Start](#quick-start)
- [Configuration file](#configuration-file)
- [Packages](#packages)
- [Authentication](#authentication)
- [Content Types](#content-types)
- [Example plugin](#example-plugin)
- [Pagination](#pagination)
- [Script signing](#script-signing)
- [Plugin Deployment](#plugin-deployment)
- [Common Issues and Troubleshooting](#common-issues-and-troubleshooting)
- [Additional Resources](#additional-resources)
- [Support and Contact](#support-and-contact)
## Introduction
Welcome to the Grayjay App plugin development documentation. This guide will provide an overview of Grayjay's plugin system and guide you through the steps necessary to create, test, debug, and deploy plugins.
Welcome to the Grayjay App plugin development documentation. Plugins are additional components that you can create to extend the functionality of the Grayjay app, for example a YouTube or Odysee plugin. This guide will provide an overview of Grayjay's plugin system and guide you through the steps necessary to create, test, debug, and deploy plugins.
## Grayjay App Overview
## Quick Start
Grayjay is a unique media application that aims to revolutionize the relationship between content creators and their audiences. By shifting the focus from platforms to creators, Grayjay democratizes the content delivery process, empowering creators to retain full ownership of their content and directly monetize their work.
### Download GrayJay:
For users, Grayjay offers a more privacy-focused and personalized content viewing experience. Rather than being manipulated by opaque algorithms, users can decide what they want to watch, thus enhancing their engagement and enjoyment of the content.
- Download the GrayJay app for Android [here](https://grayjay.app/).
Our ultimate goal is to create the best media app, merging content and features that users love with a strong emphasis on user and creator empowerment and privacy.
### Enable GrayJay Developer Mode:
By developing Grayjay, we strive to make a stride toward a more open, interconnected, and equitable media ecosystem. This ecosystem fosters a thriving community of creators who are supported by their audiences, all facilitated through a platform that respects and prioritizes privacy and ownership.
- Enable developer mode in the GrayJay app (not Android settings app) by tapping the “More” tab, tapping “Settings”, scrolling all the way to the bottom, and tapping the “Version Code” multiple times.
## Plugin Development Overview
### Run the GrayJay DevServer:
Plugins are additional components that you can create to extend the functionality of the Grayjay app.
- At the bottom of the Settings page in the GrayJay app, Click the purple “Developer Settings” button. Then click the “Start Server” button to start the DevServer.
## Setting up the Developer Environment
<img src="https://gitlab.futo.org/videostreaming/grayjay/uploads/07fc4919b0a8446c4cdf5335565c0611/image.png" width="200">
Before you start developing plugins, it is necessary to set up a suitable developer environment. Here's how to do it:
### Open the GrayJay DevServer on your computer:
1. Create a plugin, the minimal starting point is the following.
- Open the Android settings app and search for “IP address”. The IP address should look like `192.168.X.X`.
- Open `http://<phone-ip>:11337/dev` in your web browser.
<img src="https://gitlab.futo.org/videostreaming/grayjay/uploads/72885c3bc51b8efe9462ee68d47e3b51/image.png" width="600">
`SomeConfig.js`
```json
### Create and host your plugin:
- Clone the [Odysee plugin](https://gitlab.futo.org/videostreaming/plugins/odysee) as an example
- `cd` into the project folder and serve with `npx serve` (if you have [Node.js](https://nodejs.org/en/)) or any other HTTP Server you desire.
- `npx serve` should give you a Network url (not the localhost one) that looks like `http://192.168.X.X:3000`. Your config file URL will be something like `http://192.168.X.X:3000/OdyseeConfig.json`.
<img src="https://gitlab.futo.org/videostreaming/grayjay/uploads/cc266da0a0b85c5770abca22c0b03b3b/image.png" width="600">
### Test your plugin:
- When the DevServer is open in your browser, enter the config file URL and click “Load Plugin”. This will NOT inject the plugin into the app, for that you need to click "Inject Plugin" on the Integration tab.
<img src="https://gitlab.futo.org/videostreaming/grayjay/uploads/386a562f30a60cfcbb8a8a1345a788e5/image.png" width="600">
- On the Testing tab, you can individually test the methods in your plugin. To reload once you make changes on the plugin, click the top-right refresh button. *Note: While testing, the custom domParser package is overwritten with the browser's implementation, so it may behave differently than once it is loaded into the app.*
<img src="https://gitlab.futo.org/videostreaming/grayjay/uploads/08830eb8cc56cc55ba445dd49db86235/image.png" width="600">
- On the Integration tab you can test your plugin end-to-end in the GrayJay app and monitor device logs. You can click "Inject Plugin" in order to inject the plugin into the app. Your plugin should show up on the Sources tab in the GrayJay app. If you make changes and want to reload the plugin, click "Inject Plugin" again.
<img src="https://gitlab.futo.org/videostreaming/grayjay/uploads/74813fbf37dcfc63055595061e41c48b/image.png" width="600">
## Configuration file
Create a configuration file for your plugin.
`SomeConfig.json`
```js
{
"name": "Some name",
"description": "A description for your plugin",
"author": "Your author name",
"authorUrl": "https://yoursite.com",
// The `sourceUrl` field should contain the URL where your plugin will be publically accessible in the future. This allows the app to scan this location to see if there are any updates available.
"sourceUrl": "https://yoursite.com/SomeConfig.json",
"repositoryUrl": "https://github.com/someuser/someproject",
"scriptUrl": "./SomeScript.js",
"version": 1,
"iconUrl": "./someimage.png",
// The `id` field should be a uniquely generated UUID like from [https://www.uuidgenerator.net/](https://www.uuidgenerator.net/). This will be used to distinguish your plugin from others.
"id": "309b2e83-7ede-4af8-8ee9-822bc4647a24",
"scriptSignature": "<ommitted>",
"scriptPublicKey": "<ommitted>",
// See the "Script Signing" section for details
"scriptSignature": "<omitted>",
"scriptPublicKey": "<omitted>",
// See the "Packages" section for details, currently allowed values are: ["Http", "DOMParser", "Utilities"]
"packages": ["Http"],
"allowEval": false,
// The `allowUrls` field is allowed to be `everywhere`, this means that the plugin is allowed to access all URLs. However, this will popup a warning for the user that this is the case. Therefore, it is recommended to narrow the scope of the accessible URLs only to the URLs that you actually need. Other requests will be blocked. During development it can be convenient to use `everywhere`. Possible values are `odysee.com`, `api.odysee.com`, etc.
"allowUrls": [
"everywhere"
]
}
```
The `sourceUrl` field should contain the URL where your plugin will be publically accessible in the future. This allows the app to scan this location to see if there are any updates available.
The `id` field should be a uniquely generated UUID like from [https://www.uuidgenerator.net/](https://www.uuidgenerator.net/). This will be used to distinguish your plugin from others.
The `allowUrls` field is allowed to be `everywhere`, this means that the plugin is allowed to access all URLs. However, this will popup a warning for the user that this is the case. Therefore, it is recommended to narrow the scope of the accessible URLs only to the URLs that you actually need. Other requests will be blocked. During development it can be convenient to use `everywhere`. Possible values are `odysee.com`, `api.odysee.com`, etc.
The `scriptSignature` and `scriptPublicKey` should be set whenever you deploy your script (NOT REQUIRED DURING DEVELOPMENT). The purpose of these fields is to verify that a plugin update was made by the same individual that developed the original plugin. This prevents somebody from hijacking your plugin without having access to your public private keypair. When this value is not present, you can still use this plugin, however the user will be informed that these values are missing and that this is a security risk. Here is an example script showing you how to generate these values.
`sign-script.sh`
```sh
#!/bin/sh
#Example usage:
#cat script.js | sign-script.sh
#sh sign-script.sh script.js
#Set your key paths here
PRIVATE_KEY_PATH=~/.ssh/id_rsa
PUBLIC_KEY_PATH=~/.ssh/id_rsa.pub
PUBLIC_KEY_PKCS8=$(ssh-keygen -f "$PUBLIC_KEY_PATH" -e -m pkcs8 | tail -n +2 | head -n -1 | tr -d '\n')
echo "This is your public key: '$PUBLIC_KEY_PKCS8'"
if [ $# -eq 0 ]; then
# No parameter provided, read from stdin
DATA=$(cat)
else
# Parameter provided, read from file
DATA=$(cat "$1")
fi
SIGNATURE=$(echo -n "$DATA" | openssl dgst -sha512 -sign ~/.ssh/id_rsa | base64 -w 0)
echo "This is your signature: '$SIGNATURE'"
```
## Packages
The `packages` field allows you to specify which packages you want to use, current available packages are:
- `Http`: for performing HTTP requests (see [docs](TODO))
- `DOMParser`: for parsing a DOM (see [docs](TODO))
- `Utilities`: for various utility functions like generating random UUIDs or converting to Base64 (see [docs](TODO))
- `Http`: for performing HTTP requests (see [docs](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/packages/packageHttp.md))
- `DOMParser`: for parsing a DOM (no docs yet, see [source code](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt))
- `Utilities`: for various utility functions like generating UUIDs or converting to Base64 (no docs yet, see [source code](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt))
Note that this is just a starting point, plugins can also implement optional features such as login, importing playlists/subscriptions, etc. For full examples please see in-house developed plugins (click [here](TODO)).
## Authentication
`SomeScript.js`
```js
source.enable = function (conf) {
/**
* @param conf: SourceV8PluginConfig (the SomeConfig.js)
*/
}
Authentication is sometimes required by plugins to access user data and premium content, for example on YouTube or Patreon.
source.getHome = function(continuationToken) {
/**
* @param continuationToken: any?
* @returns: VideoPager
*/
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { continuationToken: continuationToken }; // Relevant data for the next page
return new SomeHomeVideoPager(videos, hasMore, context);
}
See [Authentication.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Authentication.md)
source.searchSuggestions = function(query) {
/**
* @param query: string
* @returns: string[]
*/
## Content Types
const suggestions = []; //The suggestions for a specific search query
return suggestions;
}
Docs for data structures like PlatformVideo your plugin uses to communicate with the GrayJay app.
source.getSearchCapabilities = function() {
//This is an example of how to return search capabilities like available sorts, filters and which feed types are available (see source.js for more details)
return {
types: [Type.Feed.Mixed],
sorts: [Type.Order.Chronological, "^release_time"],
filters: [
{
id: "date",
name: "Date",
isMultiSelect: false,
filters: [
{ id: Type.Date.Today, name: "Last 24 hours", value: "today" },
{ id: Type.Date.LastWeek, name: "Last week", value: "thisweek" },
{ id: Type.Date.LastMonth, name: "Last month", value: "thismonth" },
{ id: Type.Date.LastYear, name: "Last year", value: "thisyear" }
]
},
]
};
}
See [Content Types.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Content%20Types.md)
source.search = function (query, type, order, filters, continuationToken) {
/**
* @param query: string
* @param type: string
* @param order: string
* @param filters: Map<string, Array<string>>
* @param continuationToken: any?
* @returns: VideoPager
*/
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeSearchVideoPager(videos, hasMore, context);
}
## Example plugin
source.getSearchChannelContentsCapabilities = function () {
//This is an example of how to return search capabilities on a channel like available sorts, filters and which feed types are available (see source.js for more details)
return {
types: [Type.Feed.Mixed],
sorts: [Type.Order.Chronological],
filters: []
};
}
See the example plugin to better understand the plugin API e.g. `getHome` and `search`.
source.searchChannelContents = function (url, query, type, order, filters, continuationToken) {
/**
* @param url: string
* @param query: string
* @param type: string
* @param order: string
* @param filters: Map<string, Array<string>>
* @param continuationToken: any?
* @returns: VideoPager
*/
See [Example Plugin.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Example%20Plugin.md)
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { channelUrl: channelUrl, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeSearchChannelVideoPager(videos, hasMore, context);
}
## Pagination
source.searchChannels = function (query, continuationToken) {
/**
* @param query: string
* @param continuationToken: any?
* @returns: ChannelPager
*/
Plugins use "Pagers" to send paginated data to the GrayJay app.
const channels = []; // The results (PlatformChannel)
const hasMore = false; // Are there more pages?
const context = { query: query, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeChannelPager(channels, hasMore, context);
}
See [Pagers.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Pagers.md)
source.isChannelUrl = function(url) {
/**
* @param url: string
* @returns: boolean
*/
## Script signing
return REGEX_CHANNEL_URL.test(url);
}
When you deploy your plugin, you'll need to add code signing for security.
source.getChannel = function(url) {
return new PlatformChannel({
//... see source.js for more details
});
}
source.getChannelContents = function(url, type, order, filters, continuationToken) {
/**
* @param url: string
* @param type: string
* @param order: string
* @param filters: Map<string, Array<string>>
* @param continuationToken: any?
* @returns: VideoPager
*/
const videos = []; // The results (PlatformVideo)
const hasMore = false; // Are there more pages?
const context = { url: url, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeChannelVideoPager(videos, hasMore, context);
}
source.isContentDetailsUrl = function(url) {
/**
* @param url: string
* @returns: boolean
*/
return REGEX_DETAILS_URL.test(url);
}
source.getContentDetails = function(url) {
/**
* @param url: string
* @returns: PlatformVideoDetails
*/
return new PlatformVideoDetails({
//... see source.js for more details
});
}
source.getComments = function (url, continuationToken) {
/**
* @param url: string
* @param continuationToken: any?
* @returns: CommentPager
*/
const comments = []; // The results (Comment)
const hasMore = false; // Are there more pages?
const context = { url: url, continuationToken: continuationToken }; // Relevant data for the next page
return new SomeCommentPager(comments, hasMore, context);
}
source.getSubComments = function (comment) {
/**
* @param comment: Comment
* @returns: SomeCommentPager
*/
if (typeof comment === 'string') {
comment = JSON.parse(comment);
}
return getCommentsPager(comment.context.claimId, comment.context.claimId, 1, false, comment.context.commentId);
}
class SomeCommentPager extends CommentPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.getComments(this.context.url, this.context.continuationToken);
}
}
class SomeHomeVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.getHome(this.context.continuationToken);
}
}
class SomeSearchVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.search(this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken);
}
}
class SomeSearchChannelVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.searchChannelContents(this.context.channelUrl, this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken);
}
}
class SomeChannelPager extends ChannelPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.searchChannelContents(this.context.query, this.context.continuationToken);
}
}
class SomeChannelVideoPager extends VideoPager {
constructor(results, hasMore, context) {
super(results, hasMore, context);
}
nextPage() {
return source.getChannelContents(this.context.url, this.context.type, this.context.order, this.context.filters, this.context.continuationToken);
}
}
```
2. Configure a web server to host the plugin. This can be something as simple as a NGINX server where you just place the files in the wwwroot or a simple dotnet/npm program that hosts the file for you. The important part is that the webserver and the phone are on the same network and the phone can access the files hosted by the development machine. An example of what this would look like is [here](https://plugins.grayjay.app/Odysee/OdyseeConfig.json). Alternatively, you could simply point to a Github/Gitlab raw file if you do not want to host it yourself. Note that the URL is not required to be publically accessible during development and HTTPS is NOT required.
3. Enable developer mode on the mobile application by going to settings, clicking on the version code multiple times. Once enabled, click on developer settings and then in the developer settings enable the webserver.
4. You are now able to access the developer interface on the phone via `http://<phone-ip>:11337/dev`.
## Using the Developer Interface
Once in the web portal you will see several tabs and a form allowing you to load a plugin.
1. Lets load your plugin. Take the URL that your plugin config is available at (like http://192.168.1.196:5000/Some/SomeConfig.json) and enter it in the `Plugin Config Json Url` field. Once entered, click load plugin.
*The package override domParser will override the domParser with the browser implementation. This is useful when you quickly want to iterate on plugins that parse the DOM, but it is less accurate to what the plugin will behave like once in-app.*
2. Once the plugin is loaded, you can click on the `Testing` tab and call individual methods. This allows you to quickly iterate, test methods and make sure they are returning the proper values. To reload once you make changes on the plugin, click the top-right refresh button.
3. After you are sure everything is working properly, click the `Integration` tab in order to perform integration testing on your plugin. You can click the `Inject Plugin` button in order to inject the plugin into the app. On the sources page in your app you should see your source and you are able to test it and make sure everything works. If you make changes and want to reload the plugin, click the `Inject Plugin` button again.
See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%Signing.md)
## Plugin Deployment
@ -403,12 +182,6 @@ Ensure the QR code correctly points to the plugin config URL. The URL must be pu
Make sure the signature is correctly generated and added. Also, ensure the version number in the config matches the new version number.
## Additional Resources
Here are some additional resources that might help you with your plugin development:
Please
## Support and Contact
If you have any issues or need further assistance, feel free to reach out to us at: