From d2ed0c65ca712ef4a6ec9314773cbdfe3b43c322 Mon Sep 17 00:00:00 2001 From: ajp-dev <48263871+ajp-dev@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:05:49 +0800 Subject: [PATCH 001/298] fix link typo for Script Signing typo causes HTTP 400 error when accessing link to Script Signing.md --- plugin-development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin-development.md b/plugin-development.md index 6f49269e..de9fc70c 100644 --- a/plugin-development.md +++ b/plugin-development.md @@ -138,7 +138,7 @@ See [Pagers.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/doc When you deploy your plugin, you'll need to add code signing for security. -See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%Signing.md) +See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%20Signing.md) ## Plugin Deployment @@ -186,4 +186,4 @@ Make sure the signature is correctly generated and added. Also, ensure the versi If you have any issues or need further assistance, feel free to reach out to us at: -https://chat.futo.org/login/ \ No newline at end of file +https://chat.futo.org/login/ From a1d460385d644735a999e7b477a8e62ee240bfe4 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 20 Jan 2025 15:38:26 -0600 Subject: [PATCH 002/298] prevent the user from needing to tap update on system dialog when self updating Changelog: added --- app/src/main/AndroidManifest.xml | 1 + .../java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index afd659a7..7a5f764b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + = Build.VERSION_CODES.S) { + params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) + } val sessionId = packageInstaller.createSession(params); session = packageInstaller.openSession(sessionId) From daf1d42a0f1398d4b01798c23c297c14bac15c84 Mon Sep 17 00:00:00 2001 From: Alexandre Picavet Date: Thu, 9 Jan 2025 17:44:11 +0100 Subject: [PATCH 003/298] feat(player): Add a setting to adjust player seek duration Create a seekOffset dropdown setting defaulting to 10 seconds. Update the fastForwardTick method of the GestureControlView to take the seekOffset setting into account and update the view accordingly. --- .../java/com/futo/platformplayer/Settings.kt | 18 +++++++++++++++++- .../views/behavior/GestureControlView.kt | 8 ++++---- app/src/main/res/values-fr/strings.xml | 8 ++++++++ app/src/main/res/values/strings.xml | 12 +++++++++++- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 270158fd..4cdbf764 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -486,6 +486,22 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22) var deleteFromWatchLaterAuto: Boolean = true; + + @FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23) + @DropdownFieldOptionsId(R.array.seek_offset_duration) + var seekOffset: Int = 2; + + fun getSeekOffset(): Long { + return when(seekOffset) { + 0 -> 3_000L; + 1 -> 5_000L; + 2 -> 10_000L; + 3 -> 20_000L; + 4 -> 30_000L; + 5 -> 60_000L; + else -> 10_000L; + } + } } @FormField(R.string.comments, "group", R.string.comments_description, 6) @@ -981,4 +997,4 @@ class Settings : FragmentedStorageFileJson() { } } //endregion -} \ No newline at end of file +} 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 5f4d2b33..3719e85e 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 @@ -628,12 +628,12 @@ class GestureControlView : LinearLayout { private fun fastForwardTick() { _fastForwardCounter++; - val seekOffset: Long = 10000; + val seekOffset: Long = Settings.instance.playback.getSeekOffset(); if (_rewinding) { - _textRewind.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds); + _textRewind.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds); onSeek.emit(-seekOffset); } else { - _textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds); + _textFastForward.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds); onSeek.emit(seekOffset); } } @@ -807,4 +807,4 @@ class GestureControlView : LinearLayout { const val EXIT_DURATION_FAST_FORWARD: Long = 600; const val TAG = "GestureControlView"; } -} \ No newline at end of file +} diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 733eacc2..438020da 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -688,6 +688,14 @@ Continuer la lecture Superposition du lecteur + + 3 secondes + 5 secondes + 10 secondes + 20 secondes + 30 secondes + 60 secondes + Reprendre depuis le début Reprendre après 10s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa903f2f..3cff24ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -403,6 +403,8 @@ Allow full-screen portrait when watching horizontal videos Delete from WatchLater when watched After you leave a video that you mostly watched, it will be removed from watch later. + Seek duration + Fast-Forward / Fast-Rewind duration Switch to Audio in Background Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter Groups @@ -1026,6 +1028,14 @@ Within 30 seconds of loss Always + + 3 seconds + 5 seconds + 10 seconds + 20 seconds + 30 seconds + 60 seconds + 15 30 @@ -1039,4 +1049,4 @@ 1500 2000 - \ No newline at end of file + From d4ccf232c1251241a416ee895a14415759856540 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 17:57:27 -0600 Subject: [PATCH 004/298] fix HLS download odysee nebula peertube Changelog: changed --- .../main/java/com/futo/platformplayer/UISlideOverlays.kt | 8 +++++--- .../com/futo/platformplayer/downloads/VideoDownload.kt | 8 ++------ app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 6 ++++++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 582a74f7..36f0b8e1 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -280,6 +280,8 @@ class UISlideOverlays { val masterPlaylistContent = masterPlaylistResponse.body?.string() ?: throw Exception("Master playlist content is empty") + val resolvedPlaylistUrl = masterPlaylistResponse.url + val videoButtons = arrayListOf() val audioButtons = arrayListOf() //TODO: Implement subtitles @@ -292,7 +294,7 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl) masterPlaylist.getAudioSources().forEach { it -> @@ -366,11 +368,11 @@ class UISlideOverlays { 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) + StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), 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) + StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, resolvedPlaylistUrl), null) UIDialogs.toast(container.context, "Variant audio HLS playlist download started") slideUpMenuOverlay.hide() } else { 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 8ba2814f..a9a4e2bb 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -620,10 +620,8 @@ class VideoDownload { 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 cmd = + "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -633,7 +631,6 @@ class VideoDownload { 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)) { @@ -641,7 +638,6 @@ class VideoDownload { } else { "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" } - fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, 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 9d1a3faa..eacdf546 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -61,7 +61,13 @@ class HLS { val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } + val initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } var currentSegment: MediaSegment? = null lines.forEach { line -> when { From d5cab0910e9dbe7925c09a6266d71b3f21a78044 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 22:21:06 -0600 Subject: [PATCH 005/298] fix HLS audio download and download audio only Changelog: changed --- .../futo/platformplayer/UISlideOverlays.kt | 2 +- .../platformplayer/downloads/VideoDownload.kt | 14 ++-- .../platformplayer/downloads/VideoExport.kt | 2 +- .../com/futo/platformplayer/parsers/HLS.kt | 78 +++++++++++++------ 4 files changed, 63 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 582a74f7..a78cd611 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -292,7 +292,7 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl, source is IHLSManifestAudioSource) masterPlaylist.getAudioSources().forEach { it -> 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 8ba2814f..011db8de 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -414,7 +414,7 @@ class VideoDownload { videoFilePath = File(downloadDir, videoFileName!!).absolutePath; } if(actualAudioSource != null) { - audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName(); + audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(if (actualAudioSource.container == "application/vnd.apple.mpegurl") actualAudioSource.codec else actualAudioSource.container)}".sanitizeFileName(); audioFilePath = File(downloadDir, audioFileName!!).absolutePath; } if(subtitleSource != null) { @@ -620,10 +620,8 @@ class VideoDownload { 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 cmd = + "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -633,7 +631,6 @@ class VideoDownload { 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)) { @@ -641,7 +638,6 @@ class VideoDownload { } else { "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" } - fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, @@ -1150,8 +1146,10 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) return "mp4a"; + else if (container.contains("video/mp4")) + return "mp4"; else if (container.contains("audio/mpeg")) - return "mpga"; + return "mp3"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7c1c4e09..7ebb70ff 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -81,7 +81,7 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(a.container, outputFileName) + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); 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 9d1a3faa..bc01b4a6 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,5 +1,11 @@ package com.futo.platformplayer.parsers +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist 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 @@ -7,13 +13,20 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.toYesNo import com.futo.platformplayer.yesNoToBoolean +import java.io.ByteArrayInputStream import java.net.URI +import java.net.URLConnection import java.time.ZonedDateTime import java.time.format.DateTimeFormatter class HLS { companion object { - fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { + @OptIn(UnstableApi::class) + fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String, isAudioSource: Boolean? = null): MasterPlaylist { + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser() + .parse(Uri.parse(sourceUrl), inputStream) + val baseUrl = URI(sourceUrl).resolve("./").toString() val variantPlaylists = mutableListOf() @@ -21,27 +34,39 @@ class HLS { val sessionDataList = mutableListOf() var independentSegments = false - masterPlaylistContent.lines().forEachIndexed { index, line -> - when { - line.startsWith("#EXT-X-STREAM-INF") -> { - val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) - ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") - val url = resolveUrl(baseUrl, nextLine) + if (playlist is HlsMediaPlaylist) { + independentSegments = playlist.hasIndependentSegments + if (isAudioSource == true) { + val firstSegmentUrlFile = + Uri.parse(playlist.segments[0].initializationSegment?.url ?: playlist.segments[0].url).buildUpon().clearQuery().fragment(null) + .build().toString() + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile))) + } else { + variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) + } + } else if (playlist is HlsMultivariantPlaylist) { + masterPlaylistContent.lines().forEachIndexed { index, line -> + when { + line.startsWith("#EXT-X-STREAM-INF") -> { + val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) + ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") + val url = resolveUrl(baseUrl, nextLine) - variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) - } + variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) + } - line.startsWith("#EXT-X-MEDIA") -> { - mediaRenditions.add(parseMediaRendition(line, baseUrl)) - } + line.startsWith("#EXT-X-MEDIA") -> { + mediaRenditions.add(parseMediaRendition(line, baseUrl)) + } - line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { - independentSegments = true - } + line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { + independentSegments = true + } - line.startsWith("#EXT-X-SESSION-DATA") -> { - val sessionData = parseSessionData(line) - sessionDataList.add(sessionData) + line.startsWith("#EXT-X-SESSION-DATA") -> { + val sessionData = parseSessionData(line) + sessionDataList.add(sessionData) + } } } } @@ -61,7 +86,13 @@ class HLS { val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } + val initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -109,10 +140,10 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String): List { + fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List { val masterPlaylist: MasterPlaylist try { - masterPlaylist = parseMasterPlaylist(content, url) + masterPlaylist = parseMasterPlaylist(content, url, isAudioSource) return masterPlaylist.getAudioSources() } catch (e: Throwable) { if (content.lines().any { it.startsWith("#EXTINF:") }) { @@ -270,7 +301,8 @@ class HLS { val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, - val isForced: Boolean? + val isForced: Boolean?, + val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -340,7 +372,7 @@ class HLS { 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) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", it.container ?: "", it.language ?: "", null, false, it.uri) else -> null } } @@ -376,7 +408,7 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") From 3d258180bd1588a659b3079b4f1b887cd5fd43fe Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 11 Feb 2025 10:31:47 -0600 Subject: [PATCH 006/298] restore hard code HLS as mp4 Changelog: changed --- .../com/futo/platformplayer/downloads/VideoDownload.kt | 6 +++--- .../java/com/futo/platformplayer/downloads/VideoExport.kt | 6 +++--- app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 8 ++------ 3 files changed, 8 insertions(+), 12 deletions(-) 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 011db8de..76528cd8 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -410,11 +410,11 @@ class VideoDownload { else audioSource; if(actualVideoSource != null) { - videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource!!.container)}".sanitizeFileName(); + videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource.container)}".sanitizeFileName(); videoFilePath = File(downloadDir, videoFileName!!).absolutePath; } if(actualAudioSource != null) { - audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(if (actualAudioSource.container == "application/vnd.apple.mpegurl") actualAudioSource.codec else actualAudioSource.container)}".sanitizeFileName(); + audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource.container)}".sanitizeFileName(); audioFilePath = File(downloadDir, audioFileName!!).absolutePath; } if(subtitleSource != null) { @@ -1149,7 +1149,7 @@ class VideoDownload { else if (container.contains("video/mp4")) return "mp4"; else if (container.contains("audio/mpeg")) - return "mp3"; + return "mpga"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7ebb70ff..6761168c 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -69,7 +69,7 @@ class VideoExport { outputFile = f; } else if (v != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); - val f = downloadRoot.createFile(v.container, outputFileName) + val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,8 +81,8 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) - ?: throw Exception("Failed to create file in external directory."); + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName) + ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); 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 bc01b4a6..916bc74c 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -37,10 +37,7 @@ class HLS { if (playlist is HlsMediaPlaylist) { independentSegments = playlist.hasIndependentSegments if (isAudioSource == true) { - val firstSegmentUrlFile = - Uri.parse(playlist.segments[0].initializationSegment?.url ?: playlist.segments[0].url).buildUpon().clearQuery().fragment(null) - .build().toString() - mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile))) + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null)) } else { variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) } @@ -302,7 +299,6 @@ class HLS { val isDefault: Boolean?, val isAutoSelect: Boolean?, val isForced: Boolean?, - val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -372,7 +368,7 @@ class HLS { 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.container ?: "", it.language ?: "", null, false, it.uri) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri) else -> null } } From 4bc561ceab6319ac42409aafa629664b9b595931 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 22:21:06 -0600 Subject: [PATCH 007/298] fix HLS audio download and download audio only Changelog: changed --- .../futo/platformplayer/UISlideOverlays.kt | 2 +- .../platformplayer/downloads/VideoDownload.kt | 12 ++- .../platformplayer/downloads/VideoExport.kt | 2 +- .../com/futo/platformplayer/parsers/HLS.kt | 78 +++++++++++++------ 4 files changed, 62 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index f1d42838..ff587dd5 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -292,7 +292,7 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl, source is IHLSManifestAudioSource) masterPlaylist.getAudioSources().forEach { it -> 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 cd4ae885..0445da00 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -630,10 +630,8 @@ class VideoDownload { 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 cmd = + "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -643,7 +641,6 @@ class VideoDownload { 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)) { @@ -651,7 +648,6 @@ class VideoDownload { } else { "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" } - fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, @@ -1160,8 +1156,10 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) return "mp4a"; + else if (container.contains("video/mp4")) + return "mp4"; else if (container.contains("audio/mpeg")) - return "mpga"; + return "mp3"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7c1c4e09..7ebb70ff 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -81,7 +81,7 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(a.container, outputFileName) + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); 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 9d1a3faa..bc01b4a6 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,5 +1,11 @@ package com.futo.platformplayer.parsers +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist 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 @@ -7,13 +13,20 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.toYesNo import com.futo.platformplayer.yesNoToBoolean +import java.io.ByteArrayInputStream import java.net.URI +import java.net.URLConnection import java.time.ZonedDateTime import java.time.format.DateTimeFormatter class HLS { companion object { - fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { + @OptIn(UnstableApi::class) + fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String, isAudioSource: Boolean? = null): MasterPlaylist { + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser() + .parse(Uri.parse(sourceUrl), inputStream) + val baseUrl = URI(sourceUrl).resolve("./").toString() val variantPlaylists = mutableListOf() @@ -21,27 +34,39 @@ class HLS { val sessionDataList = mutableListOf() var independentSegments = false - masterPlaylistContent.lines().forEachIndexed { index, line -> - when { - line.startsWith("#EXT-X-STREAM-INF") -> { - val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) - ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") - val url = resolveUrl(baseUrl, nextLine) + if (playlist is HlsMediaPlaylist) { + independentSegments = playlist.hasIndependentSegments + if (isAudioSource == true) { + val firstSegmentUrlFile = + Uri.parse(playlist.segments[0].initializationSegment?.url ?: playlist.segments[0].url).buildUpon().clearQuery().fragment(null) + .build().toString() + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile))) + } else { + variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) + } + } else if (playlist is HlsMultivariantPlaylist) { + masterPlaylistContent.lines().forEachIndexed { index, line -> + when { + line.startsWith("#EXT-X-STREAM-INF") -> { + val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) + ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") + val url = resolveUrl(baseUrl, nextLine) - variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) - } + variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) + } - line.startsWith("#EXT-X-MEDIA") -> { - mediaRenditions.add(parseMediaRendition(line, baseUrl)) - } + line.startsWith("#EXT-X-MEDIA") -> { + mediaRenditions.add(parseMediaRendition(line, baseUrl)) + } - line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { - independentSegments = true - } + line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { + independentSegments = true + } - line.startsWith("#EXT-X-SESSION-DATA") -> { - val sessionData = parseSessionData(line) - sessionDataList.add(sessionData) + line.startsWith("#EXT-X-SESSION-DATA") -> { + val sessionData = parseSessionData(line) + sessionDataList.add(sessionData) + } } } } @@ -61,7 +86,13 @@ class HLS { val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } + val initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -109,10 +140,10 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String): List { + fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List { val masterPlaylist: MasterPlaylist try { - masterPlaylist = parseMasterPlaylist(content, url) + masterPlaylist = parseMasterPlaylist(content, url, isAudioSource) return masterPlaylist.getAudioSources() } catch (e: Throwable) { if (content.lines().any { it.startsWith("#EXTINF:") }) { @@ -270,7 +301,8 @@ class HLS { val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, - val isForced: Boolean? + val isForced: Boolean?, + val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -340,7 +372,7 @@ class HLS { 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) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", it.container ?: "", it.language ?: "", null, false, it.uri) else -> null } } @@ -376,7 +408,7 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") From ca781dfe153d3b8ed11b2d3d86c0f5c282442b67 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 11 Feb 2025 10:31:47 -0600 Subject: [PATCH 008/298] restore hard code HLS as mp4 Changelog: changed --- .../com/futo/platformplayer/downloads/VideoDownload.kt | 2 +- .../java/com/futo/platformplayer/downloads/VideoExport.kt | 6 +++--- app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 8 ++------ 3 files changed, 6 insertions(+), 10 deletions(-) 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 0445da00..f845cf92 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -1159,7 +1159,7 @@ class VideoDownload { else if (container.contains("video/mp4")) return "mp4"; else if (container.contains("audio/mpeg")) - return "mp3"; + return "mpga"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7ebb70ff..6761168c 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -69,7 +69,7 @@ class VideoExport { outputFile = f; } else if (v != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); - val f = downloadRoot.createFile(v.container, outputFileName) + val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,8 +81,8 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) - ?: throw Exception("Failed to create file in external directory."); + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName) + ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); 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 bc01b4a6..916bc74c 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -37,10 +37,7 @@ class HLS { if (playlist is HlsMediaPlaylist) { independentSegments = playlist.hasIndependentSegments if (isAudioSource == true) { - val firstSegmentUrlFile = - Uri.parse(playlist.segments[0].initializationSegment?.url ?: playlist.segments[0].url).buildUpon().clearQuery().fragment(null) - .build().toString() - mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile))) + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null)) } else { variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) } @@ -302,7 +299,6 @@ class HLS { val isDefault: Boolean?, val isAutoSelect: Boolean?, val isForced: Boolean?, - val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -372,7 +368,7 @@ class HLS { 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.container ?: "", it.language ?: "", null, false, it.uri) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri) else -> null } } From 3cd4b4503f0fc89074d5281bc643c9bcd96d77f9 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 19 Feb 2025 11:59:59 -0600 Subject: [PATCH 009/298] fix other cookie handling Changelog: changed --- .../api/media/platforms/js/internal/JSHttpClient.kt | 8 +++++++- .../futo/platformplayer/engine/packages/PackageHttp.kt | 7 ++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index da10e2ec..6f835304 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt @@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient { } if(doApplyCookies) { - if (_currentCookieMap.isNotEmpty()) { + if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) { val cookiesToApply = hashMapOf(); synchronized(_currentCookieMap) { for(cookie in _currentCookieMap @@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient { .flatMap { it.value.toList() }) cookiesToApply[cookie.first] = cookie.second; }; + synchronized(_otherCookieMap) { + for(cookie in _otherCookieMap + .filter { domain.matchesDomain(it.key) } + .flatMap { it.value.toList() }) + cookiesToApply[cookie.first] = cookie.second; + } if(cookiesToApply.size > 0) { val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt index 686318a1..6f131b4d 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -126,9 +126,9 @@ class PackageHttp: V8Package { @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.GET(url, headers, useByteResponse) else - _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + _packageClient.GET(url, headers, useByteResponse); } @V8Function fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { @@ -385,7 +385,8 @@ class PackageHttp: V8Package { } @V8Function - fun GET(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + fun GET(url: String, headers: MutableMap = HashMap(), useByteResponse: Boolean = false) : IBridgeHttpResponse { + val returnType: ReturnType = if(useByteResponse) ReturnType.BYTES else ReturnType.STRING applyDefaultHeaders(headers); return logExceptions { catchHttp { From 7ffa6b1bb318d30ca42ad37771ba9289d057a97c Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 19 Feb 2025 12:32:17 -0600 Subject: [PATCH 010/298] revert params Changelog: changed --- .../com/futo/platformplayer/engine/packages/PackageHttp.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt index 6f131b4d..686318a1 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -126,9 +126,9 @@ class PackageHttp: V8Package { @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GET(url, headers, useByteResponse) + _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.GET(url, headers, useByteResponse); + _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { @@ -385,8 +385,7 @@ class PackageHttp: V8Package { } @V8Function - fun GET(url: String, headers: MutableMap = HashMap(), useByteResponse: Boolean = false) : IBridgeHttpResponse { - val returnType: ReturnType = if(useByteResponse) ReturnType.BYTES else ReturnType.STRING + fun GET(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { From 9014fb581dc780732f606f4723f0dfae86234f5d Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 20 Feb 2025 16:07:35 -0600 Subject: [PATCH 011/298] add support for downloading encrypted HLS streams Changelog: changed --- .../platformplayer/downloads/VideoDownload.kt | 63 +++++++++++++++++-- .../com/futo/platformplayer/parsers/HLS.kt | 30 ++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) 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 ede24707..10d48130 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBytesSpeed +import com.futo.polycentric.core.hexStringToByteArray import hasAnySource import isDownloadable import kotlinx.coroutines.CancellationException @@ -59,6 +60,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.Contextual import kotlinx.serialization.Transient +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -69,6 +71,9 @@ import java.util.concurrent.Executors import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinTask import java.util.concurrent.ThreadLocalRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec import kotlin.coroutines.resumeWithException import kotlin.time.times @@ -564,6 +569,14 @@ class VideoDownload { } } + private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val secretKey = SecretKeySpec(key, "AES") + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec) + return cipher.doFinal(encryptedSegment) + } + 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(); @@ -579,6 +592,14 @@ class VideoDownload { ?: throw Exception("Variant playlist content is empty") val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) + val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { + val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl) + check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" } + DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv.hexStringToByteArray()) + } else { + null + } + variantPlaylist.segments.forEachIndexed { index, segment -> if (segment !is HLS.MediaSegment) { return@forEachIndexed @@ -590,7 +611,7 @@ class VideoDownload { try { segmentFiles.add(segmentFile) - val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed -> + val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo) { 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) @@ -771,7 +792,7 @@ class VideoDownload { else { Logger.i(TAG, "Download $name Sequential"); try { - sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); + sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress); } catch (e: Throwable) { Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") throw e @@ -798,7 +819,31 @@ class VideoDownload { } return sourceLength!!; } - private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long { + + data class DecryptionInfo( + val key: ByteArray, + val iv: ByteArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DecryptionInfo + + if (!key.contentEquals(other.key)) return false + if (!iv.contentEquals(other.iv)) return false + + return true + } + + override fun hashCode(): Int { + var result = key.contentHashCode() + result = 31 * result + iv.contentHashCode() + return result + } + } + + private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, onProgress: (Long, Long, Long) -> Unit): Long { val progressRate: Int = 4096 * 5; var lastProgressCount: Int = 0; val speedRate: Int = 4096 * 5; @@ -818,6 +863,8 @@ class VideoDownload { val sourceLength = result.body.contentLength(); val sourceStream = result.body.byteStream(); + val segmentBuffer = ByteArrayOutputStream() + var totalRead: Long = 0; try { var read: Int; @@ -828,7 +875,7 @@ class VideoDownload { if (read < 0) break; - fileStream.write(buffer, 0, read); + segmentBuffer.write(buffer, 0, read); totalRead += read; @@ -854,6 +901,14 @@ class VideoDownload { result.body.close() } + if (decryptionInfo != null) { + val decryptedData = + decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, decryptionInfo.iv) + fileStream.write(decryptedData) + } else { + fileStream.write(segmentBuffer.toByteArray()) + } + onProgress(sourceLength, totalRead, 0); return sourceLength; } 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 9d1a3faa..1faee408 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -61,7 +61,27 @@ class HLS { val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } + val keyInfo = + lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",") + + val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"') + val iv = + keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x") + + val decryptionInfo: DecryptionInfo? = key?.let { k -> + iv?.let { i -> + DecryptionInfo(k, i) + } + } + + val initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } + var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -86,7 +106,7 @@ class HLS { } } - return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo) } fun parseAndGetVideoSources(source: Any, content: String, url: String): List { @@ -368,6 +388,11 @@ class HLS { } } + data class DecryptionInfo( + val keyUrl: String, + val iv: String + ) + data class VariantPlaylist( val version: Int?, val targetDuration: Int?, @@ -376,7 +401,8 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, + val decryptionInfo: DecryptionInfo? = null ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") From 470b7bd2e5e68d5c7ef78c3b3c00440cc908f4a6 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 20 Feb 2025 21:27:44 -0600 Subject: [PATCH 012/298] add non iv version Changelog: added --- .../platformplayer/downloads/VideoDownload.kt | 34 +++++++++++-------- .../com/futo/platformplayer/parsers/HLS.kt | 6 ++-- 2 files changed, 21 insertions(+), 19 deletions(-) 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 10d48130..903671b9 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -65,6 +65,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.lang.Thread.sleep +import java.nio.ByteBuffer import java.time.OffsetDateTime import java.util.UUID import java.util.concurrent.Executors @@ -593,9 +594,9 @@ class VideoDownload { val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { - val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl) + val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl, mutableMapOf("Cookie" to "sails.sid=s%3AeSKom53v4W3_0CliWJFFMj9k3hcAuyhx.Nf9lF1sUSQ0GUvCKBOM64bsV%2BZMOkiKke43eHO6gTZI;")) check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" } - DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv.hexStringToByteArray()) + DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray()) } else { null } @@ -611,7 +612,7 @@ class VideoDownload { try { segmentFiles.add(segmentFile) - val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo) { segmentLength, totalRead, lastSpeed -> + val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { 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) @@ -651,10 +652,8 @@ class VideoDownload { 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 cmd = + "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -664,7 +663,6 @@ class VideoDownload { 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)) { @@ -672,7 +670,6 @@ class VideoDownload { } else { "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" } - fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, @@ -792,7 +789,7 @@ class VideoDownload { else { Logger.i(TAG, "Download $name Sequential"); try { - sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress); + sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress); } catch (e: Throwable) { Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") throw e @@ -822,7 +819,7 @@ class VideoDownload { data class DecryptionInfo( val key: ByteArray, - val iv: ByteArray + val iv: ByteArray? ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -843,7 +840,7 @@ class VideoDownload { } } - private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, onProgress: (Long, Long, Long) -> Unit): Long { + private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long { val progressRate: Int = 4096 * 5; var lastProgressCount: Int = 0; val speedRate: Int = 4096 * 5; @@ -902,8 +899,15 @@ class VideoDownload { } if (decryptionInfo != null) { - val decryptedData = - decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, decryptionInfo.iv) + var iv = decryptionInfo.iv + if (iv == null) { + iv = ByteBuffer.allocate(16) + .putLong(0L) + .putLong(index.toLong()) + .array() + } + + val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!) fileStream.write(decryptedData) } else { fileStream.write(segmentBuffer.toByteArray()) @@ -1222,7 +1226,7 @@ class VideoDownload { else if (container.contains("audio/webm")) return "webm"; else if (container == "application/vnd.apple.mpegurl") - return "mp4a"; + return "mp4"; else return "audio";// throw IllegalStateException("Unknown container: " + container) } 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 1faee408..3d1ce821 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -69,9 +69,7 @@ class HLS { keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x") val decryptionInfo: DecryptionInfo? = key?.let { k -> - iv?.let { i -> - DecryptionInfo(k, i) - } + DecryptionInfo(k, iv) } val initSegment = @@ -390,7 +388,7 @@ class HLS { data class DecryptionInfo( val keyUrl: String, - val iv: String + val iv: String? ) data class VariantPlaylist( From f63f9dd6db52f02d0efc44e1c952c3cebc3f14a5 Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 7 Mar 2025 14:27:18 -0600 Subject: [PATCH 013/298] initial POC shorts tab Changelog: added --- app/build.gradle | 11 + .../platformplayer/activities/MainActivity.kt | 8 +- .../bottombar/MenuBottomBarFragment.kt | 15 +- .../fragment/mainactivity/main/ShortView.kt | 427 ++++++++++++++++++ .../mainactivity/main/ShortsFragment.kt | 96 ++++ .../futo/platformplayer/states/StatePlayer.kt | 11 + .../views/video/FutoShortPlayer.kt | 179 ++++++++ .../views/video/FutoVideoPlayerBase.kt | 3 +- app/src/main/res/layout/activity_main.xml | 7 +- .../layout/fragment_overview_bottom_bar.xml | 2 +- app/src/main/res/layout/fragment_shorts.xml | 6 + app/src/main/res/layout/modal_comments.xml | 231 ++++++++++ app/src/main/res/layout/view_short.xml | 43 ++ app/src/main/res/layout/view_short_player.xml | 31 ++ app/src/main/res/values/colors.xml | 143 ++++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/themes.xml | 36 ++ 17 files changed, 1235 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt create mode 100644 app/src/main/res/layout/fragment_shorts.xml create mode 100644 app/src/main/res/layout/modal_comments.xml create mode 100644 app/src/main/res/layout/view_short.xml create mode 100644 app/src/main/res/layout/view_short_player.xml diff --git a/app/build.gradle b/app/build.gradle index 8d55d000..5f38c749 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,6 +143,10 @@ android { } buildFeatures { buildConfig true + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" } sourceSets { main { @@ -215,6 +219,7 @@ dependencies { //Database implementation("androidx.room:room-runtime:2.6.1") annotationProcessor("androidx.room:room-compiler:2.6.1") + debugImplementation 'androidx.compose.ui:ui-tooling:1.7.8' ksp("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.6.1") @@ -228,4 +233,10 @@ dependencies { testImplementation "org.mockito:mockito-core:5.4.0" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + + //Compose + def composeBom = platform('androidx.compose:compose-bom:2025.02.00') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material3:material3' } 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 25febdb1..647cf6d8 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1,13 +1,11 @@ package com.futo.platformplayer.activities import android.annotation.SuppressLint -import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager import android.content.res.Configuration -import android.media.AudioManager import android.net.Uri import android.os.Bundle import android.os.StrictMode @@ -57,6 +55,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment +import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment @@ -74,7 +73,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.UrlVideoWithTime -import com.futo.platformplayer.receivers.MediaButtonReceiver import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup @@ -161,6 +159,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; + lateinit var _fragShorts: ShortsFragment; lateinit var _fragSourceDetail: SourceDetailFragment; lateinit var _fragDownloads: DownloadsFragment; lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; @@ -315,6 +314,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragPostDetail = PostDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); + _fragShorts = ShortsFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); _fragDownloads = DownloadsFragment(); _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); @@ -1088,6 +1088,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (segment.isMainView) { var transaction = supportFragmentManager.beginTransaction(); + transaction.setReorderingAllowed(true) if (segment.topBar != null) { if (segment.topBar != fragCurrent.topBar) { transaction = transaction @@ -1188,6 +1189,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { PostDetailFragment::class -> _fragPostDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; + ShortsFragment::class -> _fragShorts as T; SourceDetailFragment::class -> _fragSourceDetail as T; DownloadsFragment::class -> _fragDownloads as T; ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; 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 c4afcf74..2b7c055a 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 @@ -386,16 +386,17 @@ class MenuBottomBarFragment : MainActivityFragment() { it.navigate() } }), - ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate() }), - ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate() }), - ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate() }), - 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(1, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment }, { it.navigate() }), + ButtonDefinition(2, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate() }), + ButtonDefinition(3, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate() }), + ButtonDefinition(4, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate() }), + ButtonDefinition(5, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate() }), + ButtonDefinition(6, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }), + ButtonDefinition(7, 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(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate() }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate() }), - ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { + ButtonDefinition(11, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { val c = it.context ?: return@ButtonDefinition; Logger.i(TAG, "settings preventPictureInPicture()"); it.requireFragment().preventPictureInPicture(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt new file mode 100644 index 00000000..bba0dcee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -0,0 +1,427 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.app.Dialog +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Animatable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.SoundEffectConstants +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.outlined.ThumbUp +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RippleConfiguration +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +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.platforms.js.SourcePluginConfig +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.engine.exceptions.ScriptAgeException +import com.futo.platformplayer.engine.exceptions.ScriptException +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.views.video.FutoShortPlayer +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.OffsetDateTime +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalMaterial3Api::class) +@UnstableApi +class ShortView : ConstraintLayout { + private var mainFragment: MainFragment? = null + private val player: FutoShortPlayer + private val overlayLoading: FrameLayout + private val overlayLoadingSpinner: ImageView + + private var url: String? = null + private var video: IPlatformVideo? = null + private var videoDetails: IPlatformVideoDetails? = null + + private var playWhenReady = false + + private var _lastVideoSource: IVideoSource? = null + private var _lastAudioSource: IAudioSource? = null + private var _lastSubtitleSource: ISubtitleSource? = null + + private var loadVideoJob: Job? = null + + private val bottomSheet: ModalBottomSheet = ModalBottomSheet() + + // Required constructor for XML inflation + constructor(context: Context) : super(context) { + inflate(context, R.layout.view_short, this) + + player = findViewById(R.id.short_player) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + setupComposeView() + } + + // Required constructor for XML inflation with attributes + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + inflate(context, R.layout.view_short, this) + + player = findViewById(R.id.short_player) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + setupComposeView() + } + + // Required constructor for XML inflation with attributes and style + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + inflate(context, R.layout.view_short, this) + + player = findViewById(R.id.short_player) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + setupComposeView() + } + + constructor(inflater: LayoutInflater, fragment: MainFragment) : super(inflater.context) { + this.mainFragment = fragment + + inflater.inflate(R.layout.view_short, this, true) + player = findViewById(R.id.short_player) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT + ) + } + + private fun setupComposeView () { + val composeView: ComposeView = findViewById(R.id.compose_view_test_button) + composeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + // In Compose world + MaterialTheme { + var checked by remember { mutableStateOf(false) } + + val tint = Color.White + + val alpha = 0.2f + val rippleConfiguration = + RippleConfiguration(color = tint, rippleAlpha = RippleAlpha(alpha, alpha, alpha, alpha)) + + val view = LocalView.current + + CompositionLocalProvider(LocalRippleConfiguration provides rippleConfiguration) { + IconToggleButton( + checked = checked, + onCheckedChange = { + checked = it + view.playSoundEffect(SoundEffectConstants.CLICK) + }, + ) { + if (checked) { + Icon( + Icons.Filled.ThumbUp, contentDescription = "Liked", tint = tint, + ) + } else { + Icon( + Icons.Outlined.ThumbUp, contentDescription = "Not Liked", tint = tint, + ) + } + } + } + } + } + } + } + + fun setMainFragment(fragment: MainFragment) { + this.mainFragment = fragment + } + + fun setVideo(url: String) { + if (url == this.url) { + return + } + + loadVideo(url) + } + + fun setVideo(video: IPlatformVideo) { + if (url == video.url) { + return + } + this.video = video + + loadVideo(video.url) + } + + fun setVideo(videoDetails: IPlatformVideoDetails) { + if (url == videoDetails.url) { + return + } + + this.videoDetails = videoDetails + } + + fun play() { + player.attach() + playVideo() + } + + fun stop() { + playWhenReady = false + + player.clear() + player.detach() + } + + fun detach() { + loadVideoJob?.cancel() + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = View.VISIBLE + } else { + overlayLoading.visibility = View.GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + } + + private fun loadVideo(url: String) { + loadVideoJob?.cancel() + + loadVideoJob = CoroutineScope(Dispatchers.Main).launch { + setLoading(true) + _lastVideoSource = null + _lastAudioSource = null + _lastSubtitleSource = null + + val result = try { + withContext(StateApp.instance.scope.coroutineContext) { + StatePlatform.instance.getContentDetails(url).await() + } + } catch (e: CancellationException) { + return@launch + } catch (e: NoPlatformClientException) { + Logger.w(TAG, "exception", e) + + UIDialogs.showDialog( + context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action( + "Close", { }, UIDialogs.ActionStyle.PRIMARY + ) + ) + return@launch + } catch (e: ScriptLoginRequiredException) { + Logger.w(TAG, "exception", e) + UIDialogs.showDialog(context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", { + val id = e.config.let { if (it is SourcePluginConfig) it.id else null } + val didLogin = + if (id == null) false else StatePlugins.instance.loginPlugin(context, id) { + loadVideo(url) + } + if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login") + }, UIDialogs.ActionStyle.PRIMARY) + ) + return@launch + } catch (e: ContentNotAvailableYetException) { + Logger.w(TAG, "exception", e) + UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${e.availableWhen}.", "Close") { } + return@launch + } catch (e: ScriptImplementationException) { + Logger.w(TAG, "exception", e) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), e, { loadVideo(url) }, null, mainFragment) + return@launch + } catch (e: ScriptAgeException) { + Logger.w(TAG, "exception", e) + UIDialogs.showDialog( + context, R.drawable.ic_lock, "Age restricted video", e.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + return@launch + } catch (e: ScriptUnavailableException) { + Logger.w(TAG, "exception", e) + if (video?.datetime == null || video?.datetime!! < OffsetDateTime.now() + .minusHours(1) + ) { + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + } + + video?.let { StatePlatform.instance.clearContentDetailCache(it.url) } + return@launch + } catch (e: ScriptException) { + Logger.w(TAG, "exception", e) + + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), e, { loadVideo(url) }, null, mainFragment) + return@launch + } catch (e: Throwable) { + Logger.w(ChannelFragment.TAG, "Failed to load video.", e) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), e, { loadVideo(url) }, null, mainFragment) + return@launch + } + + if (result !is IPlatformVideoDetails) { + Logger.w( + TAG, "Wrong content type", IllegalStateException("Expected media content, found ${result.contentType}") + ) + return@launch + } + + // if it's been canceled then don't set the video details + if (!isActive) { + return@launch + } + + videoDetails = result + video = result + + setLoading(false) + + if (playWhenReady) playVideo() + } + } + + private fun playVideo(resumePositionMs: Long = 0) { + val video = videoDetails + + if (video === null) { + playWhenReady = true + return + } + + bottomSheet.show(mainFragment!!.childFragmentManager, ModalBottomSheet.TAG) + + try { + val videoSource = _lastVideoSource + ?: player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) + val audioSource = _lastAudioSource + ?: player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)) + val subtitleSource = _lastSubtitleSource + ?: (if (video is VideoLocal) video.subtitlesSources.firstOrNull() else null) + Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") + + if (videoSource == null && audioSource == null) { + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + StatePlatform.instance.clearContentDetailCache(video.url) + return + } + + val thumbnail = video.thumbnails.getHQThumbnail() + if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() + .load(thumbnail).into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + player.setArtwork(BitmapDrawable(resources, resource)) + } + + override fun onLoadCleared(placeholder: Drawable?) { + player.setArtwork(null) + } + }) + else player.setArtwork(null) + player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) + if (subtitleSource != null) player.swapSubtitles(mainFragment!!.lifecycleScope, subtitleSource) + player.seekTo(resumePositionMs) + + _lastVideoSource = videoSource + _lastAudioSource = audioSource + _lastSubtitleSource = subtitleSource + } catch (ex: UnsupportedCastException) { + Logger.e(TAG, "Failed to load cast media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex) + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to load media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex) + } + } + + companion object { + const val TAG = "VideoDetailView" + } + + class ModalBottomSheet : BottomSheetDialogFragment() { + override fun onCreateDialog( + savedInstanceState: Bundle?, + ): Dialog { + val bottomSheetDialog = BottomSheetDialog( + requireContext() + ) + bottomSheetDialog.setContentView(R.layout.modal_comments) + + val composeView = bottomSheetDialog.findViewById(R.id.compose_view) + + composeView?.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + // In Compose world + MaterialTheme { + val view = LocalView.current + IconButton(onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + }) { + Icon( + Icons.Outlined.ThumbUp, contentDescription = "Close Bottom Sheet" + ) + } + } + } + } + return bottomSheetDialog + } + + companion object { + const val TAG = "ModalBottomSheet" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt new file mode 100644 index 00000000..b11a30a4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -0,0 +1,96 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.core.view.get +import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.futo.platformplayer.R + +@UnstableApi +class ShortsFragment : MainFragment() { + override val isMainView: Boolean = true + override val isTab: Boolean = true + override val hasBottomBar: Boolean get() = true + + private var previousShownView: ShortView? = null + + private lateinit var viewPager: ViewPager2 + private lateinit var customViewAdapter: CustomViewAdapter + private val urls = listOf( + "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra", "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra" + ) + + override fun onCreateMainView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_shorts, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewPager = view.findViewById(R.id.viewPager) + + customViewAdapter = CustomViewAdapter(urls, layoutInflater, this) + viewPager.adapter = customViewAdapter + + // TODO something is laggy sometimes when swiping between videos + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + @OptIn(UnstableApi::class) + override fun onPageSelected(position: Int) { + previousShownView?.stop() + + val focusedView = + ((viewPager[0] as RecyclerView).findViewHolderForAdapterPosition(position) as CustomViewHolder).shortView + focusedView.play() + + + previousShownView = focusedView + } + + }) + + } + + override fun onPause() { + super.onPause() + previousShownView?.stop() + } + + companion object { + private const val TAG = "ShortsFragment" + + fun newInstance() = ShortsFragment() + } + + class CustomViewAdapter( + private val urls: List, private val inflater: LayoutInflater, private val fragment: MainFragment + ) : RecyclerView.Adapter() { + @OptIn(UnstableApi::class) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { + val shortView = ShortView(inflater, fragment) + return CustomViewHolder(shortView) + } + + @OptIn(UnstableApi::class) + override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + holder.shortView.setVideo(urls[position]) + } + + @OptIn(UnstableApi::class) + override fun onViewRecycled(holder: CustomViewHolder) { + super.onViewRecycled(holder) + holder.shortView.detach() + } + + override fun getItemCount(): Int = urls.size + } + + @OptIn(UnstableApi::class) + class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index b8368ea5..b12889e8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -38,6 +38,7 @@ class StatePlayer { //Players private var _exoplayer : PlayerManager? = null; private var _thumbnailExoPlayer : PlayerManager? = null; + private var _shortExoPlayer: PlayerManager? = null //Video Status var rotationLock: Boolean = false @@ -633,6 +634,13 @@ class StatePlayer { } return _thumbnailExoPlayer!!; } + fun getShortPlayerOrCreate(context: Context) : PlayerManager { + if(_shortExoPlayer == null) { + val player = createExoPlayer(context); + _shortExoPlayer = PlayerManager(player); + } + return _shortExoPlayer!!; + } @OptIn(UnstableApi::class) private fun createExoPlayer(context : Context): ExoPlayer { @@ -656,10 +664,13 @@ class StatePlayer { fun dispose(){ val player = _exoplayer; val thumbPlayer = _thumbnailExoPlayer; + val shortPlayer = _shortExoPlayer _exoplayer = null; _thumbnailExoPlayer = null; + _shortExoPlayer = null player?.release(); thumbPlayer?.release(); + shortPlayer?.release() } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt new file mode 100644 index 00000000..9c2b003f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -0,0 +1,179 @@ +package com.futo.platformplayer.views.video + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.LinearInterpolator +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView +import androidx.media3.ui.TimeBar +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.states.StatePlayer + +@UnstableApi +class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : + FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) { + + companion object { + private const val TAG = "FutoShortVideoPlayer" + private const val PLAYER_STATE_NAME: String = "ShortPlayer" + } + + private var playerAttached = false + + private val videoView: PlayerView + private val progressBar: DefaultTimeBar + + private val loadArtwork = object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + setArtwork(BitmapDrawable(resources, resource)) + } + + override fun onLoadCleared(placeholder: Drawable?) { + setArtwork(null) + } + } + + private val player = StatePlayer.instance.getShortPlayerOrCreate(context) + + private var progressAnimator: ValueAnimator = createProgressBarAnimator() + + private var playerEventListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny( + Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED + ) + ) { + if (player.duration >= 0) { + progressAnimator.duration = player.duration + setProgressBarDuration(player.duration) + progressAnimator.currentPlayTime = player.currentPosition + } + + if (player.isPlaying) { + if (!progressAnimator.isStarted) { + progressAnimator.start() + } + } else { + if (progressAnimator.isRunning) { + progressAnimator.cancel() + } + } + } + } + } + + init { + LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true) + videoView = findViewById(R.id.video_player) + progressBar = findViewById(R.id.video_player_progress_bar) + + progressBar.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + if (progressAnimator.isRunning) { + progressAnimator.cancel() + } + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) {} + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) { + progressAnimator.currentPlayTime = player.player.currentPosition + progressAnimator.start() + return + } + + // the progress bar should never be available to the user without the player being attached to this view + assert(playerAttached) + seekTo(position) + } + }) + } + + @OptIn(UnstableApi::class) + private fun createProgressBarAnimator(): ValueAnimator { + return ValueAnimator.ofFloat(0f, 1f).apply { + interpolator = LinearInterpolator() + + addUpdateListener { animation -> + val progress = animation.animatedValue as Float + val duration = animation.duration + progressBar.setPosition((progress * duration).toLong()) + } + } + } + + fun setProgressBarDuration(duration: Long) { + progressBar.setDuration(duration) + } + + /** + * Attaches this short player instance to the exo player instance for shorts + */ + fun attach() { + // connect the exo player for shorts to the view for this instance + player.attach(videoView, PLAYER_STATE_NAME) + + // direct the base player what exo player instance to use + changePlayer(player) + + playerAttached = true + + player.player.addListener(playerEventListener) + } + + fun detach() { + playerAttached = false + player.player.removeListener(playerEventListener) + player.detach() + } + + fun setPreview(video: IPlatformVideoDetails) { + if (video.live != null) { + setSource(video.live, null, play = true, keepSubtitles = false) + } else { + val videoSource = + VideoHelper.selectBestVideoSource(video.video, Settings.instance.playback.getPreferredPreviewQualityPixelCount(), PREFERED_VIDEO_CONTAINERS) + val audioSource = + VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context)) + if (videoSource == null && audioSource != null) { + val thumbnail = video.thumbnails.getHQThumbnail() + if (!thumbnail.isNullOrBlank()) { + Glide.with(videoView).asBitmap().load(thumbnail).into(loadArtwork) + } else { + Glide.with(videoView).clear(loadArtwork) + setArtwork(null) + } + } else { + Glide.with(videoView).clear(loadArtwork) + } + + setSource(videoSource, audioSource, play = true, keepSubtitles = false) + } + } + + @OptIn(UnstableApi::class) + fun setArtwork(drawable: Drawable?) { + if (drawable != null) { + videoView.defaultArtwork = drawable + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL + } else { + videoView.defaultArtwork = null + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF + } + } +} 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 c872ca02..c3eddb12 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 @@ -5,6 +5,7 @@ import android.net.Uri import android.util.AttributeSet import android.widget.RelativeLayout import androidx.annotation.OptIn +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.media3.common.C @@ -68,7 +69,7 @@ import java.io.ByteArrayInputStream import java.io.File import kotlin.math.abs -abstract class FutoVideoPlayerBase : RelativeLayout { +abstract class FutoVideoPlayerBase : ConstraintLayout { private val TAG = "FutoVideoPlayerBase" private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1708eeb4..df3abc69 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,10 @@ - - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_overview_bottom_bar.xml b/app/src/main/res/layout/fragment_overview_bottom_bar.xml index 11c33b2c..30b6b752 100644 --- a/app/src/main/res/layout/fragment_overview_bottom_bar.xml +++ b/app/src/main/res/layout/fragment_overview_bottom_bar.xml @@ -38,7 +38,7 @@ + diff --git a/app/src/main/res/layout/modal_comments.xml b/app/src/main/res/layout/modal_comments.xml new file mode 100644 index 00000000..e079a671 --- /dev/null +++ b/app/src/main/res/layout/modal_comments.xml @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_short.xml b/app/src/main/res/layout/view_short.xml new file mode 100644 index 00000000..8ec7a845 --- /dev/null +++ b/app/src/main/res/layout/view_short.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_short_player.xml b/app/src/main/res/layout/view_short_player.xml new file mode 100644 index 00000000..bfd8762d --- /dev/null +++ b/app/src/main/res/layout/view_short_player.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6d142d08..603193fb 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -38,4 +38,147 @@ #B3000000 #ACACAC #C25353 + + + #8F4C38 + #FFFFFF + #FFDBD1 + #723523 + #77574E + #FFFFFF + #FFDBD1 + #5D4037 + #6C5D2F + #FFFFFF + #F5E1A7 + #534619 + #BA1A1A + #FFFFFF + #FFDAD6 + #93000A + #FFF8F6 + #231917 + #FFF8F6 + #231917 + #F5DED8 + #53433F + #85736E + #D8C2BC + #000000 + #392E2B + #FFEDE8 + #FFB5A0 + #FFDBD1 + #3A0B01 + #FFB5A0 + #723523 + #FFDBD1 + #2C150F + #E7BDB2 + #5D4037 + #F5E1A7 + #231B00 + #D8C58D + #534619 + #E8D6D2 + #FFF8F6 + #FFFFFF + #FFF1ED + #FCEAE5 + #F7E4E0 + #F1DFDA + #5D2514 + #FFFFFF + #A15A45 + #FFFFFF + #4B2F28 + #FFFFFF + #87655C + #FFFFFF + #41350A + #FFFFFF + #7B6C3C + #FFFFFF + #740006 + #FFFFFF + #CF2C27 + #FFFFFF + #FFF8F6 + #231917 + #FFF8F6 + #180F0D + #F5DED8 + #41332F + #5F4F4A + #7B6964 + #000000 + #392E2B + #FFEDE8 + #FFB5A0 + #A15A45 + #FFFFFF + #84422F + #FFFFFF + #87655C + #FFFFFF + #6D4D45 + #FFFFFF + #7B6C3C + #FFFFFF + #615426 + #FFFFFF + #D4C3BE + #FFF8F6 + #FFFFFF + #FFF1ED + #F7E4E0 + #EBD9D4 + #DFCEC9 + #501B0B + #FFFFFF + #753725 + #FFFFFF + #3F261E + #FFFFFF + #60423A + #FFFFFF + #362B02 + #FFFFFF + #55481C + #FFFFFF + #600004 + #FFFFFF + #98000A + #FFFFFF + #FFF8F6 + #231917 + #FFF8F6 + #000000 + #F5DED8 + #000000 + #372925 + #554641 + #000000 + #392E2B + #FFFFFF + #FFB5A0 + #753725 + #FFFFFF + #592111 + #FFFFFF + #60423A + #FFFFFF + #472C24 + #FFFFFF + #55481C + #FFFFFF + #3D3206 + #FFFFFF + #C6B5B1 + #FFF8F6 + #FFFFFF + #FFEDE8 + #F1DFDA + #E2D1CC + #D4C3BE \ 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 d4df1905..2285becf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Failed to retrieve data, are you connected? Settings History + Shorts Sources Buy FAQ diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 8fa8f2d1..3f2b80c5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -49,6 +49,42 @@ @style/Theme.FutoVideo.ListView @style/Theme.FutoVideo.TextView @style/Theme.FutoVideo.CheckedTextView + + + @color/md_theme_inversePrimary + @color/md_theme_primaryContainer + @color/md_theme_onPrimaryContainer + @color/md_theme_secondaryContainer + @color/md_theme_onSecondaryContainer + @color/md_theme_tertiary + @color/md_theme_onTertiary + @color/md_theme_tertiaryContainer + @color/md_theme_onTertiaryContainer + @color/md_theme_surfaceVariant + @color/md_theme_onSurfaceVariant + @color/md_theme_inverseSurface + @color/md_theme_inverseOnSurface + @color/md_theme_outline + @color/md_theme_errorContainer + @color/md_theme_onErrorContainer + @style/TextAppearance.Material3.DisplayLarge + @style/TextAppearance.Material3.DisplayMedium + @style/TextAppearance.Material3.DisplaySmall + @style/TextAppearance.Material3.HeadlineLarge + @style/TextAppearance.Material3.HeadlineMedium + @style/TextAppearance.Material3.HeadlineSmall + @style/TextAppearance.Material3.TitleLarge + @style/TextAppearance.Material3.TitleMedium + @style/TextAppearance.Material3.TitleSmall + @style/TextAppearance.Material3.BodyLarge + @style/TextAppearance.Material3.BodyMedium + @style/TextAppearance.Material3.BodySmall + @style/TextAppearance.Material3.LabelLarge + @style/TextAppearance.Material3.LabelMedium + @style/TextAppearance.Material3.LabelSmall + @style/ShapeAppearance.Material3.SmallComponent + @style/ShapeAppearance.Material3.MediumComponent + @style/ShapeAppearance.Material3.LargeComponent + + + + + \ No newline at end of file From 7d64003d1c97669dceb885847fd11ea27c78d56b Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 4 Apr 2025 00:37:26 +0200 Subject: [PATCH 016/298] Feed filter loading improved, home filters support, various peripheral stuff --- .../java/com/futo/platformplayer/Settings.kt | 5 +- .../futo/platformplayer/UISlideOverlays.kt | 27 ++-- .../media/models/contents/IPlatformContent.kt | 1 + .../models/video/SerializedPlatformVideo.kt | 2 + .../api/media/structures/IRefreshPager.kt | 2 +- .../api/media/structures/ReusablePager.kt | 122 +++++++++++++++++- .../fragment/mainactivity/main/FeedView.kt | 57 ++++++-- .../mainactivity/main/HomeFragment.kt | 114 +++++++++++++--- .../serializers/PlatformContentSerializer.kt | 4 +- .../states/StateSubscriptions.kt | 2 +- .../stores/StringArrayStorage.kt | 15 +++ .../SubscriptionsTaskFetchAlgorithm.kt | 1 + .../subsexchange/ChannelResult.kt | 9 +- .../subsexchange/SubsExchangeClient.kt | 1 + .../futo/platformplayer/views/ToggleBar.kt | 30 ++++- .../views/others/ToggleTagView.kt | 35 ++++- .../overlays/slideup/SlideUpMenuOverlay.kt | 7 + app/src/main/res/layout/view_toggle_bar.xml | 6 +- app/src/main/res/layout/view_toggle_tag.xml | 31 +++-- app/src/main/res/values/strings.xml | 2 + 20 files changed, 411 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 989997c3..f68a60f9 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() { var home = HomeSettings(); @Serializable class HomeSettings { - @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5) + @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3) @DropdownFieldOptionsId(R.array.feed_style) var homeFeedStyle: Int = 1; @@ -216,6 +216,9 @@ class Settings : FragmentedStorageFileJson() { return FeedStyle.THUMBNAIL; } + @FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4) + var showHomeFilters: Boolean = true; + @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index a1aa71b7..874ffd4f 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -1148,7 +1148,7 @@ class UISlideOverlays { container.context.getString(R.string.decide_which_buttons_should_be_pinned), tag = "", call = { - showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { + showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, { val selected = it .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } .filter { it != null } @@ -1156,7 +1156,7 @@ class UISlideOverlays { .toList(); onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); - } + }); }, invokeParent = false )) @@ -1164,29 +1164,40 @@ class UISlideOverlays { return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() }; } - - fun showOrderOverlay(container: ViewGroup, title: String, options: List>, onOrdered: (List)->Unit) { + fun showOrderOverlay(container: ViewGroup, title: String, options: List>, onOrdered: (List)->Unit, description: String? = null) { val selection: MutableList = mutableListOf(); var overlay: SlideUpMenuOverlay? = null; overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true, - options.map { SlideUpMenuItem( + listOf( + if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null, + ).filterNotNull() + + (options.map { SlideUpMenuItem( container.context, R.drawable.ic_move_up, it.first, "", tag = it.second, call = { + val overlayItem = overlay?.getSlideUpItemByTag(it.second); if(overlay!!.selectOption(null, it.second, true, true)) { - if(!selection.contains(it.second)) + if(!selection.contains(it.second)) { selection.add(it.second); - } else + if(overlayItem != null) { + overlayItem.setSubText(selection.indexOf(it.second).toString()); + } + } + } else { selection.remove(it.second); + if(overlayItem != null) { + overlayItem.setSubText(""); + } + } }, invokeParent = false ) - }); + })); overlay.onOK.subscribe { onOrdered.invoke(selection); overlay.hide(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt index edb1caa3..a823316a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.contents import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import java.time.OffsetDateTime interface IPlatformContent { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index 68bb5cb9..c9e02d92 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -10,6 +10,7 @@ import com.futo.polycentric.core.combineHashCodes import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNames import java.time.OffsetDateTime @kotlinx.serialization.Serializable @@ -20,6 +21,7 @@ open class SerializedPlatformVideo( override val thumbnails: Thumbnails, override val author: PlatformAuthorLink, @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + @JsonNames("datetime", "dateTime") override val datetime: OffsetDateTime? = null, override val url: String, override val shareUrl: String = "", diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt index 34a9e41a..375f9343 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt @@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1 * A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager) * When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager */ -interface IRefreshPager { +interface IRefreshPager: IPager { val onPagerChanged: Event1>; val onPagerError: Event1; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt index 45f6aea5..ee1b39f2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt @@ -1,5 +1,7 @@ package com.futo.platformplayer.api.media.structures +import com.futo.platformplayer.api.media.structures.ReusablePager.Window +import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.logging.Logger /** @@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger * A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results. * This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests */ -class ReusablePager: INestedPager, IPager { - private val _pager: IPager; +open class ReusablePager: INestedPager, IReusablePager { + protected var _pager: IPager; val previousResults = arrayListOf(); constructor(subPager: IPager) { @@ -44,7 +46,7 @@ class ReusablePager: INestedPager, IPager { return previousResults; } - fun getWindow(): Window { + override fun getWindow(): Window { return Window(this); } @@ -95,4 +97,118 @@ class ReusablePager: INestedPager, IPager { return ReusablePager(this); } } +} + + +public class ReusableRefreshPager: INestedPager, IReusablePager { + protected var _pager: IRefreshPager; + val previousResults = arrayListOf(); + + private var _currentPage: IPager; + + + val onPagerChanged = Event1>() + val onPagerError = Event1() + + constructor(subPager: IRefreshPager) { + this._pager = subPager; + _currentPage = this; + synchronized(previousResults) { + previousResults.addAll(subPager.getResults()); + } + _pager.onPagerError.subscribe(onPagerError::emit); + _pager.onPagerChanged.subscribe { + _currentPage = it; + synchronized(previousResults) { + previousResults.clear(); + previousResults.addAll(it.getResults()); + } + + onPagerChanged.emit(_currentPage); + }; + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + if(query(_pager)) + return _pager; + else if(_pager is INestedPager<*>) + return (_pager as INestedPager).findPager(query); + return null; + } + + override fun hasMorePages(): Boolean { + return _pager.hasMorePages(); + } + + override fun nextPage() { + _pager.nextPage(); + } + + override fun getResults(): List { + val results = _pager.getResults(); + synchronized(previousResults) { + previousResults.addAll(results); + } + return previousResults; + } + + override fun getWindow(): RefreshWindow { + return RefreshWindow(this); + } + + + class RefreshWindow: IPager, INestedPager, IRefreshPager { + private val _parent: ReusableRefreshPager; + private var _position: Int = 0; + private var _read: Int = 0; + + private var _currentResults: List; + + override val onPagerChanged = Event1>(); + override val onPagerError = Event1(); + + + override fun getCurrentPager(): IPager { + return _parent.getWindow(); + } + + constructor(parent: ReusableRefreshPager) { + _parent = parent; + + synchronized(_parent.previousResults) { + _currentResults = _parent.previousResults.toList(); + _read += _currentResults.size; + } + parent.onPagerChanged.subscribe(onPagerChanged::emit); + parent.onPagerError.subscribe(onPagerError::emit); + } + + + override fun hasMorePages(): Boolean { + return _parent.previousResults.size > _read || _parent.hasMorePages(); + } + + override fun nextPage() { + synchronized(_parent.previousResults) { + if (_parent.previousResults.size <= _read) { + _parent.nextPage(); + _parent.getResults(); + } + _currentResults = _parent.previousResults.drop(_read).toList(); + _read += _currentResults.size; + } + } + + override fun getResults(): List { + return _currentResults; + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + return _parent.findPager(query); + } + } +} + +interface IReusablePager: IPager { + fun getWindow(): IPager; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index af033c51..58868ee4 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 @@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.content.Context import android.content.res.Configuration import android.graphics.Color +import android.util.DisplayMetrics +import android.view.Display import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.TagsView @@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.announcements.AnnouncementView import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.OffsetDateTime import kotlin.math.max @@ -68,6 +74,7 @@ abstract class FeedView : L private val _scrollListener: RecyclerView.OnScrollListener; private var _automaticNextPageCounter = 0; + private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000); constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>? = null) : super(inflater.context) { this.fragment = fragment; @@ -129,6 +136,7 @@ abstract class FeedView : L _toolbarContentView = findViewById(R.id.container_toolbar_content); _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { + if (it is IAsyncPager<*>) it.nextPageAsync(); else @@ -182,26 +190,53 @@ abstract class FeedView : L private fun ensureEnoughContentVisible(filteredResults: List) { val canScroll = if (recyclerData.results.isEmpty()) false else { + val height = resources.displayMetrics.heightPixels; + 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 / recyclerData.layoutManager.spanCount * itemHeight - val recyclerViewHeight = _recyclerResults.height - Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight") - occupiedSpace >= recyclerViewHeight + val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; + val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); + val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; + if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { + false; + } + else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) { + false; } else { - false + true; } } + Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter") if (!canScroll || filteredResults.isEmpty()) { _automaticNextPageCounter++ - if(_automaticNextPageCounter <= 4) - loadNextPage() + if(_automaticNextPageCounter < _automaticBackoff.size) { + if(_automaticNextPageCounter > 0) { + val automaticNextPageCounterSaved = _automaticNextPageCounter; + fragment.lifecycleScope.launch(Dispatchers.Default) { + val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)]; + + withContext(Dispatchers.Main) { + setLoading(true); + } + delay(backoff.toLong()); + if(automaticNextPageCounterSaved == _automaticNextPageCounter) { + withContext(Dispatchers.Main) { + loadNextPage(); + } + } + else { + withContext(Dispatchers.Main) { + setLoading(false); + } + } + } + } + else + loadNextPage(); + } } else { + Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset"); _automaticNextPageCounter = 0; } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index 9cdac8f9..d5e01461 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -5,22 +5,32 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.allViews +import androidx.core.view.contains import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.futo.platformplayer.* +import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.IRefreshPager +import com.futo.platformplayer.api.media.structures.IReusablePager +import com.futo.platformplayer.api.media.structures.ReusablePager +import com.futo.platformplayer.api.media.structures.ReusableRefreshPager import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.ToggleBar @@ -91,6 +101,7 @@ class HomeFragment : MainFragment() { _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems); } + @SuppressLint("ViewConstructor") class HomeView : ContentFeedView { override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); @@ -100,11 +111,20 @@ class HomeFragment : MainFragment() { private val _taskGetPager: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar + private var _lastPager: IReusablePager? = null; + constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { _taskGetPager = TaskHandler>({ fragment.lifecycleScope }, { StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) }) - .success { loadedResult(it); } + .success { + val wrappedPager = if(it is IRefreshPager) + ReusableRefreshPager(it); + else + ReusablePager(it); + _lastPager = wrappedPager; + loadedResult(wrappedPager.getWindow()); + } .exception { } .exception { Logger.w(ChannelFragment.TAG, "Plugin failure.", it); @@ -208,21 +228,81 @@ class HomeFragment : MainFragment() { private val _filterLock = Object(); private var _toggleRecent = false; + private var _toggleWatched = false; + private var _togglePluginsDisabled = mutableListOf(); + private var _togglesConfig = FragmentedStorage.get("home_toggles"); fun initializeToolbarContent() { - //Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing - /* - _toggleBar = ToggleBar(context).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - } - synchronized(_filterLock) { - _toggleBar?.setToggles( - //TODO: loadResults needs to be replaced with an internal reload of the current content - ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) } - ) - } + if(_toolbarContentView.allViews.any { it is ToggleBar }) + _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar }); - _toolbarContentView.addView(_toggleBar, 0); - */ + if(Settings.instance.home.showHomeFilters) { + + if (!_togglesConfig.any()) { + _togglesConfig.set("today", "watched", "plugins"); + _togglesConfig.save(); + } + _toggleBar = ToggleBar(context).apply { + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + _togglePluginsDisabled.clear(); + synchronized(_filterLock) { + val buttonsPlugins = (if (_togglesConfig.contains("plugins")) + (StatePlatform.instance.getEnabledClients() + .map { plugin -> + ToggleBar.Toggle(plugin.name, plugin.icon, true, { + if (it) { + if (_togglePluginsDisabled.contains(plugin.id)) + _togglePluginsDisabled.remove(plugin.id); + } else { + if (!_togglePluginsDisabled.contains(plugin.id)) + _togglePluginsDisabled.add(plugin.id); + } + reloadForFilters(); + }).withTag("plugins") + }) + else listOf()) + val buttons = (listOf( + (if (_togglesConfig.contains("today")) + ToggleBar.Toggle("Today", _toggleRecent) { + _toggleRecent = it; reloadForFilters() + } + .withTag("today") else null), + (if (_togglesConfig.contains("watched")) + ToggleBar.Toggle("Unwatched", _toggleWatched) { + _toggleWatched = it; reloadForFilters() + } + .withTag("watched") else null), + ).filterNotNull() + buttonsPlugins) + .sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf() + + val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { + showOrderOverlay(_overlayContainer, + "Visible home filters", + listOf( + Pair("Plugins", "plugins"), + Pair("Today", "today"), + Pair("Watched", "watched") + ), + { + val newArray = it.map { it.toString() }.toTypedArray(); + _togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none"))); + _togglesConfig.save(); + initializeToolbarContent(); + }, + "Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings" + ); + }).asButton(); + + val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray(); + _toggleBar?.setToggles(*buttonsOrder); + } + + _toolbarContentView.addView(_toggleBar, 0); + } + } + fun reloadForFilters() { + _lastPager?.let { loadedResult(it.getWindow()) }; } override fun filterResults(results: List): List { @@ -232,7 +312,11 @@ class HomeFragment : MainFragment() { if(StateMeta.instance.isCreatorHidden(it.author.url)) return@filter false; - if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) { + if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) + return@filter false; + if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) + return@filter false; + if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) { return@filter false; } diff --git a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt index 02a39160..db540ea1 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt @@ -19,10 +19,10 @@ import kotlinx.serialization.json.jsonPrimitive class PlatformContentSerializer : JsonContentPolymorphicSerializer(SerializedPlatformContent::class) { override fun selectDeserializer(element: JsonElement): DeserializationStrategy { - val obj = element.jsonObject["contentType"]; + val obj = element.jsonObject["contentType"] ?: element.jsonObject["ContentType"]; //TODO: Remove this temporary fallback..at some point - if(obj == null && element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull != null) + if(obj == null && (element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull ?: element.jsonObject["IsLive"]?.jsonPrimitive?.booleanOrNull) != null) return SerializedPlatformVideo.serializer(); if(obj?.jsonPrimitive?.isString != false) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 1d1acff6..c92c80b0 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -69,7 +69,7 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); - private val _subsExchangeServer = "https://exchange.grayjay.app/"; + private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/"; private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); init { diff --git a/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt index be1e69e3..0d072bba 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt @@ -41,4 +41,19 @@ class StringArrayStorage : FragmentedStorageFileJson() { return values.toList(); } } + fun any(): Boolean { + synchronized(values) { + return values.any(); + } + } + fun contains(v: String): Boolean { + synchronized(values) { + return values.contains(v); + } + } + fun indexOf(v: String): Int { + synchronized(values){ + return values.indexOf(v); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 123b0320..ce0e19c2 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -153,6 +153,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( *resolves ); if (resolve != null) { + val invalids = resolve.filter { it.content.any { it.datetime == null } }; UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") for(result in resolve){ val task = providedTasks?.find { it.url == result.channelUrl }; diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt index c13f101c..957d415d 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.OffsetDateTime @@ -12,12 +13,12 @@ import java.time.OffsetDateTime @Serializable class ChannelResult( @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) - @SerialName("DateTime") + @SerialName("dateTime") var dateTime: OffsetDateTime, - @SerialName("ChannelUrl") + @SerialName("channelUrl") var channelUrl: String, - @SerialName("Content") + @SerialName("content") var content: List, - @SerialName("Channel") + @SerialName("channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index a58e17b0..0a55516f 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -52,6 +52,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + Logger.v("SubsExchangeClient", "Resolve:" + result); return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt index be3d8df8..ef2eadbc 100644 --- a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.states.StateSubscriptionGroups @@ -46,7 +47,12 @@ class ToggleBar : LinearLayout { _tagsContainer.removeAllViews(); for(button in buttons) { _tagsContainer.addView(ToggleTagView(context).apply { - this.setInfo(button.name, button.isActive); + if(button.icon > 0) + this.setInfo(button.icon, button.name, button.isActive, button.isButton); + else if(button.iconVariable != null) + this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); + else + this.setInfo(button.name, button.isActive, button.isButton); this.onClick.subscribe { button.action(it); }; }); } @@ -55,20 +61,42 @@ class ToggleBar : LinearLayout { class Toggle { val name: String; val icon: Int; + val iconVariable: ImageVariable?; val action: (Boolean)->Unit; val isActive: Boolean; + var isButton: Boolean = false + private set; + var tag: String? = null; + constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) { + this.name = name; + this.icon = 0; + this.iconVariable = icon; + this.action = action; + this.isActive = isActive; + } constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { this.name = name; this.icon = icon; + this.iconVariable = null; this.action = action; this.isActive = isActive; } constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { this.name = name; this.icon = 0; + this.iconVariable = null; this.action = action; this.isActive = isActive; } + + fun asButton(): Toggle{ + isButton = true; + return this; + } + fun withTag(str: String): Toggle { + tag = str; + return this; + } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 27c4e68d..65b13eb0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -4,19 +4,27 @@ import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater +import android.view.View import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper +import com.futo.platformplayer.models.ImageVariable class ToggleTagView : LinearLayout { private val _root: FrameLayout; private val _textTag: TextView; private var _text: String = ""; + private var _image: ImageView; var isActive: Boolean = false private set; + var isButton: Boolean = false + private set; var onClick = Event1(); @@ -24,7 +32,12 @@ class ToggleTagView : LinearLayout { LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); _root = findViewById(R.id.root); _textTag = findViewById(R.id.text_tag); - _root.setOnClickListener { setToggle(!isActive); onClick.emit(isActive); } + _image = findViewById(R.id.image_tag); + _root.setOnClickListener { + if(!isButton) + setToggle(!isActive); + onClick.emit(isActive); + } } fun setToggle(isActive: Boolean) { @@ -39,9 +52,27 @@ class ToggleTagView : LinearLayout { } } - fun setInfo(text: String, isActive: Boolean) { + fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { _text = text; _textTag.text = text; setToggle(isActive); + _image.setImageResource(imageResource); + _image.visibility = View.VISIBLE; + this.isButton = isButton; + } + fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { + _text = text; + _textTag.text = text; + setToggle(isActive); + image.setImageView(_image, R.drawable.ic_error_pred); + _image.visibility = View.VISIBLE; + this.isButton = isButton; + } + fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { + _image.visibility = View.GONE; + _text = text; + _textTag.text = text; + setToggle(isActive); + this.isButton = isButton; } } \ No newline at end of file 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 89cf1359..58850998 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 @@ -113,6 +113,13 @@ class SlideUpMenuOverlay : RelativeLayout { _textOK.visibility = View.VISIBLE; } } + fun getSlideUpItemByTag(itemTag: Any?): SlideUpMenuItem? { + for(view in groupItems){ + if(view is SlideUpMenuItem && view.itemTag == itemTag) + return view; + } + return null; + } fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean { var didSelect = false; diff --git a/app/src/main/res/layout/view_toggle_bar.xml b/app/src/main/res/layout/view_toggle_bar.xml index 3da2f363..7b99d09f 100644 --- a/app/src/main/res/layout/view_toggle_bar.xml +++ b/app/src/main/res/layout/view_toggle_bar.xml @@ -3,14 +3,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - - + \ No newline at end of file diff --git a/app/src/main/res/layout/view_toggle_tag.xml b/app/src/main/res/layout/view_toggle_tag.xml index 886f2de5..eca7010c 100644 --- a/app/src/main/res/layout/view_toggle_tag.xml +++ b/app/src/main/res/layout/view_toggle_tag.xml @@ -10,16 +10,27 @@ android:layout_marginTop="17dp" android:layout_marginBottom="8dp" android:id="@+id/root"> - - + android:layout_height="match_parent" + 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 9d82ff85..85549f27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -417,6 +417,8 @@ If subscription groups should be shown above your subscriptions to filter Preview Feed Items When the preview feedstyle is used, if items should auto-preview when scrolling over them + Show Home Filters + If the home filters should be shown above home Log Level Logging Sync Grayjay From b14518edb1e0caade4917d18ad79432c2ff5f2f4 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Sat, 5 Apr 2025 01:02:50 +0200 Subject: [PATCH 017/298] Home filter fixes, persistent sorts, subs exchange fixes, playlist video options --- .../java/com/futo/platformplayer/Settings.kt | 2 + .../mainactivity/main/DownloadsFragment.kt | 28 +++-- .../fragment/mainactivity/main/FeedView.kt | 3 + .../mainactivity/main/HomeFragment.kt | 52 ++++---- .../mainactivity/main/PlaylistFragment.kt | 4 + .../mainactivity/main/PlaylistsFragment.kt | 29 +++-- .../mainactivity/main/VideoDetailView.kt | 3 + .../mainactivity/main/VideoListEditorView.kt | 2 + .../mainactivity/main/WatchLaterFragment.kt | 3 + .../states/StateSubscriptions.kt | 2 +- .../SubscriptionsTaskFetchAlgorithm.kt | 118 +++++++++++------- .../subsexchange/SubsExchangeClient.kt | 8 +- .../views/adapters/VideoListEditorAdapter.kt | 2 + .../adapters/VideoListEditorViewHolder.kt | 7 ++ .../views/lists/VideoListEditorView.kt | 5 + .../views/others/ToggleTagView.kt | 3 + .../views/overlays/QueueEditorOverlay.kt | 6 + app/src/main/res/layout/list_playlist.xml | 42 +++++-- app/src/main/res/layout/view_toggle_tag.xml | 11 +- app/src/main/res/values/strings.xml | 2 + 20 files changed, 218 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index f68a60f9..cedc4316 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -218,6 +218,8 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4) var showHomeFilters: Boolean = true; + @FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5) + var showHomeFiltersPluginNames: Boolean = false; @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index d402a6e2..217165ae 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -21,6 +21,8 @@ import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.views.AnyInsertedAdapterView @@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() { private val _listDownloaded: AnyInsertedAdapterView; private var lastDownloads: List? = null; - private var ordering: String? = "nameAsc"; + private var ordering = FragmentedStorage.get("downloads_ordering") constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { inflater.inflate(R.layout.fragment_downloads, this); _frag = frag; + if(ordering.value.isNullOrBlank()) + ordering.value = "nameAsc"; + _usageUsed = findViewById(R.id.downloads_usage_used); _usageAvailable = findViewById(R.id.downloads_usage_available); _usageProgress = findViewById(R.id.downloads_usage_progress); @@ -132,22 +137,23 @@ class DownloadsFragment : MainFragment() { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also { it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); }; - spinnerSortBy.setSelection(0); + val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { - 0 -> ordering = "nameAsc" - 1 -> ordering = "nameDesc" - 2 -> ordering = "downloadDateAsc" - 3 -> ordering = "downloadDateDesc" - 4 -> ordering = "releasedAsc" - 5 -> ordering = "releasedDesc" - else -> ordering = null + 0 -> ordering.setAndSave("nameAsc") + 1 -> ordering.setAndSave("nameDesc") + 2 -> ordering.setAndSave("downloadDateAsc") + 3 -> ordering.setAndSave("downloadDateDesc") + 4 -> ordering.setAndSave("releasedAsc") + 5 -> ordering.setAndSave("releasedDesc") + else -> ordering.setAndSave("") } updateContentFilters() } override fun onNothingSelected(parent: AdapterView<*>?) = Unit }; + spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value))); _listDownloaded = findViewById(R.id.list_downloaded) .asAnyWithTop(findViewById(R.id.downloads_top)) { @@ -230,8 +236,8 @@ class DownloadsFragment : MainFragment() { var vidsToReturn = vids; if(!_listDownloadSearch.text.isNullOrEmpty()) vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) }; - if(!ordering.isNullOrEmpty()) { - vidsToReturn = when(ordering){ + if(!ordering.value.isNullOrEmpty()) { + vidsToReturn = when(ordering.value){ "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; "downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN }; "nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() } 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 58868ee4..17ca5510 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 @@ -240,6 +240,9 @@ abstract class FeedView : L _automaticNextPageCounter = 0; } } + fun resetAutomaticNextPageCounter(){ + _automaticNextPageCounter = 0; + } protected fun setTextCentered(text: String?) { _textCentered.text = text; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index d5e01461..a450a2ed 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -6,7 +6,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.allViews -import androidx.core.view.contains import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.futo.platformplayer.* @@ -29,6 +28,7 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.views.FeedStyle @@ -37,7 +37,6 @@ import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewHolder -import com.futo.platformplayer.views.announcements.AnnouncementView import com.futo.platformplayer.views.buttons.BigButton import kotlinx.coroutines.runBlocking import java.time.OffsetDateTime @@ -49,6 +48,12 @@ class HomeFragment : MainFragment() { private var _view: HomeView? = null; private var _cachedRecyclerData: FeedView.RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + private var _cachedLastPager: IReusablePager? = null + + private var _toggleRecent = false; + private var _toggleWatched = false; + private var _togglePluginsDisabled = mutableListOf(); + fun reloadFeed() { _view?.reloadFeed() @@ -74,7 +79,7 @@ class HomeFragment : MainFragment() { } override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = HomeView(this, inflater, _cachedRecyclerData); + val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager); _view = view; return view; } @@ -92,6 +97,7 @@ class HomeFragment : MainFragment() { val view = _view; if (view != null) { _cachedRecyclerData = view.recyclerData; + _cachedLastPager = view.lastPager; view.cleanup(); _view = null; } @@ -111,9 +117,10 @@ class HomeFragment : MainFragment() { private val _taskGetPager: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar - private var _lastPager: IReusablePager? = null; + var lastPager: IReusablePager? = null; - constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { + constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null, cachedLastPager: IReusablePager? = null) : super(fragment, inflater, cachedRecyclerData) { + lastPager = cachedLastPager _taskGetPager = TaskHandler>({ fragment.lifecycleScope }, { StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) }) @@ -122,7 +129,8 @@ class HomeFragment : MainFragment() { ReusableRefreshPager(it); else ReusablePager(it); - _lastPager = wrappedPager; + lastPager = wrappedPager; + resetAutomaticNextPageCounter(); loadedResult(wrappedPager.getWindow()); } .exception { } @@ -227,9 +235,6 @@ class HomeFragment : MainFragment() { } private val _filterLock = Object(); - private var _toggleRecent = false; - private var _toggleWatched = false; - private var _togglePluginsDisabled = mutableListOf(); private var _togglesConfig = FragmentedStorage.get("home_toggles"); fun initializeToolbarContent() { if(_toolbarContentView.allViews.any { it is ToggleBar }) @@ -245,18 +250,19 @@ class HomeFragment : MainFragment() { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } - _togglePluginsDisabled.clear(); + fragment._togglePluginsDisabled.clear(); synchronized(_filterLock) { val buttonsPlugins = (if (_togglesConfig.contains("plugins")) (StatePlatform.instance.getEnabledClients() + .filter { it is JSClient && it.enableInHome } .map { plugin -> - ToggleBar.Toggle(plugin.name, plugin.icon, true, { + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { if (it) { - if (_togglePluginsDisabled.contains(plugin.id)) - _togglePluginsDisabled.remove(plugin.id); + if (fragment._togglePluginsDisabled.contains(plugin.id)) + fragment._togglePluginsDisabled.remove(plugin.id); } else { - if (!_togglePluginsDisabled.contains(plugin.id)) - _togglePluginsDisabled.add(plugin.id); + if (!fragment._togglePluginsDisabled.contains(plugin.id)) + fragment._togglePluginsDisabled.add(plugin.id); } reloadForFilters(); }).withTag("plugins") @@ -264,13 +270,13 @@ class HomeFragment : MainFragment() { else listOf()) val buttons = (listOf( (if (_togglesConfig.contains("today")) - ToggleBar.Toggle("Today", _toggleRecent) { - _toggleRecent = it; reloadForFilters() + ToggleBar.Toggle("Today", fragment._toggleRecent) { + fragment._toggleRecent = it; reloadForFilters() } .withTag("today") else null), (if (_togglesConfig.contains("watched")) - ToggleBar.Toggle("Unwatched", _toggleWatched) { - _toggleWatched = it; reloadForFilters() + ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { + fragment._toggleWatched = it; reloadForFilters() } .withTag("watched") else null), ).filterNotNull() + buttonsPlugins) @@ -302,7 +308,7 @@ class HomeFragment : MainFragment() { } } fun reloadForFilters() { - _lastPager?.let { loadedResult(it.getWindow()) }; + lastPager?.let { loadedResult(it.getWindow()) }; } override fun filterResults(results: List): List { @@ -312,11 +318,11 @@ class HomeFragment : MainFragment() { if(StateMeta.instance.isCreatorHidden(it.author.url)) return@filter false; - if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) + if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) return@filter false; - if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) + if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) return@filter false; - if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) { + if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) { return@filter false; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index b58e3ee2..c56585b0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() { playlist.videos = ArrayList(playlist.videos.filter { it != video }); StatePlaylists.instance.createOrUpdatePlaylist(playlist); } + + override fun onVideoOptions(video: IPlatformVideo) { + UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer); + } override fun onVideoClicked(video: IPlatformVideo) { val playlist = _playlist; if (playlist != null) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index 9d188415..58caabe1 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -26,6 +26,8 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay @@ -82,7 +84,7 @@ class PlaylistsFragment : MainFragment() { private var _listPlaylistsSearch: EditText; - private var _ordering: String? = null; + private var _ordering = FragmentedStorage.get("playlists_ordering") constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { @@ -145,24 +147,25 @@ class PlaylistsFragment : MainFragment() { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also { it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); }; - spinnerSortBy.setSelection(0); + val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { - 0 -> _ordering = "nameAsc" - 1 -> _ordering = "nameDesc" - 2 -> _ordering = "dateEditAsc" - 3 -> _ordering = "dateEditDesc" - 4 -> _ordering = "dateCreateAsc" - 5 -> _ordering = "dateCreateDesc" - 6 -> _ordering = "datePlayAsc" - 7 -> _ordering = "datePlayDesc" - else -> _ordering = null + 0 -> _ordering.setAndSave("nameAsc") + 1 -> _ordering.setAndSave("nameDesc") + 2 -> _ordering.setAndSave("dateEditAsc") + 3 -> _ordering.setAndSave("dateEditDesc") + 4 -> _ordering.setAndSave("dateCreateAsc") + 5 -> _ordering.setAndSave("dateCreateDesc") + 6 -> _ordering.setAndSave("datePlayAsc") + 7 -> _ordering.setAndSave("datePlayDesc") + else -> _ordering.setAndSave("") } updatePlaylistsFiltering() } override fun onNothingSelected(parent: AdapterView<*>?) = Unit }; + spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value))); findViewById(R.id.text_view_all).setOnClickListener { _fragment.navigate(context.getString(R.string.watch_later)); }; @@ -214,8 +217,8 @@ class PlaylistsFragment : MainFragment() { var playlistsToReturn = pls; if(!_listPlaylistsSearch.text.isNullOrEmpty()) playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; - if(!_ordering.isNullOrEmpty()){ - playlistsToReturn = when(_ordering){ + if(!_ordering.value.isNullOrEmpty()){ + playlistsToReturn = when(_ordering.value){ "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; "dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX }; 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 25b03cb1..7176d125 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 @@ -693,6 +693,9 @@ class VideoDetailView : ConstraintLayout { _container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_queue.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_queue.onOptions.subscribe { + UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer); + } _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_browser.onClose.subscribe { switchContentView(_container_content_main); }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index 441f7421..c0383b89 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -104,6 +104,7 @@ abstract class VideoListEditorView : LinearLayout { videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); + videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); _videoListEditorView = videoListEditorView; @@ -122,6 +123,7 @@ abstract class VideoListEditorView : LinearLayout { open fun onShuffleClick() { } open fun onEditClick() { } open fun onVideoRemoved(video: IPlatformVideo) {} + open fun onVideoOptions(video: IPlatformVideo) {} open fun onVideoOrderChanged(videos : List) {} open fun onVideoClicked(video: IPlatformVideo) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt index 4d3c65bd..9d66c3f7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt @@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() { StatePlaylists.instance.removeFromWatchLater(video, true); } } + override fun onVideoOptions(video: IPlatformVideo) { + UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer); + } override fun onVideoClicked(video: IPlatformVideo) { val watchLater = StatePlaylists.instance.getWatchLater(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index c92c80b0..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -69,7 +69,7 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); - private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/"; + private val _subsExchangeServer = "https://exchange.grayjay.app/"; private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); init { diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index ce0e19c2..1a1d503b 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -82,23 +82,30 @@ abstract class SubscriptionsTaskFetchAlgorithm( var providedTasks: MutableList? = null; try { - val contractableTasks = - tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; - contract = - if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { - ChannelRequest(it.url) - }.toTypedArray()) else null; - if (contract?.provided?.isNotEmpty() == true) - Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); - if (contract != null && contract.required.isNotEmpty()) { - providedTasks = mutableListOf() - for (task in tasks.toList()) { - if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { - providedTasks.add(task); - tasks.remove(task); + val contractingTime = measureTimeMillis { + val contractableTasks = + tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + contract = + if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { + ChannelRequest(it.url) + }.toTypedArray()) else null; + if (contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + if (contract != null && contract!!.required.isNotEmpty()) { + providedTasks = mutableListOf() + for (task in tasks.toList()) { + if (!task.fromCache && !task.fromPeek && contract!!.provided.contains(task.url)) { + providedTasks!!.add(task); + tasks.remove(task); + } } } } + if(contract != null) + Logger.i(TAG, "Subscription Exchange contract received in ${contractingTime}ms"); + else if(contractingTime > 100) + Logger.i(TAG, "Subscription Exchange contract failed to received in${contractingTime}ms"); + } catch(ex: Throwable){ Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex); @@ -109,6 +116,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); val taskResults = arrayListOf(); + var resolveCount = 0; + var resolveTime = 0L; val timeTotal = measureTimeMillis { for(task in forkTasks) { try { @@ -137,51 +146,68 @@ abstract class SubscriptionsTaskFetchAlgorithm( } }; } - } - //Resolve Subscription Exchange - if(contract != null) { - try { - val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } - ) - }.toTypedArray() - val resolve = subsExchangeClient?.resolveContract( - contract, - *resolves - ); - if (resolve != null) { - val invalids = resolve.filter { it.content.any { it.datetime == null } }; - UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") - for(result in resolve){ - val task = providedTasks?.find { it.url == result.channelUrl }; - if(task != null) { - taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); - providedTasks?.remove(task); + //Resolve Subscription Exchange + if(contract != null) { + try { + resolveTime = measureTimeMillis { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() + val resolve = subsExchangeClient?.resolveContract( + contract!!, + *resolves + ); + if (resolve != null) { + resolveCount = resolves.size; + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks!!) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); + } } } + Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + } - if (providedTasks != null) { - for(task in providedTasks) { - taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); - } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); } } - catch(ex: Throwable) { - //TODO: fetch remainder after all? - Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); - } } - Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms") + Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms"); + if(resolveCount > 0) { + val selfFetchTime = timeTotal - resolveTime; + val selfFetchCount = tasks.count { !it.fromPeek && !it.fromCache }; + if(selfFetchCount > 0) { + val selfResolvePercentage = resolveCount.toDouble() / selfFetchCount; + val estimateSelfFetchTime = selfFetchTime + selfFetchTime * selfResolvePercentage; + val selfFetchDelta = timeTotal - estimateSelfFetchTime; + if(selfFetchDelta > 0) + UIDialogs.appToast("Subscription Exchange lost ${selfFetchDelta}ms (out of ${timeTotal}ms)", true); + else + UIDialogs.appToast("Subscription Exchange saved ${(selfFetchDelta * -1).toInt()}ms (out of ${timeTotal}ms)", true); + } + } //Cache pagers grouped by channel val groupedPagers = taskResults.groupBy { it.task.sub.channel.url } .map { entry -> val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null; - val liveTasks = entry.value.filter { !it.task.fromCache }; + val liveTasks = entry.value.filter { !it.task.fromCache && it.pager != null }; val cachedTasks = entry.value.filter { it.task.fromCache }; val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) { onNewCacheHit.emit(sub!!, it); diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 0a55516f..6f38014d 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -28,7 +28,7 @@ import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAPublicKeySpec -class SubsExchangeClient(private val server: String, private val privateKey: String) { +class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) { private val json = Json { ignoreUnknownKeys = true @@ -40,7 +40,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Contract fun requestContract(vararg channels: ChannelRequest): ExchangeContract { - val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json", contractTimeout) return Json.decodeFromString(data) } suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { @@ -74,9 +74,11 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } // IO methods - private fun post(query: String, body: String, contentType: String): String { + private fun post(query: String, body: String, contentType: String, timeout: Int = 0): String { val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { + if(timeout > 0) + this.connectTimeout = timeout requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt index aa4ee66f..f7c313f5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt @@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter { val onClick = Event1(); val onRemove = Event1(); + val onOptions = Event1(); var canEdit = false private set; @@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter { val holder = VideoListEditorViewHolder(view, _touchHelper); holder.onRemove.subscribe { v -> onRemove.emit(v); }; + holder.onOptions.subscribe { v -> onOptions.emit(v); }; holder.onClick.subscribe { v -> onClick.emit(v); }; return holder; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 3cf3194b..77df0665 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder { private val _containerDuration: LinearLayout; private val _containerLive: LinearLayout; private val _imageRemove: ImageButton; + private val _imageOptions: ImageButton; private val _imageDragDrop: ImageButton; private val _platformIndicator: PlatformIndicator; private val _layoutDownloaded: FrameLayout; @@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder { val onClick = Event1(); val onRemove = Event1(); + val onOptions = Event1(); @SuppressLint("ClickableViewAccessibility") constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { @@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder { _containerDuration = view.findViewById(R.id.thumbnail_duration_container); _containerLive = view.findViewById(R.id.thumbnail_live_container); _imageRemove = view.findViewById(R.id.image_trash); + _imageOptions = view.findViewById(R.id.image_settings); _imageDragDrop = view.findViewById(R.id.image_drag_drop); _platformIndicator = view.findViewById(R.id.thumbnail_platform); _layoutDownloaded = view.findViewById(R.id.layout_downloaded); @@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder { val v = video ?: return@setOnClickListener; onRemove.emit(v); }; + _imageOptions?.setOnClickListener { + val v = video ?: return@setOnClickListener; + onOptions.emit(v); + } } fun bind(v: IPlatformVideo, canEdit: Boolean) { diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 3bfce0be..08d32ac3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout { val onVideoOrderChanged = Event1>() val onVideoRemoved = Event1(); + val onVideoOptions = Event1(); val onVideoClicked = Event1(); val isEmpty get() = _videos.isEmpty(); @@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout { } }; + adapterVideos.onOptions.subscribe { v -> + onVideoOptions?.emit(v); + } adapterVideos.onRemove.subscribe { v -> val executeDelete = { synchronized(_videos) { diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 65b13eb0..059405ad 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -58,6 +58,7 @@ class ToggleTagView : LinearLayout { setToggle(isActive); _image.setImageResource(imageResource); _image.visibility = View.VISIBLE; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; } fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { @@ -66,12 +67,14 @@ class ToggleTagView : LinearLayout { setToggle(isActive); image.setImageView(_image, R.drawable.ic_error_pred); _image.visibility = View.VISIBLE; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; } fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { _image.visibility = View.GONE; _text = text; _textTag.text = text; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; setToggle(isActive); this.isButton = isButton; } diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt index a7181e90..53982097 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt @@ -8,7 +8,9 @@ import android.widget.LinearLayout import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.R import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay @@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout { private val _overlayContainer: FrameLayout; + val onOptions = Event1(); val onClose = Event0(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { @@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout { _topbar.onClose.subscribe(this, onClose::emit); _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } + _editor.onVideoOptions.subscribe { v -> + onOptions?.emit(v); + } _editor.onVideoRemoved.subscribe { v -> StatePlayer.instance.removeFromQueue(v); _topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos)); diff --git a/app/src/main/res/layout/list_playlist.xml b/app/src/main/res/layout/list_playlist.xml index d51cdfc5..c9ea9927 100644 --- a/app/src/main/res/layout/list_playlist.xml +++ b/app/src/main/res/layout/list_playlist.xml @@ -135,7 +135,7 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintBottom_toTopOf="@id/text_author" android:layout_marginStart="10dp" /> @@ -152,7 +152,7 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toBottomOf="@id/text_video_name" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintBottom_toTopOf="@id/text_video_metadata" android:layout_marginStart="10dp" /> @@ -169,19 +169,35 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toBottomOf="@id/text_author" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" android:layout_marginStart="10dp" /> - + app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" > + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_toggle_tag.xml b/app/src/main/res/layout/view_toggle_tag.xml index eca7010c..5f285bd2 100644 --- a/app/src/main/res/layout/view_toggle_tag.xml +++ b/app/src/main/res/layout/view_toggle_tag.xml @@ -3,8 +3,8 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="wrap_content" android:layout_height="32dp" - android:paddingStart="15dp" - android:paddingEnd="15dp" + android:paddingStart="12dp" + android:paddingEnd="12dp" android:background="@drawable/background_pill" android:layout_marginEnd="6dp" android:layout_marginTop="17dp" @@ -19,12 +19,15 @@ android:visibility="gone" android:layout_width="24dp" android:layout_height="24dp" - android:layout_marginRight="5dp" - android:layout_marginTop="4dp" /> + android:layout_gravity="center" + android:layout_marginLeft="2.5dp" + android:layout_marginRight="2.5dp" /> When the preview feedstyle is used, if items should auto-preview when scrolling over them Show Home Filters If the home filters should be shown above home + Home filter Plugin Names + If home filters should show full plugin names or just icons Log Level Logging Sync Grayjay From 7b355139fb9fbc79578af84c5b65aabec41faa07 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 7 Apr 2025 23:31:00 +0200 Subject: [PATCH 018/298] Subscription persistence fixes, home toggle fixes, subs exchange gzip, etc --- .../java/com/futo/platformplayer/Utility.kt | 35 ++++++++ .../mainactivity/main/HomeFragment.kt | 38 ++++++--- .../main/SubscriptionsFeedFragment.kt | 41 ++++++---- .../SubscriptionsTaskFetchAlgorithm.kt | 80 ++++++++++++------- .../subsexchange/SubsExchangeClient.kt | 35 +++++--- .../futo/platformplayer/views/ToggleBar.kt | 10 +-- .../views/others/ToggleTagView.kt | 24 +++++- .../views/subscriptions/SubscriptionBar.kt | 8 +- 8 files changed, 191 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 64efb992..5cd5d26f 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -27,14 +27,18 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.others.PlatformLinkMovementMethod import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.ByteBuffer import java.nio.ByteOrder +import java.time.OffsetDateTime import java.util.* import java.util.concurrent.ThreadLocalRandom +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; fun getRandomString(sizeOfRandomString: Int): String { @@ -279,3 +283,34 @@ fun ByteBuffer.toUtf8String(): String { get(remainingBytes) return String(remainingBytes, Charsets.UTF_8) } + + +fun ByteArray.toGzip(): ByteArray { + if (this == null || this.isEmpty()) return ByteArray(0) + + val gzipTimeStart = OffsetDateTime.now(); + + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { gzip -> + gzip.write(this) + } + val result = outputStream.toByteArray(); + Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms"); + return result; +} + +fun ByteArray.fromGzip(): ByteArray { + if (this == null || this.isEmpty()) return ByteArray(0) + + val inputStream = ByteArrayInputStream(this) + val outputStream = ByteArrayOutputStream() + + GZIPInputStream(inputStream).use { gzip -> + val buffer = ByteArray(1024) + var bytesRead: Int + while (gzip.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + return outputStream.toByteArray() +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index a450a2ed..988d7a3f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -250,39 +250,53 @@ class HomeFragment : MainFragment() { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } - fragment._togglePluginsDisabled.clear(); + synchronized(_filterLock) { - val buttonsPlugins = (if (_togglesConfig.contains("plugins")) + var buttonsPlugins: List = listOf() + buttonsPlugins = (if (_togglesConfig.contains("plugins")) (StatePlatform.instance.getEnabledClients() .filter { it is JSClient && it.enableInHome } .map { plugin -> - ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { - if (it) { + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active -> + var dontSwap = false; + if (active) { if (fragment._togglePluginsDisabled.contains(plugin.id)) fragment._togglePluginsDisabled.remove(plugin.id); } else { - if (!fragment._togglePluginsDisabled.contains(plugin.id)) - fragment._togglePluginsDisabled.add(plugin.id); + if (!fragment._togglePluginsDisabled.contains(plugin.id)) { + val enabledClients = StatePlatform.instance.getEnabledClients(); + val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id }; + if(availableAfterDisable > 0) + fragment._togglePluginsDisabled.add(plugin.id); + else { + UIDialogs.appToast("Home needs atleast 1 plugin active"); + dontSwap = true; + } + } + } + if(!dontSwap) + reloadForFilters(); + else { + view.setToggle(!active); } - reloadForFilters(); }).withTag("plugins") }) else listOf()) val buttons = (listOf( (if (_togglesConfig.contains("today")) - ToggleBar.Toggle("Today", fragment._toggleRecent) { - fragment._toggleRecent = it; reloadForFilters() + ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active -> + fragment._toggleRecent = active; reloadForFilters() } .withTag("today") else null), (if (_togglesConfig.contains("watched")) - ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { - fragment._toggleWatched = it; reloadForFilters() + ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active -> + fragment._toggleWatched = active; reloadForFilters() } .withTag("watched") else null), ).filterNotNull() + buttonsPlugins) .sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf() - val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { + val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active -> showOrderOverlay(_overlayContainer, "Visible home filters", listOf( diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index b4f51b14..83e39a88 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.RateLimitException +import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SubscriptionGroup @@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() { private var _group: SubscriptionGroup? = null; private var _cachedRecyclerData: FeedView.RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + private val _filterLock = Object(); + private val _filterSettings = FragmentedStorage.get("subFeedFilter"); + override fun onShownWithView(parameter: Any?, isBack: Boolean) { super.onShownWithView(parameter, isBack); _view?.onShown(); @@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() { return Json.encodeToString(this); } } - private val _filterLock = Object(); - private val _filterSettings = FragmentedStorage.get("subFeedFilter"); private var _bypassRateLimit = false; private val _lastExceptions: List? = null; @@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() { fragment.navigate(g); }; - synchronized(_filterLock) { + synchronized(fragment._filterLock) { _subscriptionBar?.setToggles( - SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); }, - SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); }, - SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, - SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }, - SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); } + SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active -> + toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); }, + SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active -> + toggleFilterContentType(ContentType.POST, active); }, + SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active -> + fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); }, + SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active -> + fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); }, + SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active -> + fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); } ); } @@ -301,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() { toggleFilterContentType(contentType, isTrue); } private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) { - synchronized(_filterLock) { + synchronized(fragment._filterLock) { if(!isTrue) { - _filterSettings.allowContentTypes.remove(contentType); - } else if(!_filterSettings.allowContentTypes.contains(contentType)) { - _filterSettings.allowContentTypes.add(contentType) + fragment._filterSettings.allowContentTypes.remove(contentType); + } else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) { + fragment._filterSettings.allowContentTypes.add(contentType) } - _filterSettings.save(); + fragment._filterSettings.save(); }; if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround loadResults(false); @@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() { val nowSoon = OffsetDateTime.now().plusMinutes(5); val filterGroup = subGroup; return results.filter { - val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); + val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); - if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration)) + if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration)) return@filter false; //TODO: Check against a sub cache @@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() { if(it.datetime?.isAfter(nowSoon) == true) { - if(!_filterSettings.allowPlanned) + if(!fragment._filterSettings.allowPlanned) return@filter false; } - if(_filterSettings.allowLive) { //If allowLive, always show live + if(fragment._filterSettings.allowLive) { //If allowLive, always show live if(it is IPlatformVideo && it.isLive) return@filter true; } diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 1a1d503b..b72e840c 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -15,12 +15,14 @@ import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.PlatformContentPager +import com.futo.platformplayer.debug.Stopwatch import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment +import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp @@ -32,6 +34,8 @@ import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ExchangeContract import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.time.OffsetDateTime import java.util.concurrent.ExecutionException import java.util.concurrent.ForkJoinPool @@ -149,42 +153,56 @@ abstract class SubscriptionsTaskFetchAlgorithm( //Resolve Subscription Exchange if(contract != null) { - try { - resolveTime = measureTimeMillis { - val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } - ) - }.toTypedArray() - val resolve = subsExchangeClient?.resolveContract( - contract!!, - *resolves - ); - if (resolve != null) { - resolveCount = resolves.size; - UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") - for(result in resolve){ - val task = providedTasks?.find { it.url == result.channelUrl }; - if(task != null) { - taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); - providedTasks?.remove(task); + fun resolve() { + try { + resolveTime = measureTimeMillis { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() + + val resolveRequestStart = OffsetDateTime.now(); + + val resolve = subsExchangeClient?.resolveContract( + contract!!, + *resolves + ); + + Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms"); + + if (resolve != null) { + resolveCount = resolves.size; + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks!!) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); } } } - if (providedTasks != null) { - for(task in providedTasks!!) { - taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); - } - } - } - Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); + } } - catch(ex: Throwable) { - //TODO: fetch remainder after all? - Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); - } + if(providedTasks?.size ?: 0 == 0) + scope.launch(Dispatchers.IO) { + resolve(); + } + else + resolve(); } } diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 6f38014d..b0357f56 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -1,10 +1,14 @@ import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm.Companion.TAG import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResult import com.futo.platformplayer.subsexchange.ExchangeContract import com.futo.platformplayer.subsexchange.ExchangeContractResolve +import com.futo.platformplayer.toGzip +import com.futo.platformplayer.toHumanBytesSize import kotlinx.serialization.* import kotlinx.serialization.json.* import kotlinx.coroutines.Dispatchers @@ -26,6 +30,7 @@ import java.nio.charset.StandardCharsets import java.security.KeyPairGenerator import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAPublicKeySpec +import java.time.OffsetDateTime class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) { @@ -40,24 +45,27 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Contract fun requestContract(vararg channels: ChannelRequest): ExchangeContract { - val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json", contractTimeout) + val data = post("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json", contractTimeout) return Json.decodeFromString(data) } suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { - val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json") return Json.decodeFromString(data) } // Endpoint: Resolve fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") - Logger.v("SubsExchangeClient", "Resolve:" + result); + val contractResolveJson = Serializer.json.encodeToString(contractResolve); + val contractResolveTimeStart = OffsetDateTime.now(); + val result = post("/api/Channel/Resolve?contractId=${contract.id}", contractResolveJson.toByteArray(Charsets.UTF_8), "application/json", 0, true) + val contractResolveTime = contractResolveTimeStart.getNowDiffMiliseconds(); + Logger.v("SubsExchangeClient", "Subscription Exchange Resolve Request [${contractResolveTime}ms]:" + result); return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve).toByteArray(Charsets.UTF_8), "application/json", true) return Serializer.json.decodeFromString(result) } @@ -74,7 +82,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } // IO methods - private fun post(query: String, body: String, contentType: String, timeout: Int = 0): String { + private fun post(query: String, body: ByteArray, contentType: String, timeout: Int = 0, gzip: Boolean = false): String { val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { if(timeout > 0) @@ -82,7 +90,16 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true - OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() } + + + if(gzip) { + val gzipData = body.toGzip(); + setRequestProperty("Content-Encoding", "gzip"); + outputStream.write(gzipData); + Logger.i("SubsExchangeClient", "SubsExchange using gzip (${body.size.toHumanBytesSize()} => ${gzipData.size.toHumanBytesSize()}"); + } + else + outputStream.write(body); val status = responseCode; Logger.i("SubsExchangeClient", "POST [${url}]: ${status}"); @@ -105,9 +122,9 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } } } - private suspend fun postAsync(query: String, body: String, contentType: String): String { + private suspend fun postAsync(query: String, body: ByteArray, contentType: String, gzip: Boolean = false): String { return withContext(Dispatchers.IO) { - post(query, body, contentType) + post(query, body, contentType, 0, gzip) } } diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt index ef2eadbc..4a545a26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -53,7 +53,7 @@ class ToggleBar : LinearLayout { this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); else this.setInfo(button.name, button.isActive, button.isButton); - this.onClick.subscribe { button.action(it); }; + this.onClick.subscribe({ view, enabled -> button.action(view, enabled); }); }); } } @@ -62,27 +62,27 @@ class ToggleBar : LinearLayout { val name: String; val icon: Int; val iconVariable: ImageVariable?; - val action: (Boolean)->Unit; + val action: (ToggleTagView, Boolean)->Unit; val isActive: Boolean; var isButton: Boolean = false private set; var tag: String? = null; - constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.iconVariable = icon; this.action = action; this.isActive = isActive; } - constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = icon; this.iconVariable = null; this.action = action; this.isActive = isActive; } - constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.iconVariable = null; diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 059405ad..3ba65413 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -12,8 +12,10 @@ import android.widget.TextView import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.images.GlideHelper import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.views.ToggleBar class ToggleTagView : LinearLayout { private val _root: FrameLayout; @@ -26,7 +28,7 @@ class ToggleTagView : LinearLayout { var isButton: Boolean = false private set; - var onClick = Event1(); + var onClick = Event2(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); @@ -36,7 +38,7 @@ class ToggleTagView : LinearLayout { _root.setOnClickListener { if(!isButton) setToggle(!isActive); - onClick.emit(isActive); + onClick.emit(this, isActive); } } @@ -52,6 +54,24 @@ class ToggleTagView : LinearLayout { } } + fun setInfo(toggle: ToggleBar.Toggle){ + _text = toggle.name; + _textTag.text = toggle.name; + setToggle(toggle.isActive); + if(toggle.iconVariable != null) { + toggle.iconVariable.setImageView(_image, R.drawable.ic_error_pred); + _image.visibility = View.GONE; + } + else if(toggle.icon > 0) { + _image.setImageResource(toggle.icon); + _image.visibility = View.GONE; + } + else + _image.visibility = View.VISIBLE; + _textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE; + this.isButton = isButton; + } + fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { _text = text; _textTag.text = text; diff --git a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt index 97eecac4..5cd2ad85 100644 --- a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt @@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout { for(button in buttons) { _tagsContainer.addView(ToggleTagView(context).apply { this.setInfo(button.name, button.isActive); - this.onClick.subscribe { button.action(it); }; + this.onClick.subscribe({ view, value -> button.action(view, value); }); }); } } @@ -166,16 +166,16 @@ class SubscriptionBar : LinearLayout { class Toggle { val name: String; val icon: Int; - val action: (Boolean)->Unit; + val action: (ToggleTagView, Boolean)->Unit; val isActive: Boolean; - constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = icon; this.action = action; this.isActive = isActive; } - constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.action = action; From ce2a2f85823ca90ca5e07780b8f74b2fb5c23bf1 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 7 Apr 2025 23:32:57 +0200 Subject: [PATCH 019/298] submods --- app/src/stable/assets/sources/odysee | 2 +- app/src/stable/assets/sources/soundcloud | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/odysee | 2 +- app/src/unstable/assets/sources/soundcloud | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index f2f83344..215cd9bd 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 +Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 diff --git a/app/src/stable/assets/sources/soundcloud b/app/src/stable/assets/sources/soundcloud index bff981c3..f8234d6a 160000 --- a/app/src/stable/assets/sources/soundcloud +++ b/app/src/stable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d +Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 331dd929..b61095ec 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 +Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index ae7b62f4..6f1266a0 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 +Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index f2f83344..215cd9bd 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 +Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 diff --git a/app/src/unstable/assets/sources/soundcloud b/app/src/unstable/assets/sources/soundcloud index bff981c3..f8234d6a 160000 --- a/app/src/unstable/assets/sources/soundcloud +++ b/app/src/unstable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d +Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 331dd929..b61095ec 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 +Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index ae7b62f4..6f1266a0 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 +Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 From 869b1fc15e4ab3665b9951f6460194e74ab2f372 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 8 Apr 2025 00:34:52 +0200 Subject: [PATCH 020/298] Fix pager for landscape --- .../platformplayer/fragment/mainactivity/main/FeedView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 17ca5510..3c915ebe 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 @@ -197,10 +197,12 @@ abstract class FeedView : L val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; + val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1; + val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows; if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { false; } - else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) { + else if (firstVisibleItemView != null && height != null && rowsHeight < height) { false; } else { true; From 1755d03a6bd402ba9980e4683ee0899e60be0b4a Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 9 Apr 2025 00:56:49 +0200 Subject: [PATCH 021/298] Fcast clearer connection/reconnection overlay, disable ipv6 by default --- .../futo/platformplayer/Extensions_Network.kt | 7 +++- .../java/com/futo/platformplayer/Settings.kt | 7 +++- .../java/com/futo/platformplayer/UIDialogs.kt | 11 ++++- .../casting/FCastCastingDevice.kt | 2 + .../platformplayer/casting/StateCasting.kt | 40 +++++++++++++++++-- .../dialogs/ConnectCastingDialog.kt | 4 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index 52d8a663..00f47885 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -216,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress { return InetAddress.getByAddress(this); } -fun getConnectedSocket(addresses: List, port: Int): Socket? { +fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? { val timeout = 2000 + + val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance() else attemptAddresses; + if(addresses.isEmpty()) + throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})"); + if (addresses.isEmpty()) { return null; } diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index cedc4316..2bd95905 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -583,10 +583,15 @@ class Settings : FragmentedStorageFileJson() { @Serializable(with = FlexibleBooleanSerializer::class) var keepScreenOn: Boolean = true; - @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1) + @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3) @Serializable(with = FlexibleBooleanSerializer::class) var alwaysProxyRequests: Boolean = false; + + @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4) + @Serializable(with = FlexibleBooleanSerializer::class) + var allowIpv6: Boolean = false; + /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index e84002da..8034854d 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -5,6 +5,7 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.Color +import android.graphics.drawable.Animatable import android.net.Uri import android.text.Layout import android.text.method.ScrollingMovementMethod @@ -199,16 +200,21 @@ class UIDialogs { dialog.show(); } - fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) { + fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { + return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions); + } + fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { val builder = AlertDialog.Builder(context); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); builder.setView(view); - + builder.setCancelable(defaultCloseAction > -2); val dialog = builder.create(); registerDialogOpened(dialog); view.findViewById(R.id.dialog_icon).apply { this.setImageResource(icon); + if(animated) + this.drawable.assume { it.start() }; } view.findViewById(R.id.dialog_text).apply { this.text = text; @@ -275,6 +281,7 @@ class UIDialogs { registerDialogClosed(dialog); } dialog.show(); + return dialog; } fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 9e12f78c..85b928c2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.casting import android.os.Looper import android.util.Base64 import android.util.Log +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage @@ -32,6 +33,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.math.BigInteger +import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket 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 e329a495..90177050 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.casting +import android.app.AlertDialog import android.content.ContentResolver import android.content.Context import android.net.Uri @@ -9,6 +10,7 @@ import android.util.Log import android.util.Xml import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi +import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient @@ -239,6 +241,9 @@ class StateCasting { Logger.i(TAG, "CastingService stopped.") } + private val _castingDialogLock = Any(); + private var _currentDialog: AlertDialog? = null; + @Synchronized fun connectDevice(device: CastingDevice) { if (activeDevice == device) @@ -272,10 +277,39 @@ class StateCasting { invokeInMainScopeIfRequired { StateApp.withContext(false) { context -> context.let { + Logger.i(TAG, "Casting state changed to ${castConnectionState}"); when (castConnectionState) { - CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device") - CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...") - CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device") + CastConnectionState.CONNECTED -> { + Logger.i(TAG, "Casting connected to [${device.name}]"); + UIDialogs.appToast("Connected to device") + synchronized(_castingDialogLock) { + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + } + CastConnectionState.CONNECTING -> { + Logger.i(TAG, "Casting connecting to [${device.name}]"); + UIDialogs.toast(it, "Connecting to device...") + synchronized(_castingDialogLock) { + if(_currentDialog == null) { + _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2, + UIDialogs.Action("Disconnect", { + device.stop(); + })); + } + } + } + CastConnectionState.DISCONNECTED -> { + UIDialogs.toast(it, "Disconnected from device") + synchronized(_castingDialogLock) { + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + } } } }; 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 bd5da2ea..8f3b836c 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -73,11 +73,11 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { }; _rememberedAdapter.onConnect.subscribe { _ -> dismiss() - UIDialogs.showCastingDialog(context) + //UIDialogs.showCastingDialog(context) } _adapter.onConnect.subscribe { _ -> dismiss() - UIDialogs.showCastingDialog(context) + //UIDialogs.showCastingDialog(context) } _recyclerRememberedDevices.adapter = _rememberedAdapter; _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af450660..e7c101c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,8 @@ Keep screen on while casting Always proxy requests Always proxy requests when casting data through the device. + Allow IPV6 + If casting over IPV6 is allowed, can cause issues on some networks Discover Find new video sources to add These sources have been disabled From a1c2d19daf1a6e859598421e27464e4e98388d4f Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 8 Apr 2025 17:58:31 -0500 Subject: [PATCH 022/298] refactor shorts code Changelog: changed --- .../fragment/mainactivity/main/ShortView.kt | 599 +++++++---------- .../mainactivity/main/ShortsFragment.kt | 32 +- .../views/overlays/WebviewOverlay.kt | 4 +- .../views/video/FutoShortPlayer.kt | 72 +- .../views/video/FutoVideoPlayerBase.kt | 2 +- app/src/main/res/layout/fragment_shorts.xml | 4 +- app/src/main/res/layout/modal_comments.xml | 619 ++++++++---------- app/src/main/res/layout/view_short.xml | 6 +- app/src/main/res/layout/view_short_player.xml | 6 +- app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 2 +- 11 files changed, 545 insertions(+), 802 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt index 2a9f954f..1bef3548 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -12,11 +12,9 @@ import android.os.Bundle import android.text.Spanned import android.util.AttributeSet import android.util.TypedValue -import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.SoundEffectConstants import android.view.View -import android.view.ViewGroup import android.widget.Button import android.widget.FrameLayout import android.widget.ImageView @@ -29,16 +27,13 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -51,7 +46,6 @@ import androidx.compose.material.icons.filled.ThumbDownOffAlt import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material.icons.filled.ThumbUpOffAlt import androidx.compose.material.icons.outlined.Share -import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.ripple.RippleAlpha @@ -74,7 +68,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.platform.ComposeView @@ -101,6 +94,7 @@ import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +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.PlatformAuthorMembershipLink @@ -120,11 +114,10 @@ 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.platforms.js.SourcePluginConfig -import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event3 +import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.ScriptAgeException @@ -173,7 +166,6 @@ import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.theme.overlay.MaterialThemeOverlay import com.google.protobuf.ByteString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -184,7 +176,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import userpackage.Protocol import java.time.OffsetDateTime -import kotlin.contracts.Effect import kotlin.coroutines.cancellation.CancellationException @OptIn(ExperimentalMaterial3Api::class) @@ -207,8 +198,6 @@ class ShortView : ConstraintLayout { private var playWhenReady = false -// private var playerAttached = false - private var _lastVideoSource: IVideoSource? = null private var _lastAudioSource: IAudioSource? = null private var _lastSubtitleSource: ISubtitleSource? = null @@ -285,54 +274,55 @@ class ShortView : ConstraintLayout { setupComposeView() } + // TODO merge this with the updateQualitySourcesOverlay for the normal video player @androidx.annotation.OptIn(UnstableApi::class) private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { - Logger.i(TAG, "updateQualitySourcesOverlay"); + Logger.i(TAG, "updateQualitySourcesOverlay") - val video: IPlatformVideoDetails?; - val localVideoSources: List?; - val localAudioSource: List?; - val localSubtitleSources: List?; + val video: IPlatformVideoDetails? + val localVideoSources: List? + val localAudioSource: List? + val localSubtitleSources: List? - val videoSources: List?; - val audioSources: List?; + val videoSources: List? + val audioSources: List? if (videoDetails is VideoLocal) { - video = videoLocal?.videoSerialized; - localVideoSources = videoDetails.videoSource.toList(); - localAudioSource = videoDetails.audioSource.toList(); - localSubtitleSources = videoDetails.subtitlesSources.toList(); + video = videoLocal?.videoSerialized + localVideoSources = videoDetails.videoSource.toList() + localAudioSource = videoDetails.audioSource.toList() + localSubtitleSources = videoDetails.subtitlesSources.toList() videoSources = null - audioSources = null; + audioSources = null } else { - video = videoDetails; - videoSources = video?.video?.videoSources?.toList(); + video = videoDetails + videoSources = video?.video?.videoSources?.toList() audioSources = if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() else null if (videoLocal != null) { - localVideoSources = videoLocal.videoSource.toList(); - localAudioSource = videoLocal.audioSource.toList(); - localSubtitleSources = videoLocal.subtitlesSources.toList(); + localVideoSources = videoLocal.videoSource.toList() + localAudioSource = videoLocal.audioSource.toList() + localSubtitleSources = videoLocal.subtitlesSources.toList() } else { - localVideoSources = null; - localAudioSource = null; - localSubtitleSources = null; + localVideoSources = null + localAudioSource = null + localSubtitleSources = null } } - val doDedup = Settings.instance.playback.simplifySources; + val doDedup = Settings.instance.playback.simplifySources val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct() ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct() ?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf() val bestAudioContainer = - audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container }; + audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container } val bestAudioSources = if (doDedup) audioSources?.filter { it.container == bestAudioContainer } ?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource }) - ?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); + ?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf() val canSetSpeed = true val currentPlaybackRate = player.getPlaybackRate() @@ -340,117 +330,94 @@ class ShortView : ConstraintLayout { SlideUpMenuOverlay(this.context, overlayQualityContainer, context.getString( R.string.quality ), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { - setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate!!.toString()); + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString()) onClick.subscribe { v -> - player.setPlaybackRate(v.toFloat()); - setSelected(v); + player.setPlaybackRate(v.toFloat()) + setSelected(v) - }; - } else null, - - if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map { - SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) }); - }.toList().toTypedArray() - ) - else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map { - SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) }); - }.toList().toTypedArray() - ) - else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map { - SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) - }.toList().toTypedArray() - ) - else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup( - this.context, context.getString(R.string.stream_video), "video", (listOf( - SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) }) - ) + (liveStreamVideoFormats.map { - SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label - ?: it.containerMimeType - ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) }); - })) - ) - else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup( - this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map { - SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) }); - }.toList().toTypedArray() - ) - else null, - - if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup( - this.context, context.getString(R.string.video), "video", *bestVideoSources.map { - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = - if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) }); - }.toList().toTypedArray() - ) - else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup( - this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map { - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = - if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) }); - }.toList().toTypedArray() - ) - else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map { - SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) - }.toList().toTypedArray() - ) - else null - ); + } + } else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_video), "video", (listOf( + SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) }) + ) + (liveStreamVideoFormats.map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label + ?: it.containerMimeType + ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) }) + })) + ) + else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) }) + }.toList().toTypedArray() + ) + else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.video), "video", *bestVideoSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null + ) } private fun handleSelectVideoTrack(videoSource: IVideoSource) { Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)") - val video = videoDetails ?: return; + if (_lastVideoSource == videoSource) return - if (_lastVideoSource == videoSource) return; - - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else if (!player.swapSources(videoSource, _lastAudioSource, true, true, true)) player.hideControls(false); //TODO: Disable player? - - _lastVideoSource = videoSource; + _lastVideoSource = videoSource } private fun handleSelectAudioTrack(audioSource: IAudioSource) { Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)") - val video = videoDetails ?: return; + if (_lastAudioSource == audioSource) return - if (_lastAudioSource == audioSource) return; - - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else (!player.swapSources(_lastVideoSource, audioSource, true, true, true)) - player.hideControls(false); //TODO: Disable player? - - _lastAudioSource = audioSource; + _lastAudioSource = audioSource } private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)") - val video = videoDetails ?: return; - var toSet: ISubtitleSource? = subtitleSource - if (_lastSubtitleSource == subtitleSource) toSet = null; + if (_lastSubtitleSource == subtitleSource) toSet = null - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else player.swapSubtitles(mainFragment!!.lifecycleScope, toSet); + player.swapSubtitles(mainFragment.lifecycleScope, toSet) - _lastSubtitleSource = toSet; + _lastSubtitleSource = toSet } private fun showVideoSettings() { Logger.i(TAG, "showVideoSettings") - overlayQualitySelector?.selectOption("video", _lastVideoSource); - overlayQualitySelector?.selectOption("audio", _lastAudioSource); - overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource); + overlayQualitySelector?.selectOption("video", _lastVideoSource) + overlayQualitySelector?.selectOption("audio", _lastAudioSource) + overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource) if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) { @@ -484,19 +451,18 @@ class ShortView : ConstraintLayout { } } - val currentPlaybackRate = player.getPlaybackRate() ?: 1.0 + val currentPlaybackRate = player.getPlaybackRate() overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" } ?.let { (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) - }; + } - overlayQualitySelector?.show(); -// _slideUpOverlay = overlayQualitySelector; + overlayQualitySelector?.show() } @OptIn(ExperimentalGlideComposeApi::class) private fun setupComposeView() { - val composeView: ComposeView = findViewById(R.id.compose_view_test_button) + val composeView: ComposeView = findViewById(R.id.shorts_overlay_content_compose_view) composeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -554,8 +520,7 @@ class ShortView : ConstraintLayout { val tint = Color.White val buttonTextStyle = TextStyle( - fontSize = 12.sp, - shadow = Shadow( + fontSize = 12.sp, shadow = Shadow( color = Color.Black, blurRadius = 3f ) ) @@ -571,27 +536,21 @@ class ShortView : ConstraintLayout { ConstraintLayout(modifier = Modifier.fillMaxSize()) { val (title, buttons) = createRefs() -// val horizontalChain = createHorizontalChain(title, buttons, chainStyle = ChainStyle.SpreadInside) Box(modifier = Modifier.constrainAs(title) { -// top.linkTo(parent.top) bottom.linkTo(parent.bottom, margin = 16.dp) start.linkTo(parent.start, margin = 8.dp) end.linkTo(buttons.start) width = Dimension.fillToConstraints - } -// .fillMaxWidth() - ) { + }) { Column( - modifier = Modifier - .align(Alignment.BottomStart), - verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.align(Alignment.BottomStart), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.clickable(onClick = { view.playSoundEffect(SoundEffectConstants.CLICK) - mainFragment!!.navigate(currentVideo?.author) + mainFragment.navigate(currentVideo?.author) }), ) { @@ -601,27 +560,23 @@ class ShortView : ConstraintLayout { .clip(CircleShape) ) Text( - currentVideo?.author?.name ?: "", - color = tint, - fontSize = 14.sp + currentVideo?.author?.name + ?: "", color = tint, fontSize = 14.sp ) } Text( currentVideo?.name - ?: "", color = tint, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontSize = 14.sp + ?: "", color = tint, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 14.sp ) } } Box(modifier = Modifier.constrainAs(buttons) { -// top.linkTo(parent.top) bottom.linkTo(parent.bottom, margin = 16.dp) start.linkTo(title.end, margin = 12.dp) end.linkTo(parent.end, margin = 4.dp) marginBottom - }) { CompositionLocalProvider(LocalRippleConfiguration provides rippleConfiguration) { Column( @@ -667,10 +622,7 @@ class ShortView : ConstraintLayout { } Text( likes.toString(), color = tint, - modifier = Modifier - .align(Alignment.BottomCenter) -// .offset(y = buttonOffset) - , + modifier = Modifier.align(Alignment.BottomCenter), style = buttonTextStyle, ) } @@ -713,30 +665,30 @@ class ShortView : ConstraintLayout { } Text( dislikes.toString(), color = tint, - modifier = Modifier - .align(Alignment.BottomCenter), + modifier = Modifier.align(Alignment.BottomCenter), style = buttonTextStyle, ) } } Box { IconButton( - modifier = Modifier.padding(bottom = buttonOffset).align(Alignment.TopCenter), + modifier = Modifier + .padding(bottom = buttonOffset) + .align(Alignment.TopCenter), onClick = { view.playSoundEffect(SoundEffectConstants.CLICK) - bottomSheet.show(mainFragment!!.childFragmentManager, CommentsModalBottomSheet.TAG) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } }, ) { Icon( Icons.AutoMirrored.Outlined.Comment, contentDescription = "View Comments", tint = tint, - ) + ) } Text( - "Comments", color = tint, - modifier = Modifier - .align(Alignment.BottomCenter), - style = buttonTextStyle + "Comments", color = tint, modifier = Modifier.align(Alignment.BottomCenter), style = buttonTextStyle ) } Box { @@ -744,12 +696,13 @@ class ShortView : ConstraintLayout { modifier = Modifier.padding(bottom = buttonOffset), onClick = { view.playSoundEffect(SoundEffectConstants.CLICK) - val url = currentVideo?.shareUrl ?: currentVideo?.url - mainFragment!!.startActivity(Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND; + val url = + currentVideo?.shareUrl ?: currentVideo?.url + mainFragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, url) type = "text/plain" - }, null)); + }, null)) }, ) { Icon( @@ -758,8 +711,7 @@ class ShortView : ConstraintLayout { } Text( "Share", color = tint, - modifier = Modifier - .align(Alignment.BottomCenter), + modifier = Modifier.align(Alignment.BottomCenter), style = buttonTextStyle, ) } @@ -778,8 +730,7 @@ class ShortView : ConstraintLayout { } Text( "Refresh", color = tint, - modifier = Modifier - .align(Alignment.BottomCenter), + modifier = Modifier.align(Alignment.BottomCenter), style = buttonTextStyle, ) } @@ -797,8 +748,7 @@ class ShortView : ConstraintLayout { } Text( "Quality", color = tint, - modifier = Modifier - .align(Alignment.BottomCenter), + modifier = Modifier.align(Alignment.BottomCenter), style = buttonTextStyle, ) } @@ -822,9 +772,6 @@ class ShortView : ConstraintLayout { modifier = Modifier .size(94.dp) .background(color = Color.Black.copy(alpha = 0.7f), shape = CircleShape), contentAlignment = Alignment.Center -// .padding(32.dp), // Pad the entire box - - ) { Icon( imageVector = if (isPlaying) Icons.Rounded.PlayArrow @@ -836,7 +783,7 @@ class ShortView : ConstraintLayout { // Auto-hide the icon after a short delay LaunchedEffect(showPlayPauseIcon) { if (showPlayPauseIcon) { - delay(1500) // Icon visible for 1.5 seconds + delay(1500) showPlayPauseIcon = false } } @@ -849,6 +796,7 @@ class ShortView : ConstraintLayout { } } + @Suppress("unused") fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { this.mainFragment = fragment this.bottomSheet.mainFragment = fragment @@ -864,6 +812,7 @@ class ShortView : ConstraintLayout { loadVideo(video.url) } + @Suppress("unused") fun changeVideo(videoDetails: IPlatformVideoDetails) { if (video?.url == videoDetails.url) { return @@ -874,31 +823,16 @@ class ShortView : ConstraintLayout { } fun play() { -// if (playerAttached){ -// throw Exception() -// } -// playerAttached = true loadLikes(this.video!!) player.attach() -// if (_lastVideoSource != null || _lastAudioSource != null) { -// if (!player.activelyPlaying) { -// player.play() -// } -// } else { playVideo() -// } } - // fun pause() { player.pause() } fun stop() { -// if (!playerAttached) { -// return -// } -// playerAttached = false playWhenReady = false player.clear() @@ -928,7 +862,7 @@ class ShortView : ConstraintLayout { val extraBytesRef = video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null } - mainFragment!!.lifecycleScope.launch(Dispatchers.IO) { + mainFragment.lifecycleScope.launch(Dispatchers.IO) { try { val queryReferencesResponse = ApiMethods.getQueryReferences( ApiMethods.SERVER, ref, null, null, arrayListOf( @@ -941,31 +875,29 @@ class ShortView : ConstraintLayout { ByteString.copyFrom(Opinion.dislike.data) ).build() ), extraByteReferences = listOfNotNull(extraBytesRef) - ); + ) - val likes = queryReferencesResponse.countsList[0]; - val dislikes = queryReferencesResponse.countsList[1]; - val hasLiked = - StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; - val hasDisliked = - StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + val likes = queryReferencesResponse.countsList[0] + val dislikes = queryReferencesResponse.countsList[1] + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) withContext(Dispatchers.Main) { onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked) onLikeDislikeUpdated.subscribe(this) { args -> if (args.hasLiked) { - args.processHandle.opinion(ref, Opinion.like); + args.processHandle.opinion(ref, Opinion.like) } else if (args.hasDisliked) { - args.processHandle.opinion(ref, Opinion.dislike); + args.processHandle.opinion(ref, Opinion.dislike) } else { - args.processHandle.opinion(ref, Opinion.neutral); + args.processHandle.opinion(ref, Opinion.neutral) } - mainFragment!!.lifecycleScope.launch(Dispatchers.IO) { + mainFragment.lifecycleScope.launch(Dispatchers.IO) { try { - Logger.i(CommentsModalBottomSheet.Companion.TAG, "Started backfill"); - args.processHandle.fullyBackfillServersAnnounceExceptions(); - Logger.i(CommentsModalBottomSheet.Companion.TAG, "Finished backfill"); + Logger.i(CommentsModalBottomSheet.Companion.TAG, "Started backfill") + args.processHandle.fullyBackfillServersAnnounceExceptions() + Logger.i(CommentsModalBottomSheet.Companion.TAG, "Finished backfill") } catch (e: Throwable) { Logger.e(CommentsModalBottomSheet.Companion.TAG, "Failed to backfill servers", e) } @@ -974,10 +906,10 @@ class ShortView : ConstraintLayout { StatePolycentric.instance.updateLikeMap( ref, args.hasLiked, args.hasDisliked ) - }; + } } } catch (e: Throwable) { - Logger.e(CommentsModalBottomSheet.Companion.TAG, "Failed to get polycentric likes/dislikes.", e); + Logger.e(CommentsModalBottomSheet.Companion.TAG, "Failed to get polycentric likes/dislikes.", e) } } } @@ -1120,7 +1052,7 @@ class ShortView : ConstraintLayout { }) else player.setArtwork(null) player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) - if (subtitleSource != null) player.swapSubtitles(mainFragment!!.lifecycleScope, subtitleSource) + if (subtitleSource != null) player.swapSubtitles(mainFragment.lifecycleScope, subtitleSource) player.seekTo(resumePositionMs) _lastVideoSource = videoSource @@ -1149,19 +1081,18 @@ class ShortView : ConstraintLayout { private lateinit var containerContentSupport: SupportOverlay private lateinit var title: TextView - private lateinit var subTitle: TextView; + private lateinit var subTitle: TextView private lateinit var channelName: TextView private lateinit var channelMeta: TextView private lateinit var creatorThumbnail: CreatorThumbnail - private lateinit var channelButton: LinearLayout; + private lateinit var channelButton: LinearLayout private lateinit var monetization: MonetizationView - private lateinit var platform: PlatformIndicator; - private lateinit var textLikes: TextView; - private lateinit var textDislikes: TextView; - private lateinit var layoutRating: LinearLayout; - private lateinit var imageDislikeIcon: ImageView; - private lateinit var imageLikeIcon: ImageView; -// private lateinit var buttonSubscribe: SubscribeButton + private lateinit var platform: PlatformIndicator + private lateinit var textLikes: TextView + private lateinit var textDislikes: TextView + private lateinit var layoutRating: LinearLayout + private lateinit var imageDislikeIcon: ImageView + private lateinit var imageLikeIcon: ImageView private lateinit var description: TextView private lateinit var descriptionContainer: LinearLayout @@ -1183,49 +1114,22 @@ class ShortView : ConstraintLayout { private lateinit var behavior: BottomSheetBehavior -// override fun getTheme(): Int { -// return R.style.CustomBottomSheetDialog -// } - -// override fun getTheme(): Int = R.style.CustomBottomSheetTheme - -// override fun onCreateView( -// inflater: LayoutInflater, -// container: ViewGroup?, -// savedInstanceState: Bundle? -// ): View? { -// -//// container. -// -// val bottomSheetDialog = inflater.inflate(R.layout.modal_comments, container, false) + private val _taskLoadPolycentricProfile = + TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { it -> setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it) + } override fun onCreateDialog( savedInstanceState: Bundle?, ): Dialog { - val bottomSheetDialog = BottomSheetDialog( -// mainFragment!!.context!!, -// MaterialThemeOverlay.wrap(requireContext()) -// ContextThemeWrapper(requireContext(), ), -// ContextThemeWrapper( requireContext(), R.style.ThemeOverlay_App_Material3_BottomSheetDialog) - requireContext(),R.style.Custom_BottomSheetDialog_Theme -// MaterialThemeOverlay.wrap(requireContext()) -// ContextThemeWrapper(requireContext(), R.style.BottomSheetDialog_Rounded) -// R.style.ThemeOverlay_App_Material3_BottomSheetDialog -// BottomSheet -// R.style.CustomBottomSheetTheme -// R.style.ThemeOverlay_Cata -// R.style.ThemeOverlay_Catalog_BottomSheetDialog_Scrollable - //com.google.android.material.R.style.Animation_Design_BottomSheetDialog - ) + val bottomSheetDialog = + BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme) bottomSheetDialog.setContentView(R.layout.modal_comments) -// bottomSheetDialog.behavior. - behavior = bottomSheetDialog.behavior -// val composeView = bottomSheetDialog.findViewById(R.id.compose_view) - - containerContent = bottomSheetDialog.findViewById(R.id.contentContainer)!! + containerContent = bottomSheetDialog.findViewById(R.id.content_container)!! containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! containerContentReplies = bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! @@ -1248,8 +1152,6 @@ class ShortView : ConstraintLayout { imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! -// buttonSubscribe = bottomSheetDialog.findViewById(R.id.button_subscribe)!! - description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! descriptionContainer = bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! @@ -1263,45 +1165,42 @@ class ShortView : ConstraintLayout { commentsList.onAuthorClick.subscribe { c -> if (c !is PolycentricPlatformComment) { - return@subscribe; + return@subscribe } + val id = c.author.id.value - Logger.i(TAG, "onAuthorClick: " + c.author.id.value); - if (c.author.id.value?.startsWith("polycentric://") ?: false) { - val navUrl = - "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length); - //val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length); + Logger.i(TAG, "onAuthorClick: $id") + if (id != null && id.startsWith("polycentric://") == true) { + val navUrl = "https://harbor.social/" + id.substring("polycentric://".length) mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl))) - //_container_content_browser.goto(navUrl); - //switchContentView(_container_content_browser); } } commentsList.onRepliesClick.subscribe { c -> - val replyCount = c.replyCount ?: 0; - var metadata = ""; + val replyCount = c.replyCount ?: 0 + var metadata = "" if (replyCount > 0) { - metadata += "$replyCount " + requireContext().getString(R.string.replies); + metadata += "$replyCount " + requireContext().getString(R.string.replies) } if (c is PolycentricPlatformComment) { - var parentComment: PolycentricPlatformComment = c; + var parentComment: PolycentricPlatformComment = c containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, { val newComment = parentComment.cloneWithUpdatedReplyCount( (parentComment.replyCount ?: 0) + 1 - ); - commentsList.replaceComment(parentComment, newComment); - parentComment = newComment; - }); + ) + commentsList.replaceComment(parentComment, newComment) + parentComment = newComment + }) } else { - containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }); + containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }) } - animateOpenOverlayView(containerContentReplies); + animateOpenOverlayView(containerContentReplies) } if (StatePolycentric.instance.enabled) { buttonPolycentric.setOnClickListener { - setTabIndex(0); - StateMeta.instance.setLastCommentSection(0); + setTabIndex(0) + StateMeta.instance.setLastCommentSection(0) } } else { buttonPolycentric.visibility = GONE @@ -1309,7 +1208,7 @@ class ShortView : ConstraintLayout { buttonPlatform.setOnClickListener { setTabIndex(1) - StateMeta.instance.setLastCommentSection(1); + StateMeta.instance.setLastCommentSection(1) } val ref = Models.referenceFromBuffer(video.url.toByteArray()) @@ -1325,10 +1224,6 @@ class ShortView : ConstraintLayout { } } -// val layoutTop: LinearLayout = bottomSheetDialog.findViewById(R.id.layout_top)!! -// containerContentMain.removeView(layoutTop) -// commentsList.setPrependedView(layoutTop) - containerContentDescription.onClose.subscribe { animateCloseOverlayView() } containerContentReplies.onClose.subscribe { animateCloseOverlayView() } @@ -1344,89 +1239,89 @@ class ShortView : ConstraintLayout { TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) //UI - title.text = video.name; - channelName.text = video.author.name; + title.text = video.name + channelName.text = video.author.name if (video.author.subscribers != null) { channelMeta.text = if ((video.author.subscribers ?: 0) > 0 - ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else ""; + ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else "" (channelName.layoutParams as MarginLayoutParams).setMargins( 0, (dp5 * -1).toInt(), 0, 0 - ); + ) } else { - channelMeta.text = ""; - (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0); + channelMeta.text = "" + (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0) } - video.author.let { - if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl); - else monetization.setPlatformMembership(null, null); + if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl) + else monetization.setPlatformMembership(null, null) } - val subTitleSegments: ArrayList = ArrayList(); - if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}"); + val subTitleSegments: ArrayList = ArrayList() + if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}") if (video.datetime != null) { - val diff = video.datetime?.getNowDiffSeconds() ?: 0; + val diff = video.datetime?.getNowDiffSeconds() ?: 0 val ago = video.datetime?.toHumanNowDiffString(true) - if (diff >= 0) subTitleSegments.add("${ago} ago"); - else subTitleSegments.add("available in ${ago}"); + if (diff >= 0) subTitleSegments.add("$ago ago") + else subTitleSegments.add("available in $ago") } - platform.setPlatformFromClientID(video.id.pluginId); - subTitle.text = subTitleSegments.joinToString(" • "); - creatorThumbnail.setThumbnail(video.author.thumbnail, false); + platform.setPlatformFromClientID(video.id.pluginId) + subTitle.text = subTitleSegments.joinToString(" • ") + creatorThumbnail.setThumbnail(video.author.thumbnail, false) - setPolycentricProfile(null, animate = false); + setPolycentricProfile(null, animate = false) + _taskLoadPolycentricProfile.run(video.author.id) when (video.rating) { is RatingLikeDislikes -> { - val r = video.rating as RatingLikeDislikes; - layoutRating.visibility = View.VISIBLE; + val r = video.rating as RatingLikeDislikes + layoutRating.visibility = VISIBLE - textLikes.visibility = View.VISIBLE; - imageLikeIcon.visibility = View.VISIBLE; - textLikes.text = r.likes.toHumanNumber(); + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() - imageDislikeIcon.visibility = View.VISIBLE; - textDislikes.visibility = View.VISIBLE; - textDislikes.text = r.dislikes.toHumanNumber(); + imageDislikeIcon.visibility = VISIBLE + textDislikes.visibility = VISIBLE + textDislikes.text = r.dislikes.toHumanNumber() } is RatingLikes -> { - val r = video.rating as RatingLikes; - layoutRating.visibility = View.VISIBLE; + val r = video.rating as RatingLikes + layoutRating.visibility = VISIBLE - textLikes.visibility = View.VISIBLE; - imageLikeIcon.visibility = View.VISIBLE; - textLikes.text = r.likes.toHumanNumber(); + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() - imageDislikeIcon.visibility = View.GONE; - textDislikes.visibility = View.GONE; + imageDislikeIcon.visibility = GONE + textDislikes.visibility = GONE } else -> { - layoutRating.visibility = View.GONE; + layoutRating.visibility = GONE } } monetization.onSupportTap.subscribe { - containerContentSupport.setPolycentricProfile(polycentricProfile); + containerContentSupport.setPolycentricProfile(polycentricProfile) animateOpenOverlayView(containerContentSupport) - }; + } monetization.onStoreTap.subscribe { polycentricProfile?.systemState?.store?.let { try { - val uri = Uri.parse(it); - val intent = Intent(Intent.ACTION_VIEW); - intent.data = uri; - requireContext().startActivity(intent); + val uri = Uri.parse(it) + val intent = Intent(Intent.ACTION_VIEW) + intent.data = uri + requireContext().startActivity(intent) } catch (e: Throwable) { - Logger.e(TAG, "Failed to open URI: '${it}'.", e); + Logger.e(TAG, "Failed to open URI: '${it}'.", e) } } - }; + } monetization.onUrlTap.subscribe { mainFragment!!.navigate(it) } @@ -1436,25 +1331,9 @@ class ShortView : ConstraintLayout { } channelButton.setOnClickListener { - mainFragment!!.navigate(video.author); - }; + mainFragment!!.navigate(video.author) + } -// composeView?.apply { -// setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) -// setContent { -// // In Compose world -// MaterialTheme { -// val view = LocalView.current -// IconButton(onClick = { -// view.playSoundEffect(SoundEffectConstants.CLICK) -// }) { -// Icon( -// Icons.Outlined.ThumbUp, contentDescription = "Close Bottom Sheet" -// ) -// } -// } -// } -// } return bottomSheetDialog } @@ -1466,15 +1345,15 @@ class ShortView : ConstraintLayout { private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { polycentricProfile = profile - val dp_35 = 35.dp(requireContext().resources) - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35) + val dp35 = 35.dp(requireContext().resources) + val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35) ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } if (avatar != null) { - creatorThumbnail.setThumbnail(avatar, animate); + creatorThumbnail.setThumbnail(avatar, animate) } else { - creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate); - creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); + creatorThumbnail.setThumbnail(video.author.thumbnail, animate) + creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()) } val username = profile?.systemState?.username @@ -1482,7 +1361,7 @@ class ShortView : ConstraintLayout { channelName.text = username } - monetization.setPolycentricProfile(profile); + monetization.setPolycentricProfile(profile) } private fun setTabIndex(index: Int?, forceReload: Boolean = false) { @@ -1493,35 +1372,19 @@ class ShortView : ConstraintLayout { } tabIndex = index -// _buttonRecommended.setTextColor(resources.getColor(if (index == 2) R.color.white else R.color.gray_ac)) - buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac)) - buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac)) -// layoutRecommended.removeAllViews() + buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null)) + buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null)) if (index == null) { - addCommentView.visibility = View.GONE + addCommentView.visibility = GONE commentsList.clear() -// _layoutRecommended.visibility = View.GONE } else if (index == 0) { - addCommentView.visibility = View.VISIBLE -// _layoutRecommended.visibility = View.GONE + addCommentView.visibility = VISIBLE fetchPolycentricComments() } else if (index == 1) { - addCommentView.visibility = View.GONE -// _layoutRecommended.visibility = View.GONE + addCommentView.visibility = GONE fetchComments() } -// else if (index == 2) { -// _addCommentView.visibility = View.GONE -// _layoutRecommended.visibility = View.VISIBLE -// _commentsList.clear() -// -// _layoutRecommended.addView(LoaderView(context).apply { -// layoutParams = LinearLayout.LayoutParams(60.dp(resources), 60.dp(resources)) -// start() -// }) -// _taskLoadRecommendations.run(null) -// } } private fun fetchComments() { @@ -1533,9 +1396,9 @@ class ShortView : ConstraintLayout { private fun fetchPolycentricComments() { Logger.i(TAG, "fetchPolycentricComments") - val video = video; - val idValue = video?.id?.value - if (video?.url?.isEmpty() != false) { + val video = video + val idValue = video.id.value + if (video.url.isEmpty() != false) { Logger.w(TAG, "Failed to fetch polycentric comments because url was null") commentsList.clear() return @@ -1543,7 +1406,7 @@ class ShortView : ConstraintLayout { val ref = Models.referenceFromBuffer(video.url.toByteArray()) val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } - commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); }; + commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); } } private fun updateDescriptionUI(text: Spanned) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt index 80322e5e..04eb3cba 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -16,11 +16,11 @@ import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlatform import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,9 +43,6 @@ class ShortsFragment : MainFragment() { private lateinit var overlayLoadingSpinner: ImageView private lateinit var overlayQualityContainer: FrameLayout private lateinit var customViewAdapter: CustomViewAdapter - private val urls = listOf( - "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://www.youtube.com/watch?v=MXHSS-7XcBc", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra", "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra" - ) init { loadPager() @@ -60,10 +57,10 @@ class ShortsFragment : MainFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewPager = view.findViewById(R.id.viewPager) + viewPager = view.findViewById(R.id.view_pager) overlayLoading = view.findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = view.findViewById(R.id.short_view_loader) - overlayQualityContainer = view.findViewById(R.id.videodetail_quality_overview) + overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview) setLoading(true) @@ -72,12 +69,14 @@ class ShortsFragment : MainFragment() { } loadPagerJob!!.invokeOnCompletion { - customViewAdapter = CustomViewAdapter(videos, layoutInflater, this@ShortsFragment, overlayQualityContainer) { - if (!shortsPager!!.hasMorePages()) { - return@CustomViewAdapter + Logger.i(TAG, "Creating adapter") + customViewAdapter = + CustomViewAdapter(videos, layoutInflater, this@ShortsFragment, overlayQualityContainer) { + if (!shortsPager!!.hasMorePages()) { + return@CustomViewAdapter + } + nextPage() } - nextPage() - } customViewAdapter.onResetTriggered.subscribe { setLoading(true) loadPager() @@ -88,7 +87,6 @@ class ShortsFragment : MainFragment() { val viewPager = viewPager!! viewPager.adapter = customViewAdapter - // TODO something is laggy sometimes when swiping between videos viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { @OptIn(UnstableApi::class) override fun onPageSelected(position: Int) { @@ -96,7 +94,8 @@ class ShortsFragment : MainFragment() { adapter.previousShownView?.stop() adapter.previousShownView = null -// viewPager.post { + // the post prevents lag when swiping + viewPager.post { val recycler = (viewPager.getChildAt(0) as RecyclerView) val viewHolder = recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? @@ -108,10 +107,8 @@ class ShortsFragment : MainFragment() { focusedView.play() adapter.previousShownView = focusedView } -// } + } } - - }) setLoading(false) } @@ -154,12 +151,9 @@ class ShortsFragment : MainFragment() { viewPager?.currentItem = 0 loadPagerJob = CoroutineScope(Dispatchers.Main).launch { -// delay(5000) val pager = try { withContext(Dispatchers.IO) { StatePlatform.instance.getShorts() -// StatePlatform.instance.getHome() - // as IPager } } catch (_: CancellationException) { return@launch diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt index 27befb1e..0dba3c4f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt @@ -19,7 +19,9 @@ class WebviewOverlay : LinearLayout { inflate(context, R.layout.overlay_webview, this) _topbar = findViewById(R.id.topbar); _webview = findViewById(R.id.webview); - _webview.settings.javaScriptEnabled = true; + if (!isInEditMode){ + _webview.settings.javaScriptEnabled = true; + } _topbar.onClose.subscribe(this, onClose::emit); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt index 8ff562b6..61cf886a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -2,28 +2,25 @@ package com.futo.platformplayer.views.video import android.animation.ValueAnimator import android.content.Context -import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.animation.LinearInterpolator import androidx.annotation.OptIn +import androidx.media3.common.C import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.upstream.DefaultAllocator import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.PlayerView import androidx.media3.ui.TimeBar -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R -import com.futo.platformplayer.Settings -import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails -import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.video.PlayerManager @UnstableApi class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : @@ -35,23 +32,9 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : } private var playerAttached = false -// private set; - private val videoView: PlayerView private val progressBar: DefaultTimeBar - - private val loadArtwork = object : CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - setArtwork(BitmapDrawable(resources, resource)) - } - - override fun onLoadCleared(placeholder: Drawable?) { - setArtwork(null) - } - } - - private val player = StatePlayer.instance.getShortPlayerOrCreate(context) - + private lateinit var player: PlayerManager private var progressAnimator: ValueAnimator = createProgressBarAnimator() private var playerEventListener = object : Player.Listener { @@ -67,10 +50,9 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : } if (player.isPlaying) { - if (progressAnimator.isPaused){ + if (progressAnimator.isPaused) { progressAnimator.resume() - } - else if (!progressAnimator.isStarted) { + } else if (!progressAnimator.isStarted) { progressAnimator.start() } } else { @@ -84,10 +66,13 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : init { LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true) - videoView = findViewById(R.id.video_player) - progressBar = findViewById(R.id.video_player_progress_bar) + videoView = findViewById(R.id.short_player_view) + progressBar = findViewById(R.id.short_player_progress_bar) - player.player.repeatMode = Player.REPEAT_MODE_ONE + if (!isInEditMode) { + player = StatePlayer.instance.getShortPlayerOrCreate(context) + player.player.repeatMode = Player.REPEAT_MODE_ONE + } progressBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { @@ -148,30 +133,6 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : player.detach() } - fun setPreview(video: IPlatformVideoDetails) { - if (video.live != null) { - setSource(video.live, null, play = true, keepSubtitles = false) - } else { - val videoSource = - VideoHelper.selectBestVideoSource(video.video, Settings.instance.playback.getPreferredPreviewQualityPixelCount(), PREFERED_VIDEO_CONTAINERS) - val audioSource = - VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context)) - if (videoSource == null && audioSource != null) { - val thumbnail = video.thumbnails.getHQThumbnail() - if (!thumbnail.isNullOrBlank()) { - Glide.with(videoView).asBitmap().load(thumbnail).into(loadArtwork) - } else { - Glide.with(videoView).clear(loadArtwork) - setArtwork(null) - } - } else { - Glide.with(videoView).clear(loadArtwork) - } - - setSource(videoSource, audioSource, play = true, keepSubtitles = false) - } - } - @OptIn(UnstableApi::class) fun setArtwork(drawable: Drawable?) { if (drawable != null) { @@ -194,9 +155,4 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : val param = PlaybackParameters(playbackRate) exoPlayer?.playbackParameters = param } - - // TODO remove stub - fun hideControls(stub: Boolean) { - - } } 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 c3eddb12..a7cf04e8 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 @@ -72,7 +72,7 @@ import kotlin.math.abs abstract class FutoVideoPlayerBase : ConstraintLayout { private val TAG = "FutoVideoPlayerBase" - private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); +// private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); private var _mediaSource: MediaSource? = null; diff --git a/app/src/main/res/layout/fragment_shorts.xml b/app/src/main/res/layout/fragment_shorts.xml index 7d6086da..25119232 100644 --- a/app/src/main/res/layout/fragment_shorts.xml +++ b/app/src/main/res/layout/fragment_shorts.xml @@ -5,7 +5,7 @@ android:layout_height="match_parent"> @@ -29,7 +29,7 @@ - - + android:background="@color/black" + android:orientation="vertical" + tools:ignore="SpeakableTextPresentCheck"> + android:layout_height="wrap_content" + android:orientation="vertical"> + + android:orientation="horizontal"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + android:layout_marginStart="14dp" + android:layout_marginEnd="14dp"> - + + + + + - + - + android:orientation="horizontal"> - + + + + + + android:orientation="horizontal"> + + + + - - - - - - - - - - - - - - - - -