From 2ee3c30b0e9e428e1a06ae32d983da9cad290e6a Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 27 Nov 2023 12:10:19 +0100 Subject: [PATCH 01/20] Better URL handling support. Prompt user to set Grayjay as a default handler for certain URLs. --- app/src/main/AndroidManifest.xml | 44 ++++++++++ .../java/com/futo/platformplayer/Settings.kt | 10 ++- .../java/com/futo/platformplayer/UIDialogs.kt | 41 ++++++++- .../states/StateAnnouncement.kt | 21 ++++- .../futo/platformplayer/states/StateApp.kt | 1 + .../main/res/layout/dialog_url_handling.xml | 86 +++++++++++++++++++ app/src/main/res/values/strings.xml | 6 ++ app/src/unstable/AndroidManifest.xml | 34 -------- 8 files changed, 204 insertions(+), 39 deletions(-) create mode 100644 app/src/main/res/layout/dialog_url_handling.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a659758a..85755ccb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -133,6 +133,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 { + 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 +145,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(); } 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/res/layout/dialog_url_handling.xml b/app/src/main/res/layout/dialog_url_handling.xml new file mode 100644 index 00000000..fa860162 --- /dev/null +++ b/app/src/main/res/layout/dialog_url_handling.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8552e9c4..1bb406e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -292,6 +292,8 @@ Clear external Downloads directory Change external General directory Change tabs visible on the home screen + Link Handling + Allow Grayjay to handle links Change the external directory for general files Clear the external storage for download files Change the external storage for download files @@ -678,6 +680,10 @@ " + Tax" New playlist Add to new playlist + URL Handling + Allow Grayjay to handle specific URLs? + When you click \'Yes\', the Grayjay app settings will open.\n\nThere, navigate to:\n1. "Open by default" or "Set as default" section.\nYou might find this option directly or under \'Advanced\' settings, depending on your device.\n\n2. Choose \'Open supported links\' for Grayjay.\n\n(some devices have this listed under \'Default Apps\' in the main settings followed by selecting Grayjay for relevant categories) + Failed to show settings Recommendations Subscriptions diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index 74db3f15..d053978f 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -6,39 +6,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From b6ad3fd991c239812b7254271f4af67fb00cd8ee Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 27 Nov 2023 13:49:34 +0000 Subject: [PATCH 02/20] HLS download implementation --- .../futo/platformplayer/UISlideOverlays.kt | 159 ++++++++++++-- .../streams/sources/HLSVariantUrlSource.kt | 51 +++++ .../platformplayer/downloads/VideoDownload.kt | 202 ++++++++++++++++-- .../platformplayer/helpers/VideoHelper.kt | 25 ++- .../com/futo/platformplayer/parsers/HLS.kt | 99 +++++++++ .../services/DownloadService.kt | 6 +- .../overlays/slideup/SlideUpMenuOverlay.kt | 3 +- 7 files changed, 503 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index e06c525f..22144ad4 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -12,18 +12,25 @@ 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.overlays.slideup.SlideUpMenuGroup @@ -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(Loader(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, { 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/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/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/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/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?){ From 0ae90ecf03b08497c19cc2a7a74ea2eb1ba1cfe9 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 27 Nov 2023 16:27:56 +0100 Subject: [PATCH 03/20] Updated Playstore flow for URL handling. --- app/src/main/AndroidManifest.xml | 44 ----------------- .../java/com/futo/platformplayer/UIDialogs.kt | 29 ++++++++---- app/src/main/res/values/strings.xml | 1 + app/src/unstable/AndroidManifest.xml | 47 +++++++++++++++++++ 4 files changed, 67 insertions(+), 54 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 85755ccb..a659758a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -133,50 +133,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (R.id.button_yes).apply { this.setOnClickListener { - 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)) - } + 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() + onYes?.invoke() + dialog.dismiss() + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bb406e4..5f09bf45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -684,6 +684,7 @@ Allow Grayjay to handle specific URLs? When you click \'Yes\', the Grayjay app settings will open.\n\nThere, navigate to:\n1. "Open by default" or "Set as default" section.\nYou might find this option directly or under \'Advanced\' settings, depending on your device.\n\n2. Choose \'Open supported links\' for Grayjay.\n\n(some devices have this listed under \'Default Apps\' in the main settings followed by selecting Grayjay for relevant categories) Failed to show settings + Play store version does not support default URL handling. Recommendations Subscriptions diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index d053978f..0f4a00b8 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -6,5 +6,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 8a35cd0e82e277b887d24e2d0fbb1322ba1f0d56 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 27 Nov 2023 17:08:40 +0100 Subject: [PATCH 04/20] Added settings to allow different behavior when audio focus is regained within 10 seconds. --- .../java/com/futo/platformplayer/Settings.kt | 4 ++++ .../services/MediaPlaybackService.kt | 20 ++++++++++++++++++- app/src/main/res/values/strings.xml | 8 ++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 4201783a..c1e7b313 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -385,6 +385,10 @@ 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.comments, "group", R.string.comments_description, 6) 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f09bf45..76b974c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -379,6 +379,8 @@ Restore a previous automatic backup Resume After Preview Review the current and past changelogs + Restart after audio focus loss + Restart playback when gaining audio focus after a loss Chapter Update FPS Change accuracy of chapter updating, higher might cost more performance Set Automatic Backup @@ -843,4 +845,10 @@ Information Verbose + + Never + Within 10 seconds of loss + Within 30 seconds of loss + Always + \ No newline at end of file From ee4442d553545ca6845aa0f5fc255ed1fdcb3d00 Mon Sep 17 00:00:00 2001 From: Gabe Rogan Date: Mon, 27 Nov 2023 15:40:31 -0500 Subject: [PATCH 05/20] Add quickstart docs --- plugin-development.md | 114 +++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 52 deletions(-) diff --git a/plugin-development.md b/plugin-development.md index 8330a53f..2ed27929 100644 --- a/plugin-development.md +++ b/plugin-development.md @@ -3,74 +3,107 @@ ## Table of Contents - [Introduction](#introduction) -- [Grayjay App Overview](#grayjay-app-overview) -- [Plugin Development Overview](#plugin-development-overview) -- [Setting up the Development Environment](#setting-up-the-development-environment) -- [Using the Developer Interface](#using-the-developer-interface) +- [Quick Start](#quick-start) +- [Configuration file](#configuration-file) +- [Example plugin](#example-plugin) - [Plugin Deployment](#plugin-deployment) - [Common Issues and Troubleshooting](#common-issues-and-troubleshooting) -- [Additional Resources](#additional-resources) - [Support and Contact](#support-and-contact) ## Introduction -Welcome to the Grayjay App plugin development documentation. This guide will provide an overview of Grayjay's plugin system and guide you through the steps necessary to create, test, debug, and deploy plugins. +Welcome to the Grayjay App plugin development documentation. Plugins are additional components that you can create to extend the functionality of the Grayjay app, for example a YouTube or Odysee plugin. This guide will provide an overview of Grayjay's plugin system and guide you through the steps necessary to create, test, debug, and deploy plugins. -## Grayjay App Overview +## Quick Start -Grayjay is a unique media application that aims to revolutionize the relationship between content creators and their audiences. By shifting the focus from platforms to creators, Grayjay democratizes the content delivery process, empowering creators to retain full ownership of their content and directly monetize their work. +### Download GrayJay: -For users, Grayjay offers a more privacy-focused and personalized content viewing experience. Rather than being manipulated by opaque algorithms, users can decide what they want to watch, thus enhancing their engagement and enjoyment of the content. +- Download the GrayJay app for Android [here](https://grayjay.app/). -Our ultimate goal is to create the best media app, merging content and features that users love with a strong emphasis on user and creator empowerment and privacy. +### Enable GrayJay Developer Mode: -By developing Grayjay, we strive to make a stride toward a more open, interconnected, and equitable media ecosystem. This ecosystem fosters a thriving community of creators who are supported by their audiences, all facilitated through a platform that respects and prioritizes privacy and ownership. +- Enable developer mode in the GrayJay app (not Android settings app) by tapping the “More” tab, tapping “Settings”, scrolling all the way to the bottom, and tapping the “Version Code” multiple times. -## Plugin Development Overview +### Run the GrayJay DevServer: -Plugins are additional components that you can create to extend the functionality of the Grayjay app. +- At the bottom of the Settings page in the GrayJay app, Click the purple “Developer Settings” button. Then click the “Start Server” button to start the DevServer. -## Setting up the Developer Environment + -Before you start developing plugins, it is necessary to set up a suitable developer environment. Here's how to do it: +### Open the GrayJay DevServer on your computer: -1. Create a plugin, the minimal starting point is the following. +- Open the Android settings app and search for “IP address”. The IP address should look like `192.168.X.X`. +- Open `http://:11337/dev` in your web browser. + + -`SomeConfig.js` -```json +### Create and host your plugin: + +- Clone the [Odysee plugin](https://gitlab.futo.org/videostreaming/plugins/odysee) as an example +- `cd` into the project folder and serve with `npx serve` (if you have [Node.js](https://nodejs.org/en/)) +- `npx serve` should give you a Network url (not the localhost one) that looks like `http://192.168.X.X:3000`. Your config file URL will be something like `http://192.168.X.X:3000/OdyseeConfig.json`. + + + +### Test your plugin: + +- When the DevServer is open in your browser, enter the config file URL and click “Load Plugin”. This will NOT inject the plugin into the app, for that you need to click "Inject Plugin" on the Integration tab. + + + +- On the Testing tab, you can individually test the methods in your plugin. To reload once you make changes on the plugin, click the top-right refresh button. *Note: While testing, the custom domParser package is overwritten with the browser's implementation, so it may behave differently than once it is loaded into the app.* + + + +- On the Integration tab you can test your plugin end-to-end in the GrayJay app and monitor device logs. You can click "Inject Plugin" in order to inject the plugin into the app. Your plugin should show up on the Sources tab in the GrayJay app. If you make changes and want to reload the plugin, click "Inject Plugin" again. + + + +## Configuration file + +Create a configuration file for your plugin. + +`SomeConfig.json` +```js { "name": "Some name", "description": "A description for your plugin", "author": "Your author name", "authorUrl": "https://yoursite.com", + // The `sourceUrl` field should contain the URL where your plugin will be publically accessible in the future. This allows the app to scan this location to see if there are any updates available. "sourceUrl": "https://yoursite.com/SomeConfig.json", "repositoryUrl": "https://github.com/someuser/someproject", "scriptUrl": "./SomeScript.js", "version": 1, "iconUrl": "./someimage.png", + + // The `id` field should be a uniquely generated UUID like from [https://www.uuidgenerator.net/](https://www.uuidgenerator.net/). This will be used to distinguish your plugin from others. "id": "309b2e83-7ede-4af8-8ee9-822bc4647a24", - "scriptSignature": "", - "scriptPublicKey": "", + // The `scriptSignature` and `scriptPublicKey` should be set whenever you deploy your script (NOT REQUIRED DURING DEVELOPMENT). The purpose of these fields is to verify that a plugin update was made by the same individual that developed the original plugin. This prevents somebody from hijacking your plugin without having access to your public private keypair. When this value is not present, you can still use this plugin, however the user will be informed that these values are missing and that this is a security risk. Here is an example script showing you how to generate these values. + "scriptSignature": "", + "scriptPublicKey": "", + + // The `packages` field allows you to specify which packages you want to use, current available packages are: + // - `Http`: for performing HTTP requests + // - `DOMParser`: for parsing a DOM + // - `Utilities`: for various utility functions like generating UUIDs or converting to Base64 + // See documentation for more: https://gitlab.futo.org/videostreaming/grayjay/-/tree/master/docs/packages "packages": ["Http"], "allowEval": false, + + // The `allowUrls` field is allowed to be `everywhere`, this means that the plugin is allowed to access all URLs. However, this will popup a warning for the user that this is the case. Therefore, it is recommended to narrow the scope of the accessible URLs only to the URLs that you actually need. Other requests will be blocked. During development it can be convenient to use `everywhere`. Possible values are `odysee.com`, `api.odysee.com`, etc. "allowUrls": [ "everywhere" ] } ``` -The `sourceUrl` field should contain the URL where your plugin will be publically accessible in the future. This allows the app to scan this location to see if there are any updates available. - -The `id` field should be a uniquely generated UUID like from [https://www.uuidgenerator.net/](https://www.uuidgenerator.net/). This will be used to distinguish your plugin from others. - -The `allowUrls` field is allowed to be `everywhere`, this means that the plugin is allowed to access all URLs. However, this will popup a warning for the user that this is the case. Therefore, it is recommended to narrow the scope of the accessible URLs only to the URLs that you actually need. Other requests will be blocked. During development it can be convenient to use `everywhere`. Possible values are `odysee.com`, `api.odysee.com`, etc. - -The `scriptSignature` and `scriptPublicKey` should be set whenever you deploy your script (NOT REQUIRED DURING DEVELOPMENT). The purpose of these fields is to verify that a plugin update was made by the same individual that developed the original plugin. This prevents somebody from hijacking your plugin without having access to your public private keypair. When this value is not present, you can still use this plugin, however the user will be informed that these values are missing and that this is a security risk. Here is an example script showing you how to generate these values. +You can use this script to generate the `scriptSignature` and `scriptPublicKey` fields above: `sign-script.sh` ```sh @@ -96,15 +129,11 @@ fi SIGNATURE=$(echo -n "$DATA" | openssl dgst -sha512 -sign ~/.ssh/id_rsa | base64 -w 0) echo "This is your signature: '$SIGNATURE'" - ``` -The `packages` field allows you to specify which packages you want to use, current available packages are: -- `Http`: for performing HTTP requests (see [docs](TODO)) -- `DOMParser`: for parsing a DOM (see [docs](TODO)) -- `Utilities`: for various utility functions like generating random UUIDs or converting to Base64 (see [docs](TODO)) +## Example plugin -Note that this is just a starting point, plugins can also implement optional features such as login, importing playlists/subscriptions, etc. For full examples please see in-house developed plugins (click [here](TODO)). +Note that this is just a starting point, plugins can also implement optional features such as login, importing playlists/subscriptions, etc. For full examples please see in-house developed plugins (click [here](https://gitlab.futo.org/videostreaming/plugins)). `SomeScript.js` ```js @@ -348,19 +377,6 @@ class SomeChannelVideoPager extends VideoPager { } ``` -2. Configure a web server to host the plugin. This can be something as simple as a NGINX server where you just place the files in the wwwroot or a simple dotnet/npm program that hosts the file for you. The important part is that the webserver and the phone are on the same network and the phone can access the files hosted by the development machine. An example of what this would look like is [here](https://plugins.grayjay.app/Odysee/OdyseeConfig.json). Alternatively, you could simply point to a Github/Gitlab raw file if you do not want to host it yourself. Note that the URL is not required to be publically accessible during development and HTTPS is NOT required. -3. Enable developer mode on the mobile application by going to settings, clicking on the version code multiple times. Once enabled, click on developer settings and then in the developer settings enable the webserver. -4. You are now able to access the developer interface on the phone via `http://:11337/dev`. - -## Using the Developer Interface - -Once in the web portal you will see several tabs and a form allowing you to load a plugin. - -1. Lets load your plugin. Take the URL that your plugin config is available at (like http://192.168.1.196:5000/Some/SomeConfig.json) and enter it in the `Plugin Config Json Url` field. Once entered, click load plugin. -*The package override domParser will override the domParser with the browser implementation. This is useful when you quickly want to iterate on plugins that parse the DOM, but it is less accurate to what the plugin will behave like once in-app.* -2. Once the plugin is loaded, you can click on the `Testing` tab and call individual methods. This allows you to quickly iterate, test methods and make sure they are returning the proper values. To reload once you make changes on the plugin, click the top-right refresh button. -3. After you are sure everything is working properly, click the `Integration` tab in order to perform integration testing on your plugin. You can click the `Inject Plugin` button in order to inject the plugin into the app. On the sources page in your app you should see your source and you are able to test it and make sure everything works. If you make changes and want to reload the plugin, click the `Inject Plugin` button again. - ## Plugin Deployment Here's how to deploy your plugin and distribute it to end-users: @@ -403,12 +419,6 @@ Ensure the QR code correctly points to the plugin config URL. The URL must be pu Make sure the signature is correctly generated and added. Also, ensure the version number in the config matches the new version number. -## Additional Resources - -Here are some additional resources that might help you with your plugin development: - -Please - ## Support and Contact If you have any issues or need further assistance, feel free to reach out to us at: From 69e43dc53314ffc0703e162ea4ee811cc82d5f22 Mon Sep 17 00:00:00 2001 From: Gabe Rogan Date: Tue, 28 Nov 2023 09:11:42 -0500 Subject: [PATCH 06/20] Split docs into multiple pages --- docs/Example Plugin.md | 244 ++++++++++++++++++++++++++++++++++ docs/Script Signing.md | 31 +++++ plugin-development.md | 293 ++++------------------------------------- 3 files changed, 303 insertions(+), 265 deletions(-) create mode 100644 docs/Example Plugin.md create mode 100644 docs/Script Signing.md diff --git a/docs/Example Plugin.md b/docs/Example Plugin.md new file mode 100644 index 00000000..a4831f05 --- /dev/null +++ b/docs/Example Plugin.md @@ -0,0 +1,244 @@ +# Example plugin + +Note that this is just a starting point, plugins can also implement optional features such as login, importing playlists/subscriptions, etc. For full examples please see in-house developed plugins (click [here](https://gitlab.futo.org/videostreaming/plugins)). + +```js +source.enable = function (conf) { + /** + * @param conf: SourceV8PluginConfig (the SomeConfig.js) + */ +} + +source.getHome = function(continuationToken) { + /** + * @param continuationToken: any? + * @returns: VideoPager + */ + const videos = []; // The results (PlatformVideo) + const hasMore = false; // Are there more pages? + const context = { continuationToken: continuationToken }; // Relevant data for the next page + return new SomeHomeVideoPager(videos, hasMore, context); +} + +source.searchSuggestions = function(query) { + /** + * @param query: string + * @returns: string[] + */ + + const suggestions = []; //The suggestions for a specific search query + return suggestions; +} + +source.getSearchCapabilities = function() { + //This is an example of how to return search capabilities like available sorts, filters and which feed types are available (see source.js for more details) + return { + types: [Type.Feed.Mixed], + sorts: [Type.Order.Chronological, "^release_time"], + filters: [ + { + id: "date", + name: "Date", + isMultiSelect: false, + filters: [ + { id: Type.Date.Today, name: "Last 24 hours", value: "today" }, + { id: Type.Date.LastWeek, name: "Last week", value: "thisweek" }, + { id: Type.Date.LastMonth, name: "Last month", value: "thismonth" }, + { id: Type.Date.LastYear, name: "Last year", value: "thisyear" } + ] + }, + ] + }; +} + +source.search = function (query, type, order, filters, continuationToken) { + /** + * @param query: string + * @param type: string + * @param order: string + * @param filters: Map> + * @param continuationToken: any? + * @returns: VideoPager + */ + const videos = []; // The results (PlatformVideo) + const hasMore = false; // Are there more pages? + const context = { query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page + return new SomeSearchVideoPager(videos, hasMore, context); +} + +source.getSearchChannelContentsCapabilities = function () { + //This is an example of how to return search capabilities on a channel like available sorts, filters and which feed types are available (see source.js for more details) + return { + types: [Type.Feed.Mixed], + sorts: [Type.Order.Chronological], + filters: [] + }; +} + +source.searchChannelContents = function (url, query, type, order, filters, continuationToken) { + /** + * @param url: string + * @param query: string + * @param type: string + * @param order: string + * @param filters: Map> + * @param continuationToken: any? + * @returns: VideoPager + */ + + const videos = []; // The results (PlatformVideo) + const hasMore = false; // Are there more pages? + const context = { channelUrl: channelUrl, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page + return new SomeSearchChannelVideoPager(videos, hasMore, context); +} + +source.searchChannels = function (query, continuationToken) { + /** + * @param query: string + * @param continuationToken: any? + * @returns: ChannelPager + */ + + const channels = []; // The results (PlatformChannel) + const hasMore = false; // Are there more pages? + const context = { query: query, continuationToken: continuationToken }; // Relevant data for the next page + return new SomeChannelPager(channels, hasMore, context); +} + +source.isChannelUrl = function(url) { + /** + * @param url: string + * @returns: boolean + */ + + return REGEX_CHANNEL_URL.test(url); +} + +source.getChannel = function(url) { + return new PlatformChannel({ + //... see source.js for more details + }); +} + +source.getChannelContents = function(url, type, order, filters, continuationToken) { + /** + * @param url: string + * @param type: string + * @param order: string + * @param filters: Map> + * @param continuationToken: any? + * @returns: VideoPager + */ + + const videos = []; // The results (PlatformVideo) + const hasMore = false; // Are there more pages? + const context = { url: url, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page + return new SomeChannelVideoPager(videos, hasMore, context); +} + +source.isContentDetailsUrl = function(url) { + /** + * @param url: string + * @returns: boolean + */ + + return REGEX_DETAILS_URL.test(url); +} + +source.getContentDetails = function(url) { + /** + * @param url: string + * @returns: PlatformVideoDetails + */ + + return new PlatformVideoDetails({ + //... see source.js for more details + }); +} + +source.getComments = function (url, continuationToken) { + /** + * @param url: string + * @param continuationToken: any? + * @returns: CommentPager + */ + + const comments = []; // The results (Comment) + const hasMore = false; // Are there more pages? + const context = { url: url, continuationToken: continuationToken }; // Relevant data for the next page + return new SomeCommentPager(comments, hasMore, context); + +} +source.getSubComments = function (comment) { + /** + * @param comment: Comment + * @returns: SomeCommentPager + */ + + if (typeof comment === 'string') { + comment = JSON.parse(comment); + } + + return getCommentsPager(comment.context.claimId, comment.context.claimId, 1, false, comment.context.commentId); +} + +class SomeCommentPager extends CommentPager { + constructor(results, hasMore, context) { + super(results, hasMore, context); + } + + nextPage() { + return source.getComments(this.context.url, this.context.continuationToken); + } +} + +class SomeHomeVideoPager extends VideoPager { + constructor(results, hasMore, context) { + super(results, hasMore, context); + } + + nextPage() { + return source.getHome(this.context.continuationToken); + } +} + +class SomeSearchVideoPager extends VideoPager { + constructor(results, hasMore, context) { + super(results, hasMore, context); + } + + nextPage() { + return source.search(this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken); + } +} + +class SomeSearchChannelVideoPager extends VideoPager { + constructor(results, hasMore, context) { + super(results, hasMore, context); + } + + nextPage() { + return source.searchChannelContents(this.context.channelUrl, this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken); + } +} + +class SomeChannelPager extends ChannelPager { + constructor(results, hasMore, context) { + super(results, hasMore, context); + } + + nextPage() { + return source.searchChannelContents(this.context.query, this.context.continuationToken); + } +} + +class SomeChannelVideoPager extends VideoPager { + constructor(results, hasMore, context) { + super(results, hasMore, context); + } + + nextPage() { + return source.getChannelContents(this.context.url, this.context.type, this.context.order, this.context.filters, this.context.continuationToken); + } +} +``` \ No newline at end of file diff --git a/docs/Script Signing.md b/docs/Script Signing.md new file mode 100644 index 00000000..5e593e35 --- /dev/null +++ b/docs/Script Signing.md @@ -0,0 +1,31 @@ +# Script signing + +The `scriptSignature` and `scriptPublicKey` should be set whenever you deploy your script (NOT REQUIRED DURING DEVELOPMENT). The purpose of these fields is to verify that a plugin update was made by the same individual that developed the original plugin. This prevents somebody from hijacking your plugin without having access to your public private keypair. When this value is not present, you can still use this plugin, however the user will be informed that these values are missing and that this is a security risk. Here is an example script showing you how to generate these values. See below for more details. + +You can use this script to generate the `scriptSignature` and `scriptPublicKey` fields above: + +`sign-script.sh` +```sh +#!/bin/sh +#Example usage: +#cat script.js | sign-script.sh +#sh sign-script.sh script.js + +#Set your key paths here +PRIVATE_KEY_PATH=~/.ssh/id_rsa +PUBLIC_KEY_PATH=~/.ssh/id_rsa.pub + +PUBLIC_KEY_PKCS8=$(ssh-keygen -f "$PUBLIC_KEY_PATH" -e -m pkcs8 | tail -n +2 | head -n -1 | tr -d '\n') +echo "This is your public key: '$PUBLIC_KEY_PKCS8'" + +if [ $# -eq 0 ]; then + # No parameter provided, read from stdin + DATA=$(cat) +else + # Parameter provided, read from file + DATA=$(cat "$1") +fi + +SIGNATURE=$(echo -n "$DATA" | openssl dgst -sha512 -sign ~/.ssh/id_rsa | base64 -w 0) +echo "This is your signature: '$SIGNATURE'" +``` \ No newline at end of file diff --git a/plugin-development.md b/plugin-development.md index 2ed27929..52dec4dc 100644 --- a/plugin-development.md +++ b/plugin-development.md @@ -5,12 +5,16 @@ - [Introduction](#introduction) - [Quick Start](#quick-start) - [Configuration file](#configuration-file) +- [Packages](#packages) +- [Authentication](#authentication) +- [Content Types](#content-types) - [Example plugin](#example-plugin) +- [Pagination](#pagination) +- [Script signing](#script-signing) - [Plugin Deployment](#plugin-deployment) - [Common Issues and Troubleshooting](#common-issues-and-troubleshooting) - [Support and Contact](#support-and-contact) - ## Introduction Welcome to the Grayjay App plugin development documentation. Plugins are additional components that you can create to extend the functionality of the Grayjay app, for example a YouTube or Odysee plugin. This guide will provide an overview of Grayjay's plugin system and guide you through the steps necessary to create, test, debug, and deploy plugins. @@ -83,15 +87,11 @@ Create a configuration file for your plugin. // The `id` field should be a uniquely generated UUID like from [https://www.uuidgenerator.net/](https://www.uuidgenerator.net/). This will be used to distinguish your plugin from others. "id": "309b2e83-7ede-4af8-8ee9-822bc4647a24", - // The `scriptSignature` and `scriptPublicKey` should be set whenever you deploy your script (NOT REQUIRED DURING DEVELOPMENT). The purpose of these fields is to verify that a plugin update was made by the same individual that developed the original plugin. This prevents somebody from hijacking your plugin without having access to your public private keypair. When this value is not present, you can still use this plugin, however the user will be informed that these values are missing and that this is a security risk. Here is an example script showing you how to generate these values. + // See the "Script Signing" section for details "scriptSignature": "", "scriptPublicKey": "", - // The `packages` field allows you to specify which packages you want to use, current available packages are: - // - `Http`: for performing HTTP requests - // - `DOMParser`: for parsing a DOM - // - `Utilities`: for various utility functions like generating UUIDs or converting to Base64 - // See documentation for more: https://gitlab.futo.org/videostreaming/grayjay/-/tree/master/docs/packages + // See the "Packages" section for details, currently allowed values are: ["Http", "DOMParser", "Utilities"] "packages": ["Http"], "allowEval": false, @@ -103,279 +103,42 @@ Create a configuration file for your plugin. } ``` -You can use this script to generate the `scriptSignature` and `scriptPublicKey` fields above: +## Packages -`sign-script.sh` -```sh -#!/bin/sh -#Example usage: -#cat script.js | sign-script.sh -#sh sign-script.sh script.js +The `packages` field allows you to specify which packages you want to use, current available packages are: +- `Http`: for performing HTTP requests (see [docs](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/packages/packageHttp.md)) +- `DOMParser`: for parsing a DOM (no docs yet, see [source code](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt)) +- `Utilities`: for various utility functions like generating UUIDs or converting to Base64 (no docs yet, see [source code](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt)) -#Set your key paths here -PRIVATE_KEY_PATH=~/.ssh/id_rsa -PUBLIC_KEY_PATH=~/.ssh/id_rsa.pub +## Authentication -PUBLIC_KEY_PKCS8=$(ssh-keygen -f "$PUBLIC_KEY_PATH" -e -m pkcs8 | tail -n +2 | head -n -1 | tr -d '\n') -echo "This is your public key: '$PUBLIC_KEY_PKCS8'" +Authentication is sometimes required by plugins to access user data and premium content, for example on YouTube or Patreon. -if [ $# -eq 0 ]; then - # No parameter provided, read from stdin - DATA=$(cat) -else - # Parameter provided, read from file - DATA=$(cat "$1") -fi +See [Authentication.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Authentication.md) -SIGNATURE=$(echo -n "$DATA" | openssl dgst -sha512 -sign ~/.ssh/id_rsa | base64 -w 0) -echo "This is your signature: '$SIGNATURE'" -``` +## Content Types + +Docs for data structures like PlatformVideo your plugin uses to communicate with the GrayJay app. + +See [Content Types.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Content%20Types.md) ## Example plugin -Note that this is just a starting point, plugins can also implement optional features such as login, importing playlists/subscriptions, etc. For full examples please see in-house developed plugins (click [here](https://gitlab.futo.org/videostreaming/plugins)). +See the example plugin to better understand the plugin API e.g. `getHome` and `search`. -`SomeScript.js` -```js -source.enable = function (conf) { - /** - * @param conf: SourceV8PluginConfig (the SomeConfig.js) - */ -} +See [Example Plugin.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Example%20Plugin.md) -source.getHome = function(continuationToken) { - /** - * @param continuationToken: any? - * @returns: VideoPager - */ - const videos = []; // The results (PlatformVideo) - const hasMore = false; // Are there more pages? - const context = { continuationToken: continuationToken }; // Relevant data for the next page - return new SomeHomeVideoPager(videos, hasMore, context); -} +## Pagination -source.searchSuggestions = function(query) { - /** - * @param query: string - * @returns: string[] - */ +Plugins use "Pagers" to send paginated data to the GrayJay app. - const suggestions = []; //The suggestions for a specific search query - return suggestions; -} +See [Pagers.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Pagers.md) -source.getSearchCapabilities = function() { - //This is an example of how to return search capabilities like available sorts, filters and which feed types are available (see source.js for more details) - return { - types: [Type.Feed.Mixed], - sorts: [Type.Order.Chronological, "^release_time"], - filters: [ - { - id: "date", - name: "Date", - isMultiSelect: false, - filters: [ - { id: Type.Date.Today, name: "Last 24 hours", value: "today" }, - { id: Type.Date.LastWeek, name: "Last week", value: "thisweek" }, - { id: Type.Date.LastMonth, name: "Last month", value: "thismonth" }, - { id: Type.Date.LastYear, name: "Last year", value: "thisyear" } - ] - }, - ] - }; -} +## Script signing -source.search = function (query, type, order, filters, continuationToken) { - /** - * @param query: string - * @param type: string - * @param order: string - * @param filters: Map> - * @param continuationToken: any? - * @returns: VideoPager - */ - const videos = []; // The results (PlatformVideo) - const hasMore = false; // Are there more pages? - const context = { query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page - return new SomeSearchVideoPager(videos, hasMore, context); -} +When you deploy your plugin, you'll need to add code signing for security. -source.getSearchChannelContentsCapabilities = function () { - //This is an example of how to return search capabilities on a channel like available sorts, filters and which feed types are available (see source.js for more details) - return { - types: [Type.Feed.Mixed], - sorts: [Type.Order.Chronological], - filters: [] - }; -} - -source.searchChannelContents = function (url, query, type, order, filters, continuationToken) { - /** - * @param url: string - * @param query: string - * @param type: string - * @param order: string - * @param filters: Map> - * @param continuationToken: any? - * @returns: VideoPager - */ - - const videos = []; // The results (PlatformVideo) - const hasMore = false; // Are there more pages? - const context = { channelUrl: channelUrl, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page - return new SomeSearchChannelVideoPager(videos, hasMore, context); -} - -source.searchChannels = function (query, continuationToken) { - /** - * @param query: string - * @param continuationToken: any? - * @returns: ChannelPager - */ - - const channels = []; // The results (PlatformChannel) - const hasMore = false; // Are there more pages? - const context = { query: query, continuationToken: continuationToken }; // Relevant data for the next page - return new SomeChannelPager(channels, hasMore, context); -} - -source.isChannelUrl = function(url) { - /** - * @param url: string - * @returns: boolean - */ - - return REGEX_CHANNEL_URL.test(url); -} - -source.getChannel = function(url) { - return new PlatformChannel({ - //... see source.js for more details - }); -} - -source.getChannelContents = function(url, type, order, filters, continuationToken) { - /** - * @param url: string - * @param type: string - * @param order: string - * @param filters: Map> - * @param continuationToken: any? - * @returns: VideoPager - */ - - const videos = []; // The results (PlatformVideo) - const hasMore = false; // Are there more pages? - const context = { url: url, query: query, type: type, order: order, filters: filters, continuationToken: continuationToken }; // Relevant data for the next page - return new SomeChannelVideoPager(videos, hasMore, context); -} - -source.isContentDetailsUrl = function(url) { - /** - * @param url: string - * @returns: boolean - */ - - return REGEX_DETAILS_URL.test(url); -} - -source.getContentDetails = function(url) { - /** - * @param url: string - * @returns: PlatformVideoDetails - */ - - return new PlatformVideoDetails({ - //... see source.js for more details - }); -} - -source.getComments = function (url, continuationToken) { - /** - * @param url: string - * @param continuationToken: any? - * @returns: CommentPager - */ - - const comments = []; // The results (Comment) - const hasMore = false; // Are there more pages? - const context = { url: url, continuationToken: continuationToken }; // Relevant data for the next page - return new SomeCommentPager(comments, hasMore, context); - -} -source.getSubComments = function (comment) { - /** - * @param comment: Comment - * @returns: SomeCommentPager - */ - - if (typeof comment === 'string') { - comment = JSON.parse(comment); - } - - return getCommentsPager(comment.context.claimId, comment.context.claimId, 1, false, comment.context.commentId); -} - -class SomeCommentPager extends CommentPager { - constructor(results, hasMore, context) { - super(results, hasMore, context); - } - - nextPage() { - return source.getComments(this.context.url, this.context.continuationToken); - } -} - -class SomeHomeVideoPager extends VideoPager { - constructor(results, hasMore, context) { - super(results, hasMore, context); - } - - nextPage() { - return source.getHome(this.context.continuationToken); - } -} - -class SomeSearchVideoPager extends VideoPager { - constructor(results, hasMore, context) { - super(results, hasMore, context); - } - - nextPage() { - return source.search(this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken); - } -} - -class SomeSearchChannelVideoPager extends VideoPager { - constructor(results, hasMore, context) { - super(results, hasMore, context); - } - - nextPage() { - return source.searchChannelContents(this.context.channelUrl, this.context.query, this.context.type, this.context.order, this.context.filters, this.context.continuationToken); - } -} - -class SomeChannelPager extends ChannelPager { - constructor(results, hasMore, context) { - super(results, hasMore, context); - } - - nextPage() { - return source.searchChannelContents(this.context.query, this.context.continuationToken); - } -} - -class SomeChannelVideoPager extends VideoPager { - constructor(results, hasMore, context) { - super(results, hasMore, context); - } - - nextPage() { - return source.getChannelContents(this.context.url, this.context.type, this.context.order, this.context.filters, this.context.continuationToken); - } -} -``` +See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%Signing.md) ## Plugin Deployment From 0e6e381800a5510e4e6422105837498d0379c1c7 Mon Sep 17 00:00:00 2001 From: Gabe Rogan Date: Tue, 28 Nov 2023 09:31:46 -0500 Subject: [PATCH 07/20] Custom HTTP server wording --- plugin-development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin-development.md b/plugin-development.md index 52dec4dc..6f49269e 100644 --- a/plugin-development.md +++ b/plugin-development.md @@ -45,7 +45,7 @@ Welcome to the Grayjay App plugin development documentation. Plugins are additio ### Create and host your plugin: - Clone the [Odysee plugin](https://gitlab.futo.org/videostreaming/plugins/odysee) as an example -- `cd` into the project folder and serve with `npx serve` (if you have [Node.js](https://nodejs.org/en/)) +- `cd` into the project folder and serve with `npx serve` (if you have [Node.js](https://nodejs.org/en/)) or any other HTTP Server you desire. - `npx serve` should give you a Network url (not the localhost one) that looks like `http://192.168.X.X:3000`. Your config file URL will be something like `http://192.168.X.X:3000/OdyseeConfig.json`. From 3bf73ed5e8bfbbe9a5a9091e35bd303907c1b2da Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 29 Nov 2023 13:43:40 +0100 Subject: [PATCH 08/20] Implemented delete comment support. Implemented Comments tab. Implemented replies overlay showing parent comment. --- .../futo/platformplayer/UISlideOverlays.kt | 6 +- .../platformplayer/activities/MainActivity.kt | 4 + .../activities/SettingsActivity.kt | 10 +- .../comments/PolycentricPlatformComment.kt | 11 +- .../platformplayer/dialogs/CommentDialog.kt | 2 +- .../bottombar/MenuBottomBarFragment.kt | 1 + .../fragment/mainactivity/main/BuyFragment.kt | 5 + .../mainactivity/main/CommentsFragment.kt | 306 ++++++++++++++++++ .../mainactivity/main/PostDetailFragment.kt | 6 +- .../mainactivity/main/VideoDetailView.kt | 10 +- .../platformplayer/states/StatePolycentric.kt | 57 +++- .../views/{Loader.kt => LoaderView.kt} | 13 +- .../platformplayer/views/MonetizationView.kt | 8 +- .../views/adapters/CommentViewHolder.kt | 20 +- .../CommentWithReferenceViewHolder.kt | 165 ++++++++++ .../views/adapters/SubscriptionAdapter.kt | 4 +- .../feedtypes/PreviewNestedVideoView.kt | 16 +- .../views/overlays/RepliesOverlay.kt | 44 ++- .../platformplayer/views/pills/PillButton.kt | 26 ++ .../views/pills/PillRatingLikesDislikes.kt | 40 ++- .../views/segments/CommentsList.kt | 40 ++- .../main/res/drawable/background_comment.xml | 6 + .../res/drawable/background_pill_pred.xml | 7 + app/src/main/res/drawable/ic_chat_filled.xml | 9 + app/src/main/res/layout/activity_settings.xml | 2 +- app/src/main/res/layout/fragment_comments.xml | 111 +++++++ app/src/main/res/layout/list_comment.xml | 26 +- .../layout/list_comment_with_reference.xml | 124 +++++++ .../layout/list_video_thumbnail_nested.xml | 2 +- app/src/main/res/layout/overlay_replies.xml | 79 ++++- app/src/main/res/layout/pill_button.xml | 13 +- .../main/res/layout/rating_likesdislikes.xml | 13 +- app/src/main/res/layout/view_monetization.xml | 2 +- app/src/main/res/values-ar/strings.xml | 4 + app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-ja/strings.xml | 4 + app/src/main/res/values-ko/strings.xml | 4 + app/src/main/res/values-pt/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values-zh/strings.xml | 4 + app/src/main/res/values/loader_attrs.xml | 1 + app/src/main/res/values/strings.xml | 5 + dep/polycentricandroid | 2 +- 45 files changed, 1164 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt rename app/src/main/java/com/futo/platformplayer/views/{Loader.kt => LoaderView.kt} (83%) create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt create mode 100644 app/src/main/res/drawable/background_comment.xml create mode 100644 app/src/main/res/drawable/background_pill_pred.xml create mode 100644 app/src/main/res/drawable/ic_chat_filled.xml create mode 100644 app/src/main/res/layout/fragment_comments.xml create mode 100644 app/src/main/res/layout/list_comment_with_reference.xml diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 22144ad4..6b401f1c 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -32,7 +32,7 @@ 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 @@ -137,7 +137,7 @@ class UISlideOverlays { } fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { - val items = arrayListOf(Loader(container.context)) + 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) { @@ -501,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/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index c1d849ef..a385170a 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -90,6 +90,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; @@ -205,6 +206,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 +284,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; @@ -916,6 +919,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/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/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..97d5200e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt @@ -0,0 +1,306 @@ +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 + +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 _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); + 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) { + val processHandle = StatePolycentric.instance.processHandle ?: return + if (comment !is PolycentricPlatformComment) { + return + } + + 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) { + var parentComment: PolycentricPlatformComment = c; + _repliesOverlay.load(false, metadata, c.contextUrl, c.reference, c, + { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, + { + val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); + val index = _comments.indexOf(c); + _comments[index] = newComment; + _adapterComments.notifyItemChanged(_adapterComments.childToParentPosition(index)); + parentComment = newComment; + }); + } 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/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/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt index f294a6f9..af91da21 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -218,6 +218,59 @@ 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 + } + + suspend fun getLiveComment(contextUrl: String, reference: Protocol.Reference): PolycentricPlatformComment { + val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, + Protocol.QueryReferencesRequestEvents.newBuilder() + .setFromType(ContentType.POST.value) + .addAllCountLwwElementReferences(arrayListOf( + 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() + )) + .addCountReferences( + Protocol.QueryReferencesRequestCountReferences.newBuilder() + .setFromType(ContentType.POST.value) + .build()) + .build() + ) + + return mapQueryReferences(contextUrl, response).first() + } + suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager { val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, Protocol.QueryReferencesRequestEvents.newBuilder() @@ -284,7 +337,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; @@ -338,7 +391,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..45b1be78 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt @@ -0,0 +1,165 @@ +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.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 + +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 _taskGetLiveComment = TaskHandler(StateApp.instance.scopeGetter, { StatePolycentric.instance.getLiveComment(it.contextUrl, it.reference) }) + .success { + bind(it, true); + } + .exception { + Logger.w(TAG, "Failed to get live comment.", it); + //TODO: Show error + } + + var onRepliesClick = Event1(); + var onDelete = Event1(); + var comment: IPlatformComment? = null + private set; + + constructor(viewGroup: ViewGroup) : 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) + + _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); + } + + fun bind(comment: IPlatformComment, live: Boolean = false) { + _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(); + + if (comment is PolycentricPlatformComment) { + if (live) { + val hasLiked = StatePolycentric.instance.hasLiked(comment.reference); + val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference); + _pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked); + } else { + _pillRatingLikesDislikes.setLoading(true) + } + + if (live) { + _buttonReplies.setLoading(false) + + val replies = comment.replyCount ?: 0; + if (replies > 0) { + _buttonReplies.visibility = View.VISIBLE; + _buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies); + } else { + _buttonReplies.visibility = View.GONE; + } + } else { + _buttonReplies.setLoading(true) + } + + if (false) { + //Restore from cached + } else { + //_taskGetLiveComment.run(comment) + } + } else { + _pillRatingLikesDislikes.visibility = View.GONE + _buttonReplies.visibility = View.GONE + } + + this.comment = comment; + } + + 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/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/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/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/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..0266cf5a 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); 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..29adc859 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 @@ -19,9 +19,12 @@ 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.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 { @@ -69,7 +72,7 @@ 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) { @@ -85,7 +88,8 @@ class CommentsList : ConstraintLayout { 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; } ); @@ -106,6 +110,36 @@ class CommentsList : ConstraintLayout { _prependedView.addView(view); } + private fun onDelete(comment: IPlatformComment) { + val processHandle = StatePolycentric.instance.processHandle ?: return + if (comment !is PolycentricPlatformComment) { + return + } + + 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(); 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/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"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_comment.xml b/app/src/main/res/layout/list_comment.xml index 8491b028..3aa9fb8e 100644 --- a/app/src/main/res/layout/list_comment.xml +++ b/app/src/main/res/layout/list_comment.xml @@ -70,7 +70,7 @@ + + + + + + diff --git a/app/src/main/res/layout/list_comment_with_reference.xml b/app/src/main/res/layout/list_comment_with_reference.xml new file mode 100644 index 00000000..2e828255 --- /dev/null +++ b/app/src/main/res/layout/list_comment_with_reference.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_video_thumbnail_nested.xml b/app/src/main/res/layout/list_video_thumbnail_nested.xml index a54e6276..cd2afb51 100644 --- a/app/src/main/res/layout/list_video_thumbnail_nested.xml +++ b/app/src/main/res/layout/list_video_thumbnail_nested.xml @@ -127,7 +127,7 @@ android:visibility="gone" android:gravity="center" android:orientation="vertical"> - diff --git a/app/src/main/res/layout/overlay_replies.xml b/app/src/main/res/layout/overlay_replies.xml index 9ce3174b..c683b38a 100644 --- a/app/src/main/res/layout/overlay_replies.xml +++ b/app/src/main/res/layout/overlay_replies.xml @@ -2,6 +2,7 @@ @@ -15,6 +16,78 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" /> + + + + + + + + + + + + @@ -32,6 +104,7 @@ android:layout_width="match_parent" android:layout_height="0dp" app:layout_constraintTop_toBottomOf="@id/add_comment_view" - app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintBottom_toBottomOf="parent" + android:layout_marginTop="12dp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/pill_button.xml b/app/src/main/res/layout/pill_button.xml index 759e090f..96457b81 100644 --- a/app/src/main/res/layout/pill_button.xml +++ b/app/src/main/res/layout/pill_button.xml @@ -9,12 +9,13 @@ android:paddingStart="7dp" android:paddingEnd="12dp" android:background="@drawable/background_pill" - android:id="@+id/root"> + android:id="@+id/root" + android:gravity="center_vertical"> + + \ No newline at end of file diff --git a/app/src/main/res/layout/rating_likesdislikes.xml b/app/src/main/res/layout/rating_likesdislikes.xml index c98194e7..a4c6599c 100644 --- a/app/src/main/res/layout/rating_likesdislikes.xml +++ b/app/src/main/res/layout/rating_likesdislikes.xml @@ -8,7 +8,8 @@ android:paddingBottom="7dp" android:paddingLeft="7dp" android:paddingRight="12dp" - android:background="@drawable/background_pill"> + android:background="@drawable/background_pill" + android:gravity="center_vertical"> + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_monetization.xml b/app/src/main/res/layout/view_monetization.xml index aa11779e..d89ee66c 100644 --- a/app/src/main/res/layout/view_monetization.xml +++ b/app/src/main/res/layout/view_monetization.xml @@ -120,7 +120,7 @@ android:orientation="horizontal" android:layout_gravity="center" /> - معلومات تفصيلي + + Newest + Oldest + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 45ba4cec..72d223fb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -722,4 +722,8 @@ Information Ausführlich + + Newest + Oldest + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2e3a05cb..b73d92bf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -738,4 +738,8 @@ Información Detallado + + Newest + Oldest + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5949281b..2691d5d0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -722,4 +722,8 @@ Information Verbeux + + Newest + Oldest + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index cb450e4b..c58233ce 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -722,4 +722,8 @@ 情報 詳細 + + Newest + Oldest + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 10fa7560..2124a56c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -722,4 +722,8 @@ 정보 상세 + + Newest + Oldest + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 26f4f3b2..793ff525 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -722,4 +722,8 @@ Informação Detalhado + + Newest + Oldest + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9a5b9440..ebc3654b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -722,4 +722,8 @@ Информация Подробно + + Newest + Oldest + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 701aee7f..7a0d2f77 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -722,4 +722,8 @@ 信息 详细 + + Newest + Oldest + diff --git a/app/src/main/res/values/loader_attrs.xml b/app/src/main/res/values/loader_attrs.xml index 73aa80fd..0c491998 100644 --- a/app/src/main/res/values/loader_attrs.xml +++ b/app/src/main/res/values/loader_attrs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76b974c8..16bc9d01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -687,6 +687,7 @@ When you click \'Yes\', the Grayjay app settings will open.\n\nThere, navigate to:\n1. "Open by default" or "Set as default" section.\nYou might find this option directly or under \'Advanced\' settings, depending on your device.\n\n2. Choose \'Open supported links\' for Grayjay.\n\n(some devices have this listed under \'Default Apps\' in the main settings followed by selecting Grayjay for relevant categories) Failed to show settings Play store version does not support default URL handling. + These are all {commentCount} comments you have made in Grayjay. Recommendations Subscriptions @@ -774,6 +775,10 @@ Disabled Enabled + + Newest + Oldest + Name Ascending Name Descending diff --git a/dep/polycentricandroid b/dep/polycentricandroid index 839e4c4a..faaa7a6d 160000 --- a/dep/polycentricandroid +++ b/dep/polycentricandroid @@ -1 +1 @@ -Subproject commit 839e4c4a4f5ed6cb6f68047f88b26c5831e6e703 +Subproject commit faaa7a6d8efb3f92fc239e7d77ec2f9a46c3a958 From c806ff2e3346590906dfa27d74806fd3bab3a879 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 29 Nov 2023 13:54:26 +0100 Subject: [PATCH 09/20] Added support for comment deletion. --- .../mainactivity/main/CommentsFragment.kt | 46 ++++++++++--------- .../views/segments/CommentsList.kt | 46 ++++++++++--------- 2 files changed, 48 insertions(+), 44 deletions(-) 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 index 97d5200e..eb1bb34b 100644 --- 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 @@ -154,33 +154,35 @@ class CommentsFragment : MainFragment() { } private fun onDelete(comment: IPlatformComment) { - val processHandle = StatePolycentric.instance.processHandle ?: return - if (comment !is PolycentricPlatformComment) { - return - } + 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)) + 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; - } + 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); + 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 { 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 29adc859..e377d81d 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 @@ -111,33 +111,35 @@ class CommentsList : ConstraintLayout { } private fun onDelete(comment: IPlatformComment) { - val processHandle = StatePolycentric.instance.processHandle ?: return - if (comment !is PolycentricPlatformComment) { - return - } + 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)) + 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; - } + 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); + 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() { From 3387c727d10671cbf8d67c44f98ebaeda398d7e7 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 29 Nov 2023 15:48:34 +0100 Subject: [PATCH 10/20] Proper implementation for replies/likes/dislikes in the comment tab. --- .../mainactivity/main/CommentsFragment.kt | 24 ++++-- .../platformplayer/states/StatePolycentric.kt | 56 ++++++------ .../CommentWithReferenceViewHolder.kt | 86 +++++++++++++------ .../views/pills/PillRatingLikesDislikes.kt | 4 + app/src/main/res/layout/fragment_comments.xml | 12 +-- 5 files changed, 113 insertions(+), 69 deletions(-) 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 index eb1bb34b..8e03e4e4 100644 --- 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 @@ -33,6 +33,7 @@ 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 @@ -84,6 +85,7 @@ class CommentsFragment : MainFragment() { 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) }) @@ -111,7 +113,7 @@ class CommentsFragment : MainFragment() { childCountGetter = { _comments.size }, childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_comments[position]); }, childViewHolderFactory = { viewGroup, _ -> - val holder = CommentWithReferenceViewHolder(viewGroup); + val holder = CommentWithReferenceViewHolder(viewGroup, _cache); holder.onDelete.subscribe(::onDelete); holder.onRepliesClick.subscribe(::onRepliesClick); return@InsertedViewAdapterWithLoader holder; @@ -202,15 +204,21 @@ class CommentsFragment : MainFragment() { } if (c is PolycentricPlatformComment) { - var parentComment: PolycentricPlatformComment = c; _repliesOverlay.load(false, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, - { - val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); - val index = _comments.indexOf(c); - _comments[index] = newComment; - _adapterComments.notifyItemChanged(_adapterComments.childToParentPosition(index)); - parentComment = newComment; + { 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) }); 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 af91da21..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 @@ -247,28 +242,36 @@ class StatePolycentric { return posts } - suspend fun getLiveComment(contextUrl: String, reference: Protocol.Reference): PolycentricPlatformComment { - val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, - Protocol.QueryReferencesRequestEvents.newBuilder() - .setFromType(ContentType.POST.value) - .addAllCountLwwElementReferences(arrayListOf( - 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() - )) - .addCountReferences( - Protocol.QueryReferencesRequestCountReferences.newBuilder() - .setFromType(ContentType.POST.value) - .build()) - .build() - ) + data class LikesDislikesReplies( + var likes: Long, + var dislikes: Long, + var replyCount: Long + ) - return mapQueryReferences(contextUrl, response).first() + 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 { @@ -347,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]; 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 index 45b1be78..f0581abd 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.views.adapters +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -22,6 +23,8 @@ 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; @@ -32,14 +35,18 @@ class CommentWithReferenceViewHolder : ViewHolder { 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, { StatePolycentric.instance.getLiveComment(it.contextUrl, it.reference) }) + private val _taskGetLiveComment = TaskHandler(StateApp.instance.scopeGetter, ::getLikesDislikesReplies) .success { - bind(it, true); + _likesDislikesReplies = it + updateLikesDislikesReplies() } .exception { Logger.w(TAG, "Failed to get live comment.", it); //TODO: Show error + hideLikesDislikesReplies() } var onRepliesClick = Event1(); @@ -47,7 +54,7 @@ class CommentWithReferenceViewHolder : ViewHolder { var comment: IPlatformComment? = null private set; - constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_comment_with_reference, viewGroup, false)) { + 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); @@ -56,6 +63,7 @@ class CommentWithReferenceViewHolder : ViewHolder { _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 @@ -99,7 +107,18 @@ class CommentWithReferenceViewHolder : ViewHolder { _textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context); } - fun bind(comment: IPlatformComment, live: Boolean = false) { + 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); @@ -123,40 +142,51 @@ class CommentWithReferenceViewHolder : ViewHolder { _textBody.text = comment.message.fixHtmlLinks(); - if (comment is PolycentricPlatformComment) { - if (live) { - val hasLiked = StatePolycentric.instance.hasLiked(comment.reference); - val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference); - _pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked); - } else { - _pillRatingLikesDislikes.setLoading(true) + 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] + } } - if (live) { + 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 = comment.replyCount ?: 0; - if (replies > 0) { - _buttonReplies.visibility = View.VISIBLE; - _buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies); - } else { - _buttonReplies.visibility = View.GONE; - } + val replies = likesDislikesReplies.replyCount ?: 0; + _buttonReplies.visibility = View.VISIBLE; + _buttonReplies.text.text = "$replies " + itemView.context.getString(R.string.replies); } else { - _buttonReplies.setLoading(true) - } + Log.i(TAG, "updateLikesDislikesReplies to load") - if (false) { - //Restore from cached - } else { - //_taskGetLiveComment.run(comment) + _pillRatingLikesDislikes.setLoading(true) + _buttonReplies.setLoading(true) + _taskGetLiveComment.run(c) } } else { - _pillRatingLikesDislikes.visibility = View.GONE - _buttonReplies.visibility = View.GONE + hideLikesDislikesReplies() } + } - this.comment = comment; + private fun hideLikesDislikesReplies() { + _pillRatingLikesDislikes.visibility = View.GONE + _buttonReplies.visibility = View.GONE } companion object { 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 0266cf5a..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 @@ -159,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; @@ -172,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/res/layout/fragment_comments.xml b/app/src/main/res/layout/fragment_comments.xml index a1e660e3..cbaccaeb 100644 --- a/app/src/main/res/layout/fragment_comments.xml +++ b/app/src/main/res/layout/fragment_comments.xml @@ -61,6 +61,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + - - Date: Wed, 29 Nov 2023 16:02:58 +0100 Subject: [PATCH 11/20] Removed last mentions of FastCast and added backwards compatibility. --- .../casting/AirPlayCastingDevice.kt | 3 +- .../platformplayer/casting/CastingDevice.kt | 28 +++++++++++++++++-- .../casting/ChomecastCastingDevice.kt | 2 -- ...CastingDevice.kt => FCastCastingDevice.kt} | 18 ++++++------ .../platformplayer/casting/StateCasting.kt | 12 ++++---- .../casting/models/{FastCast.kt => FCast.kt} | 10 +++---- .../dialogs/CastingAddDialog.kt | 5 +--- .../dialogs/ConnectedCastingDialog.kt | 4 +-- .../views/adapters/DeviceViewHolder.kt | 4 +-- app/src/main/res/values/strings.xml | 2 +- 10 files changed, 51 insertions(+), 37 deletions(-) rename app/src/main/java/com/futo/platformplayer/casting/{FastCastCastingDevice.kt => FCastCastingDevice.kt} (95%) rename app/src/main/java/com/futo/platformplayer/casting/models/{FastCast.kt => FCast.kt} (71%) 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..6102a846 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -2,7 +2,6 @@ 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 @@ -13,7 +12,6 @@ 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 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..399c3817 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -345,7 +345,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 +961,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 +1042,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 +1090,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; 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..8abe1ce4 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) { @@ -62,7 +59,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 -> { 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/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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16bc9d01..5af4d54d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -839,7 +839,7 @@ Russian - FastCast + FCast ChromeCast AirPlay From baad342aec6c9786ea5023381008b8cbb5009e85 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 30 Nov 2023 08:47:07 +0100 Subject: [PATCH 12/20] Fixed Rumble comments and show error in CommentList whenever an error happens. --- .../views/segments/CommentsList.kt | 41 +++++++++++++++++-- .../main/res/layout/view_comments_list.xml | 8 ++-- app/src/stable/assets/sources/rumble | 2 +- app/src/unstable/assets/sources/rumble | 2 +- 4 files changed, 43 insertions(+), 10 deletions(-) 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 e377d81d..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,6 +23,7 @@ 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.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.adapters.CommentViewHolder @@ -29,15 +34,22 @@ 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); @@ -75,15 +87,26 @@ class CommentsList : ConstraintLayout { 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, _ -> @@ -100,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); @@ -183,6 +216,7 @@ class CommentsList : ConstraintLayout { fun load(readonly: Boolean, loader: suspend () -> IPager) { cancel(); + setMessage(null); _readonly = readonly; setLoading(true); @@ -213,6 +247,7 @@ class CommentsList : ConstraintLayout { _comments.clear(); _commentsPager = null; _adapterComments.notifyDataSetChanged(); + setMessage(null); } fun cancel() { diff --git a/app/src/main/res/layout/view_comments_list.xml b/app/src/main/res/layout/view_comments_list.xml index f285cae0..48318415 100644 --- a/app/src/main/res/layout/view_comments_list.xml +++ b/app/src/main/res/layout/view_comments_list.xml @@ -1,13 +1,11 @@ - + android:layout_height="match_parent"> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/stable/assets/sources/rumble b/app/src/stable/assets/sources/rumble index 60a7ee2d..b0e35a9b 160000 --- a/app/src/stable/assets/sources/rumble +++ b/app/src/stable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 60a7ee2ddf71b936d9c289a3343020cc20edfe56 +Subproject commit b0e35a9b6631fb3279fb38619ecc3eba812e5ed6 diff --git a/app/src/unstable/assets/sources/rumble b/app/src/unstable/assets/sources/rumble index 60a7ee2d..b0e35a9b 160000 --- a/app/src/unstable/assets/sources/rumble +++ b/app/src/unstable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 60a7ee2ddf71b936d9c289a3343020cc20edfe56 +Subproject commit b0e35a9b6631fb3279fb38619ecc3eba812e5ed6 From 948f5a2a6d51bfc107f97008a80fb42c5864da01 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 30 Nov 2023 09:56:40 +0100 Subject: [PATCH 13/20] Added FCast guide and other casting help options. --- app/src/main/AndroidManifest.xml | 4 + .../java/com/futo/platformplayer/UIDialogs.kt | 9 +- .../activities/FCastGuideActivity.kt | 108 ++++++++++++++++ .../dialogs/CastingAddDialog.kt | 7 + .../dialogs/CastingHelpDialog.kt | 63 +++++++++ app/src/main/res/drawable/ic_fcast.xml | 12 ++ .../main/res/layout/activity_fcast_guide.xml | 121 ++++++++++++++++++ .../main/res/layout/dialog_casting_add.xml | 30 ++++- .../main/res/layout/dialog_casting_help.xml | 68 ++++++++++ app/src/main/res/values/strings.xml | 10 ++ 10 files changed, 424 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/activities/FCastGuideActivity.kt create mode 100644 app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt create mode 100644 app/src/main/res/drawable/ic_fcast.xml create mode 100644 app/src/main/res/layout/activity_fcast_guide.xml create mode 100644 app/src/main/res/layout/dialog_casting_help.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a659758a..ba3fcc35 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -210,5 +210,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/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 556b7fb5..f9dd9185 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri -import android.util.Log import android.util.TypedValue import android.view.Gravity import android.view.LayoutInflater @@ -18,7 +17,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 @@ -344,6 +342,13 @@ class UIDialogs { } } + 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/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/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 8abe1ce4..295e191a 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -23,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); @@ -35,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); @@ -102,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/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/dialog_casting_add.xml b/app/src/main/res/layout/dialog_casting_add.xml index b94b44db..a24ac948 100644 --- a/app/src/main/res/layout/dialog_casting_add.xml +++ b/app/src/main/res/layout/dialog_casting_add.xml @@ -8,13 +8,31 @@ android:background="@color/gray_1d" android:padding="20dp"> - + android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5af4d54d..2dd01402 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -688,6 +688,16 @@ Failed to show settings Play store version does not support default URL handling. These are all {commentCount} comments you have made in Grayjay. + Tutorial + Go back to casting add dialog + View a video about how to cast + View the FCast technical documentation + Guide + How to use FCast guide + FCast + Open the FCast website + FCast Website + FCast Technical Documentation Recommendations Subscriptions From 4be4bb631f366d05fa24922c9f4efae1fd724058 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 30 Nov 2023 11:40:58 +0100 Subject: [PATCH 14/20] Fixed gesture control issues causing wrong area to have gesture controls and disabled full screen gesture when casting. --- .../views/behavior/GestureControlView.kt | 113 +++++++++--------- .../platformplayer/views/casting/CastView.kt | 3 +- .../views/video/FutoVideoPlayer.kt | 2 +- app/src/main/res/layout/view_cast.xml | 10 +- 4 files changed, 67 insertions(+), 61 deletions(-) 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..01ec06dc 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,14 +85,9 @@ 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 { - override fun onDown(p0: MotionEvent): Boolean { return false; } + _gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener { + override fun onDown(p0: MotionEvent): Boolean { Logger.v(TAG, "onDown ${p0.x}, ${p0.y}"); return false; } override fun onShowPress(p0: MotionEvent) = Unit; override fun onSingleTapUp(p0: MotionEvent): Boolean { return false; } override fun onScroll(p0: MotionEvent, p1: MotionEvent, distanceX: Float, distanceY: Float): Boolean { @@ -112,15 +110,15 @@ 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); + Logger.v(TAG, "onScroll p0.x = ${p0.x}, p0.y = ${p0.y}, p1.x = ${p1.x}, p1.y = ${p1.y}, rx = $rx, ry = $ry, width = $width, height = $height, _isFullScreen = $_isFullScreen") 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 +134,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 +164,59 @@ 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); + Logger.v(TAG, "onTouch x = $x, y = $y, ev.x = ${ev.x}, ev.y = ${ev.y}"); - 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/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/res/layout/view_cast.xml b/app/src/main/res/layout/view_cast.xml index 18958a08..9344fa9e 100644 --- a/app/src/main/res/layout/view_cast.xml +++ b/app/src/main/res/layout/view_cast.xml @@ -23,6 +23,11 @@ android:background="#cc000000" android:layout_marginBottom="6dp" /> + + - - Date: Thu, 30 Nov 2023 11:50:52 +0100 Subject: [PATCH 15/20] Removed Logger. --- .../futo/platformplayer/views/behavior/GestureControlView.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 01ec06dc..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 @@ -87,7 +87,7 @@ class GestureControlView : LinearLayout { _layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen); _gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener { - override fun onDown(p0: MotionEvent): Boolean { Logger.v(TAG, "onDown ${p0.x}, ${p0.y}"); return false; } + override fun onDown(p0: MotionEvent): Boolean { return false; } override fun onShowPress(p0: MotionEvent) = Unit; override fun onSingleTapUp(p0: MotionEvent): Boolean { return false; } override fun onScroll(p0: MotionEvent, p1: MotionEvent, distanceX: Float, distanceY: Float): Boolean { @@ -112,7 +112,6 @@ class GestureControlView : LinearLayout { } else { val rx = (p0.x + p1.x) / (2 * width); val ry = (p0.y + p1.y) / (2 * height); - Logger.v(TAG, "onScroll p0.x = ${p0.x}, p0.y = ${p0.y}, p1.x = ${p1.x}, p1.y = ${p1.y}, rx = $rx, ry = $ry, width = $width, height = $height, _isFullScreen = $_isFullScreen") if (ry > 0.1 && ry < 0.9) { if (_isFullScreen && rx < 0.2) { startAdjustingBrightness(); @@ -174,7 +173,6 @@ class GestureControlView : LinearLayout { override fun onTouchEvent(event: MotionEvent?): Boolean { val ev = event ?: return super.onTouchEvent(event); - Logger.v(TAG, "onTouch x = $x, y = $y, ev.x = ${ev.x}, ev.y = ${ev.y}"); cancelHideJob(); From 0fd8ba28bbbe7c0ad8144ebe2bd7f3fdc19cb4e2 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 30 Nov 2023 14:21:42 +0100 Subject: [PATCH 16/20] Chromecast protobuf cleanup and fixed Odysee content-types being misrepresented causing casting to desktop to break. --- .../casting/ChomecastCastingDevice.kt | 14 ++-- .../platformplayer/protos/ChromeCast.proto | 18 ++++ .../protos/DeviceAuthMessage.proto | 82 ------------------- app/src/stable/assets/sources/odysee | 2 +- app/src/unstable/assets/sources/odysee | 2 +- 5 files changed, 27 insertions(+), 91 deletions(-) create mode 100644 app/src/main/proto/com/futo/platformplayer/protos/ChromeCast.proto delete mode 100644 app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto 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 6102a846..eb254b6d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -5,7 +5,7 @@ import android.util.Log 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.* @@ -374,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"); } @@ -427,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(); @@ -446,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/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/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index 6ea20460..a05feced 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit 6ea204605d4a27867702d7b024237506904d53c7 +Subproject commit a05feced804a5b664c75568162a7b3fa5562d8b3 diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index 6ea20460..a05feced 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit 6ea204605d4a27867702d7b024237506904d53c7 +Subproject commit a05feced804a5b664c75568162a7b3fa5562d8b3 From fc5888d57eaeeb2eb2dad6740d4e6ff5b3f9d0ef Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 1 Dec 2023 14:11:15 +0100 Subject: [PATCH 17/20] Added setting to allow restarting playback after connectivity loss behavior to be changed. --- .../java/com/futo/platformplayer/Settings.kt | 4 ++++ .../views/video/FutoVideoPlayerBase.kt | 23 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c1e7b313..9c3b8231 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -389,6 +389,10 @@ class Settings : FragmentedStorageFileJson() { @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, 11) + @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/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 3f873b8c..84452add 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,25 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val pos = position; val dur = duration; + var shouldRestartPlayback = false if (_shouldPlaybackRestartOnConnectivity && abs(pos - dur) > 2000) { + if (Settings.instance.playback.restartPlaybackAfterLoss == 1) { + val lossTime_ms = _connectivityLossTime_ms + if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 30) { + shouldRestartPlayback = true + } + } else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) { + val lossTime_ms = _connectivityLossTime_ms + if (lossTime_ms != null && System.currentTimeMillis() - lossTime_ms < 1000 * 10) { + shouldRestartPlayback = true + } + } else if (Settings.instance.playback.restartPlaybackAfterLoss == 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(); @@ -519,6 +539,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> { Logger.i(TAG, "IO error, set _shouldPlaybackRestartOnConnectivity=true"); _shouldPlaybackRestartOnConnectivity = true; + _connectivityLossTime_ms = System.currentTimeMillis() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2dd01402..72462219 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -381,6 +381,8 @@ Review the current and past changelogs Restart after audio focus loss Restart playback when gaining audio focus after a loss + Restart after connectivity loss + Restart playback when gaining connectivity after a loss Chapter Update FPS Change accuracy of chapter updating, higher might cost more performance Set Automatic Backup From 23d10857559a77d2baef5ad3c5c73e2ac75572fe Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 1 Dec 2023 15:01:41 +0100 Subject: [PATCH 18/20] Fixes to connectivity loss playback restart and fixes to added ensureEnoughContentVisible. --- .../java/com/futo/platformplayer/Settings.kt | 2 +- .../fragment/mainactivity/main/FeedView.kt | 47 ++++++++++++++----- .../views/video/FutoVideoPlayerBase.kt | 19 ++++---- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 9c3b8231..db6bfd52 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -390,7 +390,7 @@ class Settings : FragmentedStorageFileJson() { @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, 11) + @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; } 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/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 84452add..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 @@ -156,22 +156,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val dur = duration; var shouldRestartPlayback = false if (_shouldPlaybackRestartOnConnectivity && abs(pos - dur) > 2000) { - if (Settings.instance.playback.restartPlaybackAfterLoss == 1) { + 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.restartPlaybackAfterLoss == 2) { + } 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.restartPlaybackAfterLoss == 3) { + } 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; @@ -529,13 +528,13 @@ 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; @@ -557,8 +556,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout { Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false"); _shouldPlaybackRestartOnConnectivity = false; } - - } companion object { From f90290c4ecd7549f23daa11178db76f85e007658 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 4 Dec 2023 10:57:11 +0100 Subject: [PATCH 19/20] Added support for connecting to FCast via QR code. --- app/src/main/AndroidManifest.xml | 8 + .../java/com/futo/platformplayer/UIDialogs.kt | 5 + .../platformplayer/activities/MainActivity.kt | 210 ++++++++++++------ .../platformplayer/casting/StateCasting.kt | 45 ++++ .../dialogs/ConnectCastingDialog.kt | 22 ++ .../res/layout/dialog_casting_connect.xml | 22 +- 6 files changed, 234 insertions(+), 78 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba3fcc35..ed7a2988 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,6 +61,14 @@ + + + + + + + + diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index f9dd9185..86d73e28 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer +import android.app.Activity import android.app.AlertDialog import android.content.Context import android.content.Intent @@ -337,6 +338,10 @@ 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(); } 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 a385170a..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 @@ -124,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(); @@ -409,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") @@ -496,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) { @@ -573,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)") @@ -719,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(); 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 399c3817..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."); @@ -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/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/res/layout/dialog_casting_connect.xml b/app/src/main/res/layout/dialog_casting_connect.xml index ecadd61f..5cb43c35 100644 --- a/app/src/main/res/layout/dialog_casting_connect.xml +++ b/app/src/main/res/layout/dialog_casting_connect.xml @@ -89,18 +89,32 @@ android:textColor="@color/white" android:fontFamily="@font/inter_regular" /> - +