diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a659758a..ed7a2988 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,6 +61,14 @@ + + + + + + + + @@ -210,5 +218,9 @@ android:name=".activities.QRCaptureActivity" android:screenOrientation="portrait" android:theme="@style/Theme.FutoVideo.NoActionBar" /> + \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 8daa4736..db6bfd52 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -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) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 39e3ad84..86d73e28 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -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(R.id.button_no).apply { + this.setOnClickListener { + dialog.dismiss() + } + } + + view.findViewById(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); diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index e06c525f..6b401f1c 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -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(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() + val audioButtons = arrayListOf() + //TODO: Implement subtitles + //val subtitleButtons = arrayListOf() + + 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() + 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(); 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); diff --git a/app/src/main/java/com/futo/platformplayer/activities/FCastGuideActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/FCastGuideActivity.kt new file mode 100644 index 00000000..691bbb77 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/FCastGuideActivity.kt @@ -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(R.id.text_explanation).apply { + val guideText = """ +

1. Install FCast Receiver:

+

- Open Play Store, FireStore, or FCast website on your TV/desktop.
+ - Search for "FCast Receiver", install and open it.

+
+ +

2. Prepare the Grayjay App:

+

- Ensure it's connected to the same network as the FCast Receiver.

+
+ +

3. Initiate Casting from Grayjay:

+

- Click the cast button in Grayjay.

+
+ +

4. Connect to FCast Receiver:

+

- Wait for your device to show in the list or add it manually with its IP address.

+
+ +

5. Confirm Connection:

+

- Click "OK" to confirm your device selection.

+
+ +

6. Start Casting:

+

- Press "start" next to the device you've added.

+
+ +

7. Play Your Video:

+

- Start any video in Grayjay to cast.

+
+ +

Finding Your IP Address:

+

On FCast Receiver (Android): Displayed on the main screen.
+ On Windows: Use 'ipconfig' in Command Prompt.
+ On Linux: Use 'hostname -I' or 'ip addr' in Terminal.
+ On MacOS: System Preferences > Network.

+ """.trimIndent() + + text = Html.fromHtml(guideText, Html.FROM_HTML_MODE_COMPACT) + } + + findViewById(R.id.button_back).setOnClickListener { + UIDialogs.showCastingTutorialDialog(this) + finish() + } + + findViewById(R.id.button_close).onClick.subscribe { + UIDialogs.showCastingTutorialDialog(this) + finish() + } + + findViewById(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(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"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index c1d849ef..84dbb104 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt index 3e5259a9..8527e2d6 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt index 69c92f49..90a65e00 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt @@ -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 { @@ -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 { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt new file mode 100644 index 00000000..36df5fb2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index e8a8a573..a6748cf2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -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) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index 66a655be..8beba2f2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -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 { + 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 { diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 39b8c640..eb254b6d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -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") { diff --git a/app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt similarity index 95% rename from app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt rename to app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index da4f8fbf..e3524846 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -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(json); + val playbackUpdate = Json.decodeFromString(json); time = playbackUpdate.time.toDouble(); isPlaying = when (playbackUpdate.state) { 1 -> true @@ -295,7 +295,7 @@ class FastCastCastingDevice : CastingDevice { return; } - val volumeUpdate = Json.decodeFromString(json); + val volumeUpdate = Json.decodeFromString(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 { diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index b71094b2..f59b55ad 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -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(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 { 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, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { FastCastCastingDevice(name, addresses, port) }, + return addOrUpdateCastDevice(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, + val services: List + ) + + @Serializable + private data class FCastService( + val port: Int, + val type: Int + ) + companion object { val instance: StateCasting = StateCasting(); diff --git a/app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt similarity index 71% rename from app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt rename to app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt index 5b8e8272..64de18ba 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt @@ -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 ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 9966e40a..295e191a 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -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() { diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt new file mode 100644 index 00000000..9f305b18 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt @@ -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(R.id.button_guide).onClick.subscribe { + context.startActivity(Intent(context, FCastGuideActivity::class.java)) + } + + findViewById(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(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(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(R.id.button_close).onClick.subscribe { + dismiss() + UIDialogs.showCastingAddDialog(context) + } + } + + companion object { + private val TAG = "CastingTutorialDialog"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt index 584c8465..cc9015eb 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt @@ -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(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index dca38091..8f13545c 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -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() { diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 612c7a8c..619db900 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -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"; } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 7f082407..048e36d3 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -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() + 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() + 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() + 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, 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"; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index 6d7b8991..f2e420b9 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -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() }), ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }), ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate() }), + ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate() }), 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()"); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt index e0d13b28..ca970cfb 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt @@ -35,6 +35,11 @@ class BuyFragment : MainFragment() { return view; } + override fun onDestroyMainView() { + super.onDestroyMainView() + _view = null + } + class BuyView: LinearLayout { private val _fragment: BuyFragment; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt new file mode 100644 index 00000000..8e03e4e4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt @@ -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; + private val _textCommentCount: TextView + private val _comments: ArrayList = 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 = IdentityHashMap() + + private val _taskLoadComments = if(!isInEditMode) TaskHandler>( + StateApp.instance.scopeGetter, { StatePolycentric.instance.getSystemComments(context, it) }) + .success { pager -> onCommentsLoaded(pager); } + .exception { + UIDialogs.toast("Failed to load comments"); + setLoading(false); + } + .exception { + 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(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) { + 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index 1dad57ef..0e498476 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -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 : L val fragment: TFragment; private val _scrollListener: RecyclerView.OnScrollListener; + private var _automaticNextPageCounter = 0; constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>? = null) : super(inflater.context) { this.fragment = fragment; @@ -122,7 +121,6 @@ abstract class FeedView : L _toolbarContentView = findViewById(R.id.container_toolbar_content); - var filteredNextPageCounter = 0; _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { if (it is IAsyncPager<*>) it.nextPageAsync(); @@ -142,15 +140,8 @@ abstract class FeedView : 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 { 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 : 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 : L _recyclerResults.addOnScrollListener(_scrollListener); } + private fun ensureEnoughContentVisible(filteredResults: List) { + 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 : L recyclerData.resultsUnfiltered.addAll(toAdd); recyclerData.adapter.notifyDataSetChanged(); recyclerData.loadedFeedStyle = feedStyle; + ensureEnoughContentVisible(filteredResults) } private fun detachPagerEvents() { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index b35cd912..667b6ac9 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 6a84494a..c65cffa2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -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); }; diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index a2aa67ef..e40f83cb 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -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) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(sources: Iterable, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? { diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index e07b8a17..57f42576 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) { diff --git a/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt b/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt index dc5ce5bb..2a4bb4b4 100644 --- a/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt +++ b/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt @@ -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?, val creationTime: OffsetDateTime = OffsetDateTime.now()); + data class CachedOwnedClaims(val ownedClaims: List?, 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() private val _profileCache = hashMapOf() private val _profileUrlCache = FragmentedStorage.get("profileUrlCache") private val _scope = CoroutineScope(Dispatchers.IO); - private val _taskGetProfile = BatchedTaskHandler(_scope, { system -> + private val _taskGetProfile = BatchedTaskHandler(_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 diff --git a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt index a58a9b29..cf6e0ba2 100644 --- a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt @@ -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(); diff --git a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt index 7f076304..a5b08b81 100644 --- a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt @@ -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"); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt b/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt index 76d06783..9bced80b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt @@ -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 diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index c30a4311..bfdc4836 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -543,6 +543,7 @@ class StateApp { ); } + StateAnnouncement.instance.registerDefaultHandlerAnnouncement(); StateAnnouncement.instance.registerDidYouKnow(); Logger.i(TAG, "MainApp Started: Finished"); } diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt index f294a6f9..73468d3f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -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 { + 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() + 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 { 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 { + private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List { 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; diff --git a/app/src/main/java/com/futo/platformplayer/views/Loader.kt b/app/src/main/java/com/futo/platformplayer/views/LoaderView.kt similarity index 83% rename from app/src/main/java/com/futo/platformplayer/views/Loader.kt rename to app/src/main/java/com/futo/platformplayer/views/LoaderView.kt index 8e4a64d3..2e0610e3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/Loader.kt +++ b/app/src/main/java/com/futo/platformplayer/views/LoaderView.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt index 4a6f4677..d9e1011c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt @@ -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? = 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?) { - _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") } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt index 79660207..89c3c1bb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt @@ -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(); + var onRepliesClick = Event1(); + var onDelete = Event1(); 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; } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt new file mode 100644 index 00000000..f0581abd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt @@ -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; + private var _likesDislikesReplies: StatePolycentric.LikesDislikesReplies? = null; + + private val _taskGetLiveComment = TaskHandler(StateApp.instance.scopeGetter, ::getLikesDislikesReplies) + .success { + _likesDislikesReplies = it + updateLikesDislikesReplies() + } + .exception { + Logger.w(TAG, "Failed to get live comment.", it); + //TODO: Show error + hideLikesDislikesReplies() + } + + var onRepliesClick = Event1(); + var onDelete = Event1(); + var comment: IPlatformComment? = null + private set; + + constructor(viewGroup: ViewGroup, cache: IdentityHashMap) : 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"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 6eddcc98..8d4a6f0a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt index 75e57c47..f7a63d53 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt @@ -17,8 +17,8 @@ class SubscriptionAdapter : RecyclerView.Adapter { var onSettings = Event1(); var sortBy: Int = 3 set(value) { - field = value; - updateDataset(); + field = value + updateDataset() } constructor(inflater: LayoutInflater, confirmationMessage: String) : super() { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt index 80f10782..62918eed 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt index 6644d7eb..d66bb466 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt @@ -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) { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt index 8bc3770e..6ddf89c4 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt index 487ac8e7..9a59debb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt index 1b2d9c37..fe2cb7e0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt index c4990dce..32263642 100644 --- a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt @@ -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(); val onBrightnessAdjusted = Event1(); val onSoundAdjusted = Event1(); 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() { diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index 0c0be08b..5045f59d 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt index 44cb6020..77a0ba88 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt @@ -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, onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null) { + fun load(readonly: Boolean, metadata: String, contextUrl: String?, ref: Protocol.Reference?, parentComment: IPlatformComment? = null, loader: suspend () -> IPager, 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; diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt index cc8e30f1..2c34dc5e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt @@ -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?){ diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt b/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt index 014a24b4..2a84c372 100644 --- a/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt @@ -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(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 + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt b/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt index f56feced..8854f606 100644 --- a/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt +++ b/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt index 0677aa99..e02205b7 100644 --- a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt @@ -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 IPager, IPager>(StateApp.instance.scopeGetter, { it(); }) .success { pager -> onCommentsLoaded(pager); } .exception { - UIDialogs.toast("Failed to load comments"); + setMessage("UnknownHostException: " + it.message); + Logger.e(TAG, "Failed to load comments.", it); + setLoading(false); + } + .exception { + setMessage(it.message); + Logger.e(TAG, "Failed to load comments.", it); setLoading(false); } .exception { + 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(); + var onRepliesClick = Event1(); var onCommentsLoaded = Event1(); + + 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) { cancel(); + setMessage(null); _readonly = readonly; setLoading(true); @@ -177,6 +247,7 @@ class CommentsList : ConstraintLayout { _comments.clear(); _commentsPager = null; _adapterComments.notifyDataSetChanged(); + setMessage(null); } fun cancel() { diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index 01eb189c..3b1c9430 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -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) }; diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 3f873b8c..ae1a109b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -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? = 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 { diff --git a/app/src/main/proto/com/futo/platformplayer/protos/ChromeCast.proto b/app/src/main/proto/com/futo/platformplayer/protos/ChromeCast.proto new file mode 100644 index 00000000..395c4889 --- /dev/null +++ b/app/src/main/proto/com/futo/platformplayer/protos/ChromeCast.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto b/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto deleted file mode 100644 index f6b090d9..00000000 --- a/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto +++ /dev/null @@ -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; -} diff --git a/app/src/main/res/drawable/background_comment.xml b/app/src/main/res/drawable/background_comment.xml new file mode 100644 index 00000000..152c90b9 --- /dev/null +++ b/app/src/main/res/drawable/background_comment.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_pill_pred.xml b/app/src/main/res/drawable/background_pill_pred.xml new file mode 100644 index 00000000..85ae3542 --- /dev/null +++ b/app/src/main/res/drawable/background_pill_pred.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_chat_filled.xml b/app/src/main/res/drawable/ic_chat_filled.xml new file mode 100644 index 00000000..dda8bf17 --- /dev/null +++ b/app/src/main/res/drawable/ic_chat_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fcast.xml b/app/src/main/res/drawable/ic_fcast.xml new file mode 100644 index 00000000..22ac06f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_fcast.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/activity_fcast_guide.xml b/app/src/main/res/layout/activity_fcast_guide.xml new file mode 100644 index 00000000..4d6a2b89 --- /dev/null +++ b/app/src/main/res/layout/activity_fcast_guide.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 747ad391..ac34014a 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -52,7 +52,7 @@ android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content"> - - + android:orientation="horizontal"> + + + + + - +