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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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/335] 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 004e4be4d36c833572bf27c363bf5d9c5c36501f Mon Sep 17 00:00:00 2001 From: quonverbat Date: Wed, 2 Apr 2025 00:32:10 +0300 Subject: [PATCH 016/335] Added Turkish Translations --- app/src/main/res/values-tr/strings.xml | 1059 ++++++++++++++++++++++++ 1 file changed, 1059 insertions(+) create mode 100644 app/src/main/res/values-tr/strings.xml diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..bb0d98a5 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,1059 @@ + + Yayınla + Ara + Sorguya Ekle + Thumbnail + Kanal Resmi + Ekle + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + Sıraya Ekle + Geçmişe Ekle + Genel + Kanal + Ana Sayfa + İlerleme Çubuğu + Tarihsel bir ilerleme çubuğu gösterilecekse + Aramada, ana sayfada gizlenmişleri gizle + Ana sayfada gizlenmiş içerik üreticilerini ve videoları arama sonuçlarında da gizle + Önerilenler + Daha Fazla + Oynatma Listeleri + Abonelikler + Yükleniyor + Yeniden Dene + İptal et + Veri alınamadı, internete bağlı mısınız? + Ayarlar + Geçmiş + Kaynaklar + Satın Al + SSS + Gizlilik Modu + En üstteki kaynak birincil olarak kabul edilecektir + Varsayılanlar + Ana Sayfa + Tercih Edilen Kalite + Bir videoyu izlemek için varsayılan kalite + Güncelle + Kapat + Asla + İçe aktarma seçeneklerinden birini seçin. + Bir güncelleme mevcut, uygulamayı güncellemek ister misiniz? + Güncelleme indiriliyor… + Güncelleme kuruluyor… + Tamamlandı + getHome + Başarılı + Paketi güncellenemedi hata> + İşlem genel bir şekilde başarısız oldu + İşlem aktif olarak iptal edildiği için başarısız oldu + İşlem bloklandığı için başarısız oldu + İşlem, cihazda zaten yüklü olan başka bir paketle çakıştığı için başarısız oldu + İşlem cihazla temel olarak uyumlu olmadığı için başarısız oldu + İşlem bir ya da daha fazla APK geçersiz olduğundan başarısız oldu + İşlem depolama sorunları yüzünden başarısız oldu + CANLI + Canlı + Daha fazla okumak için dokunun + Gizlemek için dokunun + Versiyon + Göre sırala + Abone ol + Abonelikten çık + Arka Plan + Video + Ekle + İndir + Paylaş + Hepsini görüntüle + Üreticiler + Etkin + Ekranı açık tut + Yayın sırasında ekranı açık tut + Her zaman proxy istekleri + Cihaz üzerinden veri yayınlarken her zaman proxy istekleri. + Keşfet + Eklemek için yeni video kaynakları bulun + Bu kaynaklar devre dışı bırakılmıştır + Bunlar bu grup için görünür olan üreticilerdir + Bu üreticiler bu grupta değiller + Devre dışı + Daha sonra izle + Oluştur + Tamam + Evet + Hayır + Onayla + Tekrar sorma + Bu oynatma listesini silmek istediğinizden emin misiniz? + Bu aboneliği kaldırmak istediğinizden emın misiniz? + Bu kaynağın kaldırılması aboneliklerinizin bazılarının çözümlenmemesine neden olacaktır. + Karıştır + Hepsini oynat + Arama geçmişi + Uygulama Hatası + Geliştirici + Geçmiş önerisini kaldır + Yorumlar + İçeriğin altındaki yorum bölümü + Alışveriş + Oynatma listesinin sonuna ulaşıldı + Oynatma listesi video sona erdikten sonra yeniden başlatılacak + Şimdi yeniden başlat + Sıranın sonuna ulaşıldı + Oynatma listesinin sonuna ulaşıldı + Daha Sonra İzle\'nin sonuna ulaşıldı + Sıranın sonuna ulaşıldı + Sıra video sona erdikten sonra yeniden başlatılacak + Sonraki + Sıra + Temizle + Son saat + Son 24 saat + Son hafta + Son 30 gün + Son yıl + Tüm zamanlar + Bu geçmiş girdileri kaldırmak istediğinizden emin misiniz? + kaldırıldı + Kaynak ekle + Platform URL + Repository URL + Kod URL + Konfigürasyon URL + Bunlar eklentinin çalışabilmesi için gereken izinlerdir + Eklenti değerlendirme kapasitesine erişebilecek + Eklenti şu domainlere erişim sahibi olacak + QR Kodu Okut + İndirmek için bir QR kodu okutun + Eklentinin konfigürasyonunu yüklemek için bir URL girin + URL gir + Kur + Hiçbir cihaz bulunamadı. Cihazınızın görünmesi biraz zaman alabilir, lütfen sabırlı olun + Hazır değil + Bağlan + Dur + Başlat + Depolama Alanı + İndirmeler + Hepsinin seçimini kaldır + Hepsini seç + Yayınlama cihazı ekle + Yeni aktivite + Üretici NeoPass\'te + Seçenekler + Dışa aktar + Kaldır + Güncelleme yükleniyor… + Çevrimiçi videolara göz atarken ve yorum bırakmak istediğinizde, çevrimiçi varlığınızı yönetmenin hem kolay hem de ihtiyaçlarınıza odaklanmış yeni bir yolu olan Polycentric\'i deneyin. İşte bir Polycentric profili oluşturmanın harika bir seçim olmasının nedenleri:\n\n 1. Kontrol Sizde: Polycentric ile verileriniz tek bir şirket tarafından kontrol edilen tek bir yerde saklanmaz. Bunun yerine, birden fazla konuma yayılır ve çevrimiçi varlığınız üzerinde daha fazla kontrol sahibi olursunuz. Verilerinizin nerede saklanacağına siz karar verirsiniz ve sağlayıcılar arasında kolayca geçiş yapabilir veya yeni sağlayıcılar ekleyebilirsiniz.\n\n 2. Gizlilik ve Güvenlik: Polycentric, gelişmiş güvenlik teknikleri kullanarak bilgilerinizi güvende tutar. Kişisel bilgilerinizin iyi korunduğundan ve yalnızca bunları paylaşmayı seçtiğiniz kişiler tarafından erişilebilir olduğundan emin olabilirsiniz.\n\n 3. Sorunsuz Ağ Oluşturma: Polycentric, tek bir platforma bağlı kalmadan başkalarıyla bağlantı kurmanızı ve içerikleriyle etkileşim kurmanızı sağlar. Bir sağlayıcı güvenilmez hale gelirse veya erişimi engellemeye çalışırsa, Polycentric istemciniz otomatik olarak diğer kaynaklardan bilgi bularak sizi bağlı tutar.\n\n 4. Akıllı Arama ve Öneriler: Polycentric, arama ve öneriler için birden fazla kaynak kullanır ve size en üst düzey performansı sunarken hiçbir sağlayıcının gördüğünüz sonuçlar üzerinde fazla etkisi olmamasını sağlar.\n\n 5. Uyumlu ve Açık: Polycentric, kullanıcılarının ihtiyaçlarına uyum sağlayan sürekli iyileştirmeler ve yeni özellikler sunarak esnek ve yeni gelişmelere açık olacak şekilde tasarlanmıştır.\n\n 6. Bir Polycentric profili oluşturarak çevrimiçi videolara yorum bırakabilir ve daha kişiselleştirilmiş bir çevrimiçi deneyimin keyfini çıkarabilirsiniz. Bu kullanıcı dostu çözümün dijital hayatınızın kontrolünü nasıl size verdiğini kendiniz görün. Bugün Polycentric\'e kaydolun! + Yayınla + Kullanım + Ana sayfada etkinleştir + Aramada etkinleştir + Kontrol et + Grayjay yapımı ve bakımı kolay ya da ucuz bir uygulama değildir. Uygulamada ve diğer sistemlerde tam zamanlı çalışan mühendislerimiz var. Ve parasını yakın zamanda -belki hiç- kazanamayacağız.\n\nFUTO\'nun misyonu, açık kaynaklı yazılım ve kötü amaçlı olmayan yazılım iş uygulamalarının projeler ve geliştiricileri için sürdürülebilir bir gelir kaynağı haline gelmesidir. Bu nedenle kullanıcıların yazılım için gerçekten ödeme yapmasından yanayız.\n\nBu yüzden Grayjay, yazılım için ödeme yapmanızı istiyor. + Bir hata oluştu, rahatsızlıktan dolayı özür dileriz. + Bunu çözmemize yardım etmek için, lütfen çökme raporunu aşağıda bizimle paylaşın, ve sorunu ne yaparken yaşadığınız hakkında bilgi verin. + Yeni Profil + Varolan Profili İçe Aktar + Yeni bir kişilik oluşturun + Varolan kişiliği kullanın + İzinler + Güvenlik Uyarıları + Bunlar eklenti davranışına dair uyarılardır + Lütfen CAPTCHA\'yı girin ve sona erdiğinde kapatın + KAPAT + Gönder + Yeniden Başlat + Sekmeleri Düzenle + İçe aktarmak için okutun + Kişiliğini başka bir uygulamaya gönder + Kopyala + Kişiğini panoya kopyala + Polycentric + Profil İsmi + Bu diğer kullanıcılara görünecek + Profil Oluştur + YA DA + Profilinizi buraya yapıştırın polycentric://… + Profili içe aktar + Görünüşe göre bir geliştiricisiniz + Geliştirici Ayarları + Migrasyon + Günlük yedeklemeniz için bir şifre koyun + Harici depolamaya yazılmış günlük yedeklemenizi şifrelemek için bir şifre koyun + Yedek Şifre + Şifre Tekrar + Otomatik yedeklemeden restore edin + Görünüşe göre cihazınızda bir otomatik yedekleme var, eğer restore etmek isterseniz, yedek şifrenizi girin. + Restore Edin + Hata Mesajı + İsim + IP + Port + Keşfedilmiş Cihazlar + Hatırlanan Cihazlar + Hatırlanan cihaz yok + Şuna bağlan + Ses + Değişiklikler + Bu versiyon ve önceki versiyonlar için var olan değişiklikleri gösterir + Örnek bir değişiklik. + Önceki + Sonraki + Yorum Yap + Yorum boş değil, yine de kapatmak ister misiniz? + İçe aktar + Benim oynatma listesi adım + Bu belleği içe aktarmak ister misiniz? + Etkinleştirilmesi gereken kaynaklar + Ögelerin taşınması gerekiyor veya bozulmuşlar, bunları şimdi yedekten geri yüklemek ister misiniz? + Yok sayılırsa bir sonraki başlatmada tekrar sorulacaktır. + Yok say + Değişiklikleri görüntüle + Zaten ödedim + Üyelikler + Sık sık tekrarlanan aylık ödeme + ek avantajlar + Üreticiye destek olmak için tek seferlik ödeme + Üreticinin mağazası + Bağış + Promosyonlar + Bu yaratıcının güncel promosyonları + İndiriliyor + Videolar + Geçmişi temizle + İçe aktarılacak bir şey yok + Herhangi bir kaynak indirmediniz, lütfen uygulamayı amaçlandığı şekilde kullanmak için kaynak ekleyin. + Birçok kaynak eklemek uygulamanızın yüklenme hızını yavaşlatabilir. + Destek + Üyelik + Mağaza + Canlı Sohbet + Kaldır + Videolar + Oynatma listesi + Polycentric kanal + Etkinleştir + Yapım aşamasında + ALTINDA + YAPIM + Devre dışı bırak + Eksik eklenti yüzünden bu içerik Grayjay\'de oynatılamaz. + Bu içerik kilitli + Bilinmeyen + Tarayıcıda açmak için dokunun + Eksik Eklenti + İzleyiciler baskın yapıyor + Şimdi git + Engel ol + Son Zamanlarda Kullanılan Oynatma Listesi + Lisans e-postası + Lisans anahtarını göndermek için gerekli + Makbuz e-postası (kullanıcı@domain.com) + Şu şekilde ödeme + Ülke + Posta Kodu + Grayjay + Satış Vergisi ( + Toplam + Ödeme + Standard ödeme yolları + Stripe, başlıca kredi kartlarını, banka kartlarını ve çeşitli yerelleştirilmiş ödeme yöntemlerini kabul eden güvenli bir çevrimiçi ödeme hizmetidir. + Bir yorum ekle… + İlgilenmiyorum + Yüklemek için QR kodu okutun + Tam ekrana geç + Tarafından + İmza + Geçerli + Tekrar et + Görüntüle + QR kod ile indirin + QR kodu okutarak bir eklenti indirin + Çıkış Yap + Video + Daha detaylı bir video görüntüleyin + Teknik Dokümantasyon + Teknik dokümantasyonu görüntüleyin + Mağazamı ziyaret edin + Kişiliğinizin bir yedeklemesini oluşturun + Bu kişilikten çıkış yap + Bu profili sil + URL ile yükleyin + Önceden yüklenmiş videoları daha hızlı yüklemek için önbelleğe alın + Kullanıcılar tarafından ve kendi bildirilen raporların bir listesi + Ayrıca oturum açma veya ayarlar gibi verilerle ilgili tüm eklentileri kaldırır + Duyuru + Bildirimler + Güncellemeler için devre dışı bırakılmış eklentileri kontrol edin + Güncellemeler için devre dışı bırakılmış eklentileri kontrol edin + Planlanmış İçerik Bildirimleri + Keşfedilen planlanmış içerikleri bildirim olarak zamanlar, bu sayede bu içerik için daha doğru bildirimler verir. + Bayt aralıklarını kullanmaya çalışın + Otomatik Güncelleme + Ters yatay otomatik döndürmeye her zaman izin ver + Sistem ayarlarından otomatik döndürmeyi devre dışı bıraksanız bile, tam ekran modunda iki yatay yön arasında her zaman otomatik döndürme olacaktır. + Kaynakları basitleştir + Çözünürlük yoluyla kaynaklar çoğaltılır, böylece yalnızca alakalı kaynaklar görünür. + Otomatik Yedekleme + Arka Plan Davranışı + Arka Plan Güncellemesi + Arka Plan İndirmesi + Yedekleme + Göz Atma + ByteRange Concurrency + ByteRange İndirmesi + Yayınlama + Oynatıcının davranışını değiştir + Harici indirilenler klasörünü değiştir + Harici indirilenler klasörünü temizle + Harici genel klasörünü değiştir + Ana sayfada görünen sekmeleri değiştir + Link Kontrolü + Grayjay\'in linkleri yönetmesine izin ver + Genel dosyalar için harici klasörü değiştir + İndirilen dosyalar için harici depolama alanını temizle + İndirilen dosyalar için harici depolama alanını değiştir + Çerezleri Temizle + Çıkışta Çerezleri Temizle + Arka Plan İşlerini Test Et + + Ödemeyi Temizle + Çıkış yaptığınızda çerezleri temizler + Uygulama-içi tarayıcı çerezlerini temizler + Göz atma davranışını özelleştir + Zaman çubuğu + Tarihsel zaman çubuklarının gösterimini özelleştir + Yayınlamayı özelleştir + Felaket niteliğinde bir arıza durumunda günlük yedeklemeyi özelleştir + Videoların indirilmesini özelleştir + Ana sayfa sekmenizin nasıl çalışacağını ve görüneceğini özelleştirin + Abonelikler sekmenizin nasıl çalışacağını ve görüneceğini özelleştirin + Arka planda indirmenin kullanılıp kullanılmayacağını özelleştirin + Otomatik güncelleştiriciyi özelleştirin + Güncellemelerin ne zaman indirileceğini özelleştirin + Videoların ne zaman indirileceğini özelleştirin + Grayjay ile açarak içe aktarabileceğiniz verilerinizi içeren bir zip dosyası oluşturur + Varsayılan Ses Kalitesi + Varsayılan Oynatma Hızı + Varsayılan Video Kalitesi + Uygulamadan lisans anahtarlarını sil + Ne zaman indir + Video Önbelleğini Etkinleştir + Yayınlamayı etkinleştir + Abonelik önbelleği için deneysel arka plan güncellemesi + Veriyi Dışa Aktar + Veriyi İçe Aktar + İçe aktarmak için bir dosya seçin, çeşitli dosyaları destekler (doğrudan açmak yerine) + Harici Depolama + Feed Stili + Dil + Uygulama Dili + Yeniden başlatma gerektirebilir + Uygulama başlatıldığında al + Sekme açıldığında al + Sekme açıldığında yeni sonuçlar alın (henüz sonuç yoksa ve sorun yaşamıyorsanız, devre dışı bırakılması önerilmez) + Her zaman önbellekten yeniden yükle + Bu önerilmez, ama bazı sorunlar için geçici bir çözümdür. + Kanal İçeriklerine Göz At + Eğer eklenti tarafından destekleniyorsa, kanal içeriğini önizleyin. Oran sınırlı çağrılar nedeniyle, bu işlem abonelik yenileme süresini artırabilir. + Çok sorulan sorulara cevap al + Uygulamaya geri bildirimde bulunun + Bilgi + + Senkronizasyon + Özelliği etkinleştir + Yayın + Cihazın varlığını yayınlamasına izin ver + Keşfedilenlere bağlan + Cihazın bilinen eşleştirilmiş cihazları aramasına ve onlarla bağlantı başlatmasına izin ver + Son bağlanılana bağlan + Cihazın son bilinen cihaza otomatik olarak bağlanmasına izin ver + Hareket Kontrolleri + Ses çubuğu + Kaydırma hareketinin sesi değiştirmesine izin ver + Parlaklık çubuğu + Kayrdırma hareketinin parlaklığı değiştirmesine izin ver + Tam ekrana geç + Kaydırma hareketinin tam ekrana geçmesine izin ver + Sistem parlaklığı + Hareket kontrolleri sistem parlaklığını değiştirir + Sistem parlaklığını eski haline getir + Sistem parlaklığını tam ekrandan çıkarken eski haline getir + Yakınlaştırmayı etkinleştir + İki parmak kaydırma hareketi ile yakınlaştırmayı etkinleştir + Görüntüyü kaydırmayı etkinleştir + İki parmak kaydırma hareketi ile görüntüyü dikey ve yatay olarak kaydırmayı etkinleştir + Sistem sesi + Hareket kontrolleri sistem sesini değiştirir + Canlı Chat Web Görüntüsü + Tam Ekran Portre + Ters portreye izin ver + Uygulamanın ters portreye dönmesine izin ver + Döndürme bölgesi + Döndürme bölgelerinin hassasiyetini belirleyin (daha az hassas yapmak için azaltın) + Stabilite eşik süresi + Bir dönüşü tetiklemek için yönelimin aynı olması gereken süreyi belirtin + Webm Video Kodeklerini Tercih Edin + Eğer oynatıcı mp4 kodeklerini (h264/AAC), Webm kodeklerine (vp9/opus) tercih ederse daha kötü bir uyumluluğa yol açabilir. + Tam otomatik döndürme kilidi + Döndürme kilidi açıksa herhangi bir döndürmeye engel olur (yatay ve yatay ters arasında geçişte bile). + Webm Audio Kodeklerini Tercih Et + Eğer oynatıcı mp4 kodeklerini (AAC), Webm kodeklerine (opus) tercih ederse daha kötü bir uyumluluğa yol açabilir. + Çerçeve altında videoya izin ver + Videonun tam ekranda ekran çerçevesinin altına gitmesine izin ver.\nYeniden başlatma gerektirebilir + Otomatik olarak sonraki videoyu oynat + Bir videoyu izlerken sonraki videoyu otomatik olarak oynatma varsayılan olacaktır + Yatay videolar izlerken tam ekran portreye izin ver + İzlendikten sonra Daha Sonra İzle\'den kaldır + Büyük bir çoğunluğunu izlediğiniz bir videoyu Daha Sonra İzle\'de bırakırsanız, Daha Sonra İzle\'den kaldırılacaktır. + Arka planda sese değiştir + Mümkünse arka planda yalnızca ses akışına geçerek bant genişliği kullanımını optimize edin, bu takılmalara neden olabilir + Gruplar + Abonelik Gruplarını Göster + Abonelik gruplarının filtrelemek için aboneliklerinizin üzerinde gösterilmesi gerekiyorsa + Feed Ögelerini Önizle + Önizleme feed stili kullanıldığında, ögelerin üzerinden kaydırırken otomatik olarak önizlenir. + Log Seviyesi + Loglama + Grayjay\'i Senkronize Et + Verilerinizi birden fazla cihaz arasında senkronize edin + Polycentric kişiliği düzenle + Polycentric kişiliğinizi düzenleyin + Manuel kontrol + Güncellemeleri manuel olarak kontrol edin + Hız sınırlandırılmış kaynaklardan indirme hızlarını artırmak için eşzamanlı thread sayısı. + Ödeme + Ödeme Durumu + Döndürme Engelini Baypas Et + Oynatma Listesi Silme Onayı + Bir oynatma listesinden bir medya silerken onay diyaloğu göster + Kopya oynatma listesi videolarına izin ver + Bir videonun oynatma listelerine birçok kere eklenmesine izin ver + Polycentric\'i Etkinleştir + Polycentric Lokal Önbelleğe İzin Ver + Yükleme sürelerini azaltmak için Polycentric sonuçlarını cihazda önbelleğe alır, değişiklik uygulamanın yeniden başlatılmasını gerektirir + Sorun yaşıyorsanız devre dışı bırakılabilir + Video olmayan görüntülerde döndürmeyi etkinleştirir.\nUYARI: Bunun için tasarlanmamıştır + Bu beklenmeyen davranışlara neden olabilir ve çoğunlukla test edilmemiştir. + Bu bölümü değiştirmek uygulamanın yeniden başlatılmasını gerektirmektedir. + Oynatıcı + Eklentiler + Tercih Edilen Yayınlama Kalitesi + Harici bir cihaza yayınlarken varsayılan kalite + Tercih Edilen Kalite (Ölçülü) + Hücresel gibi ölçülü bağlantılarda varsayılan kalite + Tercih Edilen Önizleme Kalitesi + Bir videoyu önizlerken varsayılan kalite + Birincil Dil + Orijinal Sesi Tercih Et + Tercih edilen dil bilindiğinde bunun yerine orijinal sesi kullanın + Varsayılan Yorum Bölümü + Önerilenleri Gizle + Önerilenler sekmesini tamamen gizle. + Varsayılan Olarak Önerilenler + Varsayılan olarak yorumlar yerine, önerilenleri gösterir. + Kötü İtibara Sahip Yorumları Gösterme + Kötü itibara sahip yorumların gösterilip gösterilmemesi. Devre dışı bırakmak deneyimi kötüleştirebilir. + Gömülü Eklentileri Yeniden İndir + Önbelleğe Alınmış Versiyonu Kaldır + Son indirilmiş versiyonu kaldır + Gizli Ögeleri Temizle + Gizlenmiş üreticileri ve videoları temizleyerek onları yeniden gösterir + Duyuruları sıfırla + Gizli duyuruları sıfırla + Otomatik Yedeklemeyi Restore Et + Bir önceki otomatik yedeklemeyi restore et + Önizlemeden Sonra Devam Et + Şu anki ve önceki değişiklikleri görüntüle + Ses odak kaybından sonra yeniden başlat + Bir ses kayıptan sonra ses odağı kazanıldığında oynatmayı yeniden başlat + Bağlantı kaybından sonra yeniden başlat + Bir bağlantı kayıptan sonra bağlantı sağlandığında oynatmayı yeniden başlat + Bölüm Güncelleme FPS\'i + Bölüm güncellemesinin doğruluğunu değiştirin, daha yüksek olması daha fazla performansa mal olabilir + Otomatik Yedeklemeyi Ayarlayın + Uygulamayı açtıktan kısa bir süre sonra, abonelikleri almaya başla + Sıkça Sorulan Soruları (SSS) Göster + Sorunları Göster + Kanalları almak için kaç thread kullanılacağını belirtin + Geri bildirim gönder + Logları gönder + Kanal Önbelleğini Temizle + Abonelik kanalı önbelleğinden tüm içeriği siler + Sorunları azaltmamızda bize yardım etmek için logları gönderin + Abonelik Eşzamanlılığı + Oynatma Süresini Lokal Olarak Takip Et + Aboneliklerin oynatma süresini lokal olarak takip edin, abonelikleri sıralarken ve üretici önerilirken kullanılır. + İzleme Ölçümlerini Göster + Her üreticinin oynatma süresini ve oynatma sayısını üreticiler sekmesinde gösterir + Bu verilen dereceye göre cihazın dönmesine engel olur + Eğer varsa yerel uygulaması yerine canlı chat web penceresini kullanın + Versiyon Kodu + Versiyon İsmi + Versiyon Tipi + Kanal Önbellek Büyüklüğü (Başlat) + Önizleme modunda video izlerken, videoyu açarken o konumdan devam et + Lütfen log göndermek logları etkinleştirin + Gömülü eklentiler yeniden kuruldu, uygulamayı yeniden başlatmanız önerilir + Duyurular sıfırlandı. + Mağazayı gösterme başarısız oldu. + Değişiklikler alınıyor + Ödendi + Ödenmedi + Lisanslar temizlendi, bu, uygulamanın yeniden başlatılmasını gerektirebilir + getHome\'dan 2 sayfa alma denemeleri + Arka Plan Abonelik Testi + Bütün İndirilenleri Temizle + İndirilenleri Temizle + CookieManager\'dan bütün çerezleri temizle + Beni Çökert + Uygulamayı bilerek çökertir + Duyuruları Sil + Çözümlenmemişleri Sil + Tüm duyuruları sil + Tüm indirilen videoları ve ilgili dosyaları siler + Devam eden indirilenleri siler + Çözümlenmemiş kaynak dosyalarını siler + Geliştirici Modu + Tüm Sertifikalara İzin Ver + Bu, Grayjay ağ trafiğinizin tamamının açığa çıkma riski taşır. + Geliştirme Sunucusu + Deneysel + Önbellek + Depoyu hata alana kadar doldur + Enjekte Et + V8\'e bir test kaynak konfigürasyonu (local) enjekte eder + Diğer + Diğerleri… + Tüm abonelikleri siler + Geliştirme sunucusu ile ilgili ayarlar, telefonunuzu güvenlik açıklarına maruz bırakabileceğinden dikkatli olun + Sunucuyu Başlat + Oynatıcıyı Test Et + Tüm videoları oynatmaya devam eder + Abonelik Önbelleği 5000 + Geçmiş Önbelleği 100 + Başlangıçta sunucuyu başlat + Port 11337\'de bir DevServer başlatır, güvenlik açıklarını ortaya çıkarabilir. + V8 iletişim hızını test et + V8 oluşum hızını test et + V8 iletişim hızını test eder + V8 oluşturma sürelerini ve çalıştırmayı test eder + Tümünü abonelikten çık + V8 Denektaşları + V8 Script Testleri + Entegre V8 motorunu kullanan çeşitli kıyaslamalar + Özel bir kaynağa karşı çeşitli testler + Diskte yer kalmayana kadar yazar + Görünürlük + Güncellemeleri kontrol et + Bir eklentinin başlangıçta güncellemeler açısından kontrol edilip edilmemesi + Otomatik Güncelleme + Hiçbir izin değiştirilmemişse ve eklenti etkinleştirilmişse başlangıç sırasında otomatik olarak güncellenir + Geliştirici Gönderimlerine İzin Ver + Geliştiricinin sunucusuna veri göndermesine olanak tanır, hassas veriler içerebileceğinden dikkatli olun. + Geliştiriciye güvendiğinizden emin olun. Hassas verilere erişim kazanabilirler. Bunu yalnızca geliştiricinin bir hatayı düzeltmesine yardımcı olmaya çalışırken etkinleştirin. + Oran Sınırı + Bu eklentinin davranışının oran sınırlamasıyla ilgili ayarları + Aboneliklerin Oranını Sınırla + Yapılan abonelik isteklerinin miktarını sınırlayın + Bu eklentinin içeriğinin göründüğü yerlerde etkinleştirin + İçeriği ana sayfa sekmesinde göster + İçeriği arama sonuçlarında göster + Geçerli bir URL sağlanmadı.. + Geçersiz Konfigürasyon Formatı + Konfigürasyon alınamadı + Script alma başarısız oldu + URL Erişimi + Eklentinin şu domainlere erişimi olacak + Erişimi Değerlendir + Eklenti, değerlendirme yeteneğine (uzaktan enjeksiyon) erişebilecek + QR kod okuma başarısız oldu + Eklenti URL\'i değil + Bir QR kod okutun + Henüz tamamlanmadı. + Bilinmeyen Durum + Bir şeyler ters gitti… stack trace mi eksik? + Loglar zaten gönderildi. + Hiçbir log bulunamadı. + Otomatik paylaşım başarısız oldu, manuel paylaşmak ister misiniz? + {id} Paylaşıldı + VS\'de bilinmeyen hata (exception) + Geliştiricilere exception\'ı gönder… + Lisans anahtarınız ayarlandı!\nUygulamayı yeniden başlatmanız gerekebilir. + Geçersiz lisans formatı + Bilinmeyen içerik formatı + Bilinmeyen dosya formatı + Bilinmeyen Polycentric formatı + Bilinmeyen URL formatı + Dosya işleme başarısız oldu + Bilinmeyen yeniden yapılandırma türü + Metin belgesi işlenemedi + NewPipe abonelikleri işlenemedi + QR kod oluşturma başarısız oldu + Metni Paylaş + Metin Kopyalandı + En az 3 karakter uzunluğunda olmak zorunda. + Profil oluşturma başarısız oldu. + Sunucuların tam olarak geri doldurulması başarısız oldu. + Bu kişiliğe giriş yap + Metin alanı herhangi bir veri içermiyor + Geçerli bir URL değil + Bu profil zaten içe aktarıldı + Profil içe aktarılıramadı: + İstemci geri doldurulamadı: + Bu profili silmek istediğinizden emin misiniz? + İşlem tanıtıcısı ayarlanmadı. + İsim en az 3 karakter uzunluğunda olmalı + İşlem tanıtıcısı ayarlanmadı. + Görüntü okuma başarısız oldu + Değişiklikler kaydedildi + Değişiklikler senkronize edilemedi + Görüntü seçici iptal edildi + Artık geliştirici modundasınız + Abone + {name} şurada bulun + Alma başarısız oldu + Satın alımınız için teşekkürler, ödeme alındıktan sonra e-postanıza bir anahtar kodu gönderilecek! + Ödeme başarısız oldu + Ödeme başarılı + Lisans anahtarını girin + Geçersiz lisans anahtarı + Lisans + Anahtar etkinleştirme başarısız oldu + Bu kanalı destekleyen herhangi bir kaynak etkin değil + {channelName} kanalını bir oynatma listesine dönüştürmek ister misiniz? + Kanal dönüştürme başarısız oldu + Sayfa + Videoyu Senkronize Et + Gizle + Ana Sayfada Gizle + Üreticiyi Ana Sayfada Gizle + Feed\'i Sırayla Oynat + Bütün feed\'i oynat + Sıraya Eklendi + Zaten Sırada + Kullanıldı + Mevcut + Sonraki sayfayı yükleme başarısız oldu + {pluginName} eklentisi başarısız oldu:\n{message} + Eklenti buna bağlı olarak başarısız oldu: + Ana sayfayı alma başarısız oldu \nEklenti + Ana sayfayı alma başarısız oldu + Ana sayfa mevcut değil + Ana sayfa mevcut değil, lütfen internet bağlantınızı kontrol edin ve yeniden deneyin. + Oynatma Listelerini İçe Aktar + oynatma listeleri içe aktarıldı. + {index} arasından {size} tanesi seçildi + Abonelikleri İçe Aktar + abonelikler içe aktarıldı. + Oynatma listesini düzenle + Metin Olarak Paylaş + Bir video URL listesi olarak paylaş + İçe Aktarma Olarak Paylaş + Grayjay\'e ait bir içe aktarma dosyası olarak paylaş + Oynatma listesi yükleme başarısız oldu + Lütfen oynatma listesinin yüklenmesi için bekleyin + Oynatma listesi lokal olarak kopyalandı + İndirilmiş videoları silmek istediğinizden emin misiniz? + Yeni bir oynatma listesi oluştur + Yeni bir grup oluştur + Beklenen medya içeriği bulundu + Gönderi yükleme başarısız oldu. + yanıtlar + Yanıtlar + Eklenti ayarları kaydedildi + Eklenti ayarları + Bu ayarlar eklenti tarafından tanımlanmıştır + Kaynak yükleme başarısız oldu + Güncellemeleri kontrol et + Kaynağın yeni versiyonlarını kontrol eder + Doğrulama + Bu platformdan çıkış yap + Bu kaynaktan aboneliklerinizi içe aktarın + Bu kaynaktan oynatma listelerinizi içe aktarın + Giriş + Giriş Gerekli + Bu kaynağın platformuna giriş yapın + Yönetim + Kaldır + Eklentiyi uygulamadan kaldırır + CAPTCHA\'yı sil + Bu eklenti için kaydedilen CAPTCHA yanıtını siler + Oynatma listeleri alınamadı. + {subscriptionCount} adet kullanıcı aboneliği alındı. + Abonelikler alınamadı. + Kaldırmak istediğinizden emin misiniz? + Kaldırıldı + Güncellemeler kontrol edilemedi + Eklenti tamamen güncel + Oran Sınırı Uyarısı + Bu, çok sayıda aboneliğe daha iyi destek sağlayana kadar kullanıcıların oran sınırına ulaşmasını önlemek için geçici bir önlemdir. + \n\nŞu eklentiler için çok fazla aboneliğiniz var:\n + Gönderiler + Planlanmış + İzlenmiş + Hiçbir bir sonuç bulunamadı\nYenilemek için aşağı kaydırın + Overlay + Yenile + şu anda izlenen + görüntüleme + Planlanmış + Oynatma takibini alma başarısız oldu + Canlı etkinlikleri alırken istisna: + Canlı sohbet penceresini alırken istisna: + Canlı sohbet yüklenemedi + Desteklenmeyen yayın formatı + Medya yüklenemedi + Çevrim Dışı Oynatma + Medya Hatası + Medya kaynağı yetkisiz bir hatayla karşılaştı.\nBu, eklentinin yeniden yüklenmesiyle çözülebilir.\nYeniden yüklemek ister misiniz?\n(Deneysel) + Oynatma Oranı + Kalite + Çevrim Dışı Video + Çevrim Dışı Ses + Çevrim Dişi Altyazılar + Videoyu Yayınla + Sesi Yayınla + Ses + Altyazılar + Mevcut olmayan video + Bu video mevcut değil. + Sıranızdaki [{authorName}] tarafından [{videoName}] videosu mevcut değil. + Geri + Duraklat + Oynat + Videoyu durdurur + Videoyu devam ettirir + Kaynaksız video + Sıranızdakı [{authorName}] tarafından [{videoName}] videosu için gerekli kaynak etkin değildi, oynatım atlandı. + Video yüklenemedi (ScriptImplementationException) + Geçersiz video + Sıranızdaki [{authorName}] tarafından [{videoName}] videosu geçersizdi, oynatım atlandı. + Yaş sınırlaması olan video + Sıranızdaki [{authorName}] tarafından [{videoName}] videosunda yaş sınırlaması vardı, video erişilebilir olmadığından oynatım atlandı. + Video yüklenemedi (ScriptException) + Video yüklenemedi + Şu anda mevcut değil, {time}s sonra tekrar deneyin. + Canlı yayın için yeniden deneme başarısız oldu + Bu uygulama geliştirilme henüz aşamasında. Lütfen hata raporları gönderin ve birçok özelliğin eksik olduğunu anlayışla karşılayın. + Lütfen en az 1 karakter kullanın + Bu videoyu silmek istediğinizden emin misiniz? + Açmak için dokunun + Güncelleme mevcut! + izleniyor + şurada mevcut + saniye + Lütfen yorum yapmak için giriş yapın. + Yorum yapılamadı: + Limitsiz bağlantı bekleniyor + Son hata + Hata + Filtreler + izleyiciler + En az bir yanıt beklendi fakat sunucu tarafından hiç yanıt gönderilmedi. + Lütfen beğenmek için giriş yapın + Lütfen beğenmemek için giriş yapın + "Yorumlar yüklenemedi. " + Script mevcut değil + İmza geçerli + İmza geçersiz + İmza mevcut değil + "Abone Olundu " + "Abonelikten Çıkıldı " + Bir otomatik yedeklemeniz yok + Eski bir yedekleme mevcut + Bu yedeklemeyi restore etmek ister misiniz? + Geçersiz Kıl + Veri Denemesi + İndirme mevcut değil + (henüz) indirilebilir kaynak bulunmuyor + Hiç + Sadece Ses + Videoyu İndir + Videonun detayları alınıyor + İndirmenin detayları alınamadı + Hedef Çözünürlük + Hedef Bitrate + Düşük Bitrate + Yüksek Bitrate + Eylemler + Videoyu indir + Video Seçenekleri + Pinleri Değiştir + Hangi butonların pinleneceğine karar ver + Pinlerinizi sırayla seçin + Daha Fazla Seçenek + Kaydet + Bu üretici henüz Harbor (Polycentric) için bir destek seçeneği koymadı + Daha Fazla Yükle + Oran sınırınından kaçınmak için {requestCount} istekten sonra durdu, daha fazla yüklemek için Daha Fazla Yükle\'ye dokunun. + Bu üretici henüz bir para kazanma yönetemi ayarlamadı + " + Vergi" + Yeni oynatma listesi + Yeni oynatma listesine ekle + URL yönetimi + Grayjay\'in URL\'leri yönetmesine izin ver? + \'Evet\' e tıkladığınızda, Grayjay uygulama ayarları açılacak.\n\nOradan:\n1. "Varsayılan olarak aç" veya "Varsayılan olarak seç" bölümüne tıklayın.\n Bu seçeneği \'Gelişmiş\' bölümünün tam altında bulabilirsiniz, cihazınıza bağlı olarak.\n\n2. Grayjay için \'Desteklenen bağlantıları aç\' ı seçin.\n\n(bazı cihazlar bunu ana ayarlarda \'Varsayılan Uygulamalar\' ın altında listeledi, Grayjay için ilgili kategorileri seçin) + Ayarları gösterme başarısız oldu + Play store versiyonu varsayılan bağlantı yönetimine izin vermiyor. + Bu {commentCount} yorumun hepsi Grayjay\'de yaptığınız yorumların tamamı. + Öğretici + Yayınlamaya dön diyalog ekle + Nasıl yayınlama yapılacağına dair bir video izle + FCast teknik dokümantasyonu görüntüle + Rehber + FCast kullanım rehberi + FCast + FCast\'in web sitesini açın + FCast Website + FCast Teknik Dokümantasyon + Yorumlarınızı görüntülemek için giriş yapın + Polycentric devre dışı + Oynat Duraklat + Pozisyon + Öğreticiler + Bu öğreticileri görmek istiyor musunuz? Onlara her zaman daha fazla butonu ile ulaşabilirsiniz. + Üretici Ekle + Seç + Yakınlaştır + Güncelleme olup olmadığını kontrol et. + En yukarıya kaydırın + Pil Optimizasyonunu Devre Dışı Bırak + Pil optimizasyon ayarlarına gitmek için tıklayın. Pil optimizasyonunu devre dışı bırakmak işletim sisteminin medya otutumlarını durdurmasına engel olacaktır. + Kişisel Abonelikler Listesine Katkıda Bulun + \nŞu anki abonelikler listenizle FUTO\'ya katkıda bulunmak ister misiniz?\n\nVeri, Grayjay gizlilik politikasına bağlı olarak yönetilecektir. Bu liste anonimize edilecek ve aboneliklerin kime ait olduğu ile ilgili bir referans bulundurmadan saklanacaktır.\n\nBunun amacı ise Grayjay ve FUTO\'nun bu verileri kullanarak platformlar arası bir öneri sistemi oluşturarak Grayjay\'de seveceğiniz yeni içerik üreticilerini bulmayı kolaylaştırmak istemesi. + Yayın butonu + Incognito butonu + Üretici thumbnail + Aramayı temizle + Ara + Arama ikonu + Geri butonu + Uygulama ikonu + Geçmiş ikonu + Oynatma listesi oluştur + Paylaş + Filtrele + Sil + Ayarlar + Grup simgesi + Düzenle + İndir + Kapat + Duraklat + Oynat + Bağış miktarı + Yanıtlar + Beğen + Beğenme + Abone Ol + Platform göstergesi + Cihaz ikonu + Yükleyici + Bağış yazarının görüntüsü + Görüntüyü düzenle + Ekle + İndirme göstergeci + Çek ve bırak + Daha Sonra İzle\'ye ekle + Kapat + Sesini aç + Küçült + Döndürmeyi kilitle + Tekrarla + Önceki + Sonraki + Tam ekran + Otomatik Oynatma + Güncelleme döndürgeci + Oynat + Duraklat + Durdur + QR kodu okut + Yardım + Polycentric profil resmini değiştir + + Önerilenler + Abonelikler + + + 0.25 + 0.5 + 0.75 + 1.0 + 1.25 + 1.5 + 1.75 + 2.0 + 2.25 + + + Otomatik (720p) + 2160p + 1440p + 1080p + 720p + 480p + 360p + 240p + 144p + + + Hiç (Sadece Ses) + 2160p + 1440p + 1080p + 720p + 480p + 360p + 240p + 144p + + + 1 Thread + 2 Thread + 4 Thread + 6 Thread + 8 Thread + 10 Thread + 15 Thread + + + Asla + Her 15 Dakika + Her Saat + Her 3 Saat + Her 6 Saat + Her 12 Saat + Her Gün + + + Düşük Bitrate + Yüksek Bitrate + + + Uygulamayı Başlatırken + Asla + + + Devre Dışı + Etkin + + + Sınırsız İnternet + Wifi & Ethernet + Her Zaman + + + Devre Dışı + Etkin + + + En Yeni + En Eski + + + A\'dan Z\'ye + Z\'den A\'ya + Görüntüleme Artan + Görüntüleme Azalan + İzlenme Süresi Artan + İzlenme Süresi Azalan + + + Ad (A\'dan Z\'ye) + Ad (Z\'den A\'ya) + İndirme Tarihi (En Eski) + İndirme Tarihi (En Yeni) + Çıkış Tarihi (En Eski) + Çıkış Tarihi (En Yeni) + + + Önizle + Listele + + + Sistem + İngilizce (EN) + Almanca (DE) + İspanyolca (ES) + Portekizce (PT) + Fransızca (FR) + Japonca (JA) + Korece (KO) + Çince (ZH) + Rusça (RU) + Arapça (AR) + + + Hiç + Oynatmaya Devam Et + Oynatma Overlay\'i + + + Baştan Başla + 10s Sonra Başla + Her Zaman Devam Et + + + 24 + 30 + 60 + 120 + + + Polycentric + Platform + Son Seçilen + + + İngilizce + İspanyolca + Almanca + Fransızca + Japonca + Korece + Tayca + Vietnamca + Endonezce + Hintçe + Arapça + Türkçe + Rusça + Portekizce + Çince + + + FCast + ChromeCast + AirPlay + + + Hiç + Hata + Uyarı + Bilgi + Detaylı + + + Asla + 10 saniye içinde + 30 saniye içinde + Her Zaman + + + 15 + 30 + 45 + + + 100 + 500 + 750 + 1000 + 1500 + 2000 + + From 7d64003d1c97669dceb885847fd11ea27c78d56b Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 4 Apr 2025 00:37:26 +0200 Subject: [PATCH 017/335] 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 018/335] 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 019/335] 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 020/335] 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 021/335] 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 022/335] 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 023/335] 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"> + + + + - - - - - - - - - - - - - - - - -