Compare commits

...

16 Commits

Author SHA1 Message Date
Kelvin K efe074d272 menu bar contrast removal 2025-11-27 10:38:19 -06:00
Kelvin K 8a9efd3a0f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-27 10:30:09 -06:00
Kelvin K 251302b9c3 Fix back behavior for Android 16 2025-11-27 10:29:55 -06:00
Koen J 5cdac1405e Reverted javet for compat with android 9. 2025-11-27 17:08:27 +01:00
Koen J 565ea7cb8b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-27 13:41:55 +01:00
Koen J 9fa3e22d2e Crash fixes related to remoteLast. 2025-11-27 13:41:21 +01:00
Kelvin 5548783337 Merge branch 'revert-d902306f' into 'master'
Revert "Revert old ffmpeg"

See merge request videostreaming/grayjay!157
2025-11-26 20:06:29 +00:00
Kelvin 0dca8798cb Revert "Revert old ffmpeg"
This reverts commit d902306fe4
2025-11-26 20:06:16 +00:00
Kelvin K d902306fe4 Revert old ffmpeg 2025-11-26 13:35:09 -06:00
Kelvin K baa2a4fcf3 Deps 2025-11-26 12:05:24 -06:00
Kelvin K 8be7ad9f68 Alignment for more menu 2025-11-26 10:12:52 -06:00
Kelvin K 992cbcb3a0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-26 09:38:03 -06:00
Kelvin K e0857aea9b Window pan for keyboard 2025-11-26 09:37:35 -06:00
Koen J 50cd0723c9 JNI fixes. 2025-11-26 13:45:01 +01:00
Koen J 4c4b322682 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-26 11:50:33 +01:00
Koen J 7cff8568c0 Reverted to use FFMPEG for combining HLS segments. 2025-11-26 11:33:46 +01:00
26 changed files with 201 additions and 173 deletions
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
size 65512557
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
size 36133152
+1 -1
View File
@@ -181,7 +181,7 @@ dependencies {
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.8.0'
+3
View File
@@ -29,6 +29,8 @@
android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true"
tools:replace="android:enableOnBackInvokedCallback"
android:enableOnBackInvokedCallback="false"
tools:targetApi="31"
android:largeHeap="true">
<provider
@@ -61,6 +63,7 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
android:launchMode="singleInstance"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
@@ -8,6 +8,7 @@ import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -332,6 +333,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//Preload common files to memory
FragmentedStorage.get<SubscriptionStorage>();
FragmentedStorage.get<Settings>();
@@ -1,6 +1,9 @@
package com.futo.platformplayer.downloads
import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaMuxer
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
@@ -585,55 +588,44 @@ class VideoDownload {
return cipher.doFinal(encryptedSegment)
}
private fun remuxWithFfmpegInPlace(inputFile: File): Boolean {
val inputPath = inputFile.absolutePath
if (!inputFile.exists()) {
Logger.w(TAG, "remuxWithFfmpegInPlace: input does not exist: $inputPath")
return false
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
val parent = inputFile.parentFile
if (parent == null) {
Logger.w(TAG, "remuxWithFfmpegInPlace: input has no parent: $inputPath")
return false
}
val tmpFile = File(parent, inputFile.nameWithoutExtension + "_fixed." + inputFile.extension)
val cmd = buildString {
append("-y ")
append("-i \"").append(inputFile.absolutePath).append("\" ")
append("-c copy ")
append("-movflags +faststart ")
append("\"").append(tmpFile.absolutePath).append("\"")
}
Logger.i(TAG, "FFmpeg remux command: $cmd")
val session = FFmpegKit.execute(cmd)
val returnCode = session.returnCode
if (ReturnCode.isSuccess(returnCode)) {
val newLen = tmpFile.length()
if (!inputFile.delete()) {
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to delete original: ${inputFile.absolutePath}")
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
}
if (!tmpFile.renameTo(inputFile)) {
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to move tmp: ${tmpFile.absolutePath}")
} else {
Logger.i(TAG, "remuxWithFfmpegInPlace: success for $inputPath (size=$newLen bytes)")
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ Logger.v(TAG, it.message) },
statisticsCallback,
executorService
)
return true
} else {
Logger.e(TAG, "FFmpeg remux failed for $inputPath. rc=$returnCode, logs=${session.allLogsAsString}")
tmpFile.delete()
return false
continuation.invokeOnCancellation {
session.cancel()
}
}
}
private fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists())
targetFile.delete()
@@ -678,6 +670,7 @@ class VideoDownload {
.array()
}
val segmentFiles = arrayListOf<File>()
try {
val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
@@ -713,123 +706,134 @@ class VideoDownload {
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
val rangeOffsets = mutableMapOf<String, Long>()
targetFile.outputStream().use { outStr ->
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
if (isCancelled) throw CancellationException("Cancelled")
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Downloading HLS initialization map")
Logger.i(TAG, "Downloading HLS initialization map")
var mapRangeStart: Long? = null
var mapRangeLength: Long? = null
var mapRangeStart: Long? = null
var mapRangeLength: Long? = null
if (variantPlaylist.mapBytesLength > 0) {
mapRangeLength = variantPlaylist.mapBytesLength
if (variantPlaylist.mapBytesLength > 0) {
mapRangeLength = variantPlaylist.mapBytesLength
val mapUrl = variantPlaylist.mapUrl!!
if (variantPlaylist.mapBytesStart >= 0) {
mapRangeStart = variantPlaylist.mapBytesStart
rangeOffsets[mapUrl] =
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
} else {
val offset = rangeOffsets[mapUrl] ?: 0L
mapRangeStart = offset
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
}
val mapUrl = variantPlaylist.mapUrl
if (variantPlaylist.mapBytesStart >= 0) {
mapRangeStart = variantPlaylist.mapBytesStart
rangeOffsets[mapUrl] =
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
} else {
val offset = rangeOffsets[mapUrl] ?: 0L
mapRangeStart = offset
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
}
}
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val iv = staticIvBytes
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
mapBytes = decryptSegment(mapBytes, kb, iv)
}
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val iv = staticIvBytes
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
mapBytes = decryptSegment(mapBytes, kb, iv)
}
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
throw IllegalStateException("HLS MAP segment too large to handle.")
}
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
throw IllegalStateException("HLS MAP segment too large to handle.")
}
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outStr = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
outStr.write(mapBytes)
outStr.flush()
downloadedTotalLength += mapBytes.size
}
val totalSegments = variantPlaylist.segments.size
var mediaSegmentIndex = 0
var bytesSinceLastSpeedUpdate = 0L
var lastSpeedUpdateTime = System.currentTimeMillis()
var lastSpeed = 0L
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) return@forEachIndexed
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Download '$name' segment $index sequential")
var rangeStart: Long? = null
var rangeLength: Long? = null
if (segment.bytesLength > 0) {
rangeLength = segment.bytesLength
val urlKey = segment.uri
if (segment.bytesStart >= 0) {
rangeStart = segment.bytesStart
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
} else {
val offset = rangeOffsets[urlKey] ?: 0L
rangeStart = offset
rangeOffsets[urlKey] = offset + segment.bytesLength
}
}
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val ivBytes = if (staticIvBytes != null) {
staticIvBytes!!
} else {
val sequenceNumber = mediaSequence + mediaSegmentIndex
buildSequenceIv(sequenceNumber)
}
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
}
val segmentLength = segmentBytes.size.toLong()
if (segmentLength > Int.MAX_VALUE) {
throw IllegalStateException("HLS media segment too large to handle.")
}
val avgLen = if (index == 0) {
segmentLength
} else {
if (index > 0) downloadedTotalLength / index else segmentLength
}
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
outStr.write(segmentBytes)
downloadedTotalLength += segmentLength
bytesSinceLastSpeedUpdate += segmentLength
val now = System.currentTimeMillis()
val elapsed = now - lastSpeedUpdateTime
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
bytesSinceLastSpeedUpdate = 0
lastSpeedUpdateTime = now
}
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
mediaSegmentIndex++
} finally {
outStr.close()
}
downloadedTotalLength += mapBytes.size
}
remuxWithFfmpegInPlace(targetFile)
val totalSegments = variantPlaylist.segments.size
var mediaSegmentIndex = 0
var bytesSinceLastSpeedUpdate = 0L
var lastSpeedUpdateTime = System.currentTimeMillis()
var lastSpeed = 0L
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) return@forEachIndexed
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Download '$name' segment $index sequential")
var rangeStart: Long? = null
var rangeLength: Long? = null
if (segment.bytesLength > 0) {
rangeLength = segment.bytesLength
val urlKey = segment.uri
if (segment.bytesStart >= 0) {
rangeStart = segment.bytesStart
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
} else {
val offset = rangeOffsets[urlKey] ?: 0L
rangeStart = offset
rangeOffsets[urlKey] = offset + segment.bytesLength
}
}
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val ivBytes = if (staticIvBytes != null) {
staticIvBytes
} else {
val sequenceNumber = mediaSequence + mediaSegmentIndex
buildSequenceIv(sequenceNumber)
}
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
}
val segmentLength = segmentBytes.size.toLong()
if (segmentLength > Int.MAX_VALUE) {
throw IllegalStateException("HLS media segment too large to handle.")
}
val avgLen = if (index == 0) {
segmentLength
} else {
if (index > 0) downloadedTotalLength / index else segmentLength
}
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outStr = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
outStr.write(segmentBytes)
} finally {
outStr.close()
}
downloadedTotalLength += segmentLength
bytesSinceLastSpeedUpdate += segmentLength
val now = System.currentTimeMillis()
val elapsed = now - lastSpeedUpdateTime
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
bytesSinceLastSpeedUpdate = 0
lastSpeedUpdateTime = now
}
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
mediaSegmentIndex++
}
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "Finished HLS Source for $name")
} catch (ioex: IOException) {
if (targetFile.exists())
@@ -843,6 +847,11 @@ class VideoDownload {
targetFile.delete()
throw ex
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength
}
@@ -102,6 +102,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
private var moreColumns = 3;
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
_inflater = inflater;
@@ -170,6 +172,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
setMoreVisible(false);
}
})
moreColumns = columns;
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
_layoutMoreButtons.layoutManager = layoutManager;
@@ -321,29 +324,37 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutMoreButtons.removeAllViews();
var insertedButtons = 0;
//Force settings to be first
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
if (settingsIndex != -1) {
val button = buttons[settingsIndex]
buttons.removeAt(settingsIndex)
buttons.add(0, button)
//insertedButtons++;
}
//Force buy to be on top for more buttons
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
if (buyIndex != -1) {
val button = buttons[buyIndex]
buttons.removeAt(buyIndex)
buttons.add(0, button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
//Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(if (insertedButtons == 1) 1 else 0, button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
//Force privacy to be third
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
if (privacyIndex != -1) {
val button = buttons[privacyIndex]
buttons.removeAt(privacyIndex)
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
val newButtons = mutableListOf<MenuButtonItem>();
@@ -602,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (c is Activity) {
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
}*/
}),
}),/*
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
@@ -612,7 +623,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
UIDialogs.Action("Enable", {
StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.PRIMARY));
}),
}),*/
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
})
@@ -124,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
}
}
fun leaveDirectory() {
if(navStack.size > 1) {
navStack.removeLast();
openDirectory(navStack.last());
if (navStack.size > 1) {
navStack.removeAt(navStack.size - 1)
openDirectory(navStack.last())
}
else {}
}
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
if(addToStack)
@@ -436,9 +436,9 @@ class StateApp {
try {
val caFile = AppCaUpdater.ensureCaBundle(context)
Libcurl.setDefaultCAPath(caFile.absolutePath)
Logger.i(TAG, "Libcurl initialized")
} catch (t: Throwable) {
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
Logger.e(TAG, "Failed to initialize Libcurl", t);
}
}
@@ -21,7 +21,7 @@ class StateAssets {
if(part == "." || part == "..") {
if(parentAllowance <= 0)
throw IllegalStateException("Path [${path}] attempted to escape path..");
parts1.removeLast();
parts1.removeAt(parts1.size - 1);
toSkip++;
}
else
@@ -11,7 +11,7 @@ class SearchHistoryStorage : FragmentedStorageFileJson() {
if (!lastQueries.contains(text)) {
lastQueries.add(0, text);
if (lastQueries.size > 10)
lastQueries.removeLast();
lastQueries.removeAt(lastQueries.size - 1);
}
else {
lastQueries.remove(text);
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -109,6 +109,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layoutDirection="rtl"
android:gravity="end">
</androidx.recyclerview.widget.RecyclerView>