Compare commits

...

73 Commits

Author SHA1 Message Date
Kelvin 8437825dd1 apply language filters to downloads 2025-12-17 20:29:45 +01:00
Kelvin 0fbe0bb438 Add filters for video languages to resolve excessive sources 2025-12-17 19:43:56 +01:00
Kelvin 34d2e62314 sub mods 2025-12-17 16:27:12 +01:00
Kelvin 1075ded170 Language for video support, original for video support, deduplication fix for languages on videos, submods 2025-12-17 15:32:37 +01:00
Koen J 80bb15f3fb Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-15 10:03:34 +01:00
Koen J 27a86a67f0 Updated submodules and fixed casting for combined request executor. 2025-12-15 10:03:18 +01:00
Koen 284b2a24f8 Merge branch 'marcus/casting-sdk-updates' into 'master'
casting: subscribe to and handle MediaItemEnd events

See merge request videostreaming/grayjay!158
2025-12-15 09:01:31 +00:00
Kelvin K 854d1506a6 Compile fix 2025-12-11 17:17:42 -06:00
Kelvin K 811fd4e73e Improved dl 2025-12-11 17:16:31 -06:00
Kelvin K 335988aa67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-11 14:16:07 -06:00
Kelvin K 29a54fbed4 Download support combined 2025-12-11 14:15:55 -06:00
Koen J 3a11d0d9d1 Fixed HLS downloading for Twitch, DialyMotion, Nebula. 2025-12-05 15:31:31 +01:00
Koen J bda534e485 Various updates to bg update flow:
- Throttled progress updates in notifications resolving the notifications not showing under some conditions.
- Properly cancel notifications when interacting with in-app dialogs.
- Added install failed notification.
- Added install success notification.
- Added default behavior for tapping on notifications.
- Fixed crash in install receiver.
2025-12-04 11:18:00 +01:00
Kelvin K 09fd4c0881 Fix it asking for background updating when not required 2025-12-03 18:37:06 -06:00
Kelvin K 1667866a35 Hotfix invalid closed state 2025-12-03 18:08:36 -06:00
Kelvin K 035125d0f8 Hotfix invalid closed state 2025-12-03 18:06:38 -06:00
Kelvin K 1bb0cdc405 Add exception handling for background updater 2025-12-03 12:49:08 -06:00
Kelvin K 86019c80a1 Fix in-video login flow 2025-12-03 11:58:00 -06:00
Kelvin K 8c640d3def Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:44:58 -06:00
Kelvin K 7ed1e8a28b NEw install dialog, incognito dont show fix, crash fix old android search library 2025-12-03 11:44:27 -06:00
Koen J 3dcfe8c340 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 18:19:26 +01:00
Koen J 042ced81ef Fix for update when app is fully killed. 2025-12-03 18:18:43 +01:00
Kelvin K b37f48380b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:10:35 -06:00
Kelvin K 0a02169782 Fix PiP for back button 2025-12-03 11:10:22 -06:00
Koen J f12e4390f3 Changed the order of buttons. 2025-12-03 17:48:47 +01:00
Koen J 82ab45d04e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 17:35:06 +01:00
Koen J 7f77c39296 Made notifications for update silent. 2025-12-03 17:33:41 +01:00
Kelvin K 99eee4f6ee Disable misbehaving thumbnail rendering 2025-12-03 10:27:40 -06:00
Kelvin K 68886502d1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 09:58:16 -06:00
Kelvin K 26461c21c4 move to background on back with video 2025-12-03 09:58:08 -06:00
Koen J 300466f722 Update dialogs should nicely be hidden when interacting with notifications. 2025-12-03 16:33:51 +01:00
Koen J 961710cc8b Fixed thumbnails acting up and added support for library content thumbnail when casting. 2025-12-03 15:37:53 +01:00
Koen J eba995f87d Added support for casting local media content. 2025-12-03 14:09:33 +01:00
Koen a67244e79a Merge branch 'marcus/cast-dev-connection-state-fix' into 'master'
casting: set connectionState to correct value when disconnected

See merge request videostreaming/grayjay!160
2025-12-03 11:16:08 +00:00
Marcus Hanestad 70502a7651 casting: set connectionState to correct value when disconnected 2025-12-03 12:12:15 +01:00
Koen J 36b4f5b41d Potential fix for issue where cast icon doesn't properly turn blue at the right moment. 2025-12-03 12:04:54 +01:00
Kelvin K def39ba397 Diff loop icon, allow loop1 in playlist, fix queue clear on opening video on a channel page 2025-12-02 17:11:12 -06:00
Kelvin K 49d59f4466 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-02 15:21:36 -06:00
Kelvin K 1c9becc2ba Replace finalize with manual close for jsrequestexecutor, incognito icon change, show explanation for incognito 2025-12-02 15:21:22 -06:00
Koen 1cde591061 Merge branch 'bgupdate' into 'master'
BG update initial impl.

See merge request videostreaming/grayjay!159
2025-12-02 17:31:23 +00:00
Koen 8ac18f053c BG update initial impl. 2025-12-02 17:31:23 +00:00
Koen J 56bdae9ff1 Fixed crash related to ShapeLayout for BigButton. 2025-12-01 16:25:31 +01:00
Koen J 74ddfe9f0e 2 crash fixes. 2025-12-01 14:41:35 +01:00
Koen J acb9500e2a Re-enabled some logging. 2025-12-01 14:30:35 +01:00
Koen J 45f621763a Fixed thumbnail to consider max size 1920 and fitcenter and implemented fixes for pagination of library. 2025-12-01 14:20:41 +01:00
Koen J 0abc65a9bd Downsample if larger than 1080x1080 to prevent crashing. 2025-12-01 11:51:26 +01:00
Koen J 6d6309973e Fixed crash on devices that don't support android.content.pm.action.CONFIRM_INSTALL 2025-12-01 10:58:04 +01:00
Koen J 92ec085d25 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-01 09:31:24 +01:00
Koen J 767a8befaa Fixed crash happening due to recycled bitmap in notification. 2025-12-01 09:31:08 +01:00
Kelvin K 09763320dd login from devportal fix 2025-11-28 16:59:15 -06:00
Kelvin K 27fb2997f9 Minimize and maximize for prompted login 2025-11-28 15:30:03 -06:00
Kelvin K 0f46bc5888 improved motionlayout responsiveness 2025-11-28 12:15:08 -06:00
Kelvin K dccf4fcf3c more reliable motion event triggesr 2025-11-28 11:58:17 -06:00
Kelvin K da7fef1ecd Detect settings as a active tab 2025-11-28 11:37:52 -06:00
Kelvin K 58a89a00ef Make motionlayout transition coverage smaller 2025-11-28 11:24:03 -06:00
Kelvin K f2efc603ba Legacy text rendering for subtitles 2025-11-27 11:12:40 -06:00
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
Marcus Hanestad 894e400819 casting: subscribe to and handle MediaItemEnd events 2025-11-27 16:56:43 +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
117 changed files with 2643 additions and 780 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
+2 -2
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) implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS //JS
implementation 'com.caoccao.javet:javet-v8-android:5.0.1' implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
//Exoplayer //Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.8.0' implementation 'androidx.media3:media3-exoplayer:1.8.0'
@@ -232,7 +232,7 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK //Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') { implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
// Polycentricandroid includes this // Polycentricandroid includes this
exclude group: 'net.java.dev.jna' exclude group: 'net.java.dev.jna'
} }
+19
View File
@@ -29,6 +29,8 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo" android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:replace="android:enableOnBackInvokedCallback"
android:enableOnBackInvokedCallback="false"
tools:targetApi="31" tools:targetApi="31"
android:largeHeap="true"> android:largeHeap="true">
<provider <provider
@@ -61,6 +63,7 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:resizeableActivity="true" android:resizeableActivity="true"
android:supportsPictureInPicture="true"> android:supportsPictureInPicture="true">
@@ -249,5 +252,21 @@
android:name=".activities.QRCodeFullscreenActivity" android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<service
android:name=".UpdateDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".UpdateActionReceiver"
android:exported="false" />
<activity
android:name=".activities.InstallUpdateActivity"
android:exported="false"
android:theme="@style/Theme.App.TransparentNoUi"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
</application> </application>
</manifest> </manifest>
+7
View File
@@ -415,6 +415,8 @@ class VideoUrlSource {
this.url = obj.url; this.url = obj.url;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class VideoUrlWidevineSource extends VideoUrlSource { class VideoUrlWidevineSource extends VideoUrlSource {
@@ -512,6 +514,8 @@ class HLSSource {
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class DashSource { class DashSource {
@@ -525,6 +529,8 @@ class DashSource {
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class DashWidevineSource extends DashSource { class DashWidevineSource extends DashSource {
@@ -550,6 +556,7 @@ class DashManifestRawSource {
this.language = obj.language ?? Language.UNKNOWN; this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.original = obj?.original;
} }
} }
@@ -387,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.audio_languages) @DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context): String? { fun getPrimaryLanguage(context: Context? = null): String? {
return when(primaryLanguage) { return when(primaryLanguage) {
0 -> "en"; 0 -> "en";
1 -> "es"; 1 -> "es";
@@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.auto_update_when_array) @DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0; var check: Int = 0;
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1) @FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download) //@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0; var shouldBackgroundDownload: Boolean = false;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2) @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download) @DropdownFieldOptionsId(R.array.when_download)
@@ -1052,6 +1052,8 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7) @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true; var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
} }
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19) @FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@@ -370,17 +370,19 @@ class UIDialogs {
} }
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
setOnDismissListener { dismissAction?.invoke() }
}
} }
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE) val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
} }
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) { fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
@@ -403,13 +405,6 @@ class UIDialogs {
dialog.setMaxVersion(lastVersion); dialog.setMaxVersion(lastVersion);
} }
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.showPredownloaded(apkFile);
}
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) { fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
if(!store.hasMissingReconstructions()) if(!store.hasMissingReconstructions())
onConcluded(); onConcluded();
@@ -5,6 +5,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.OptIn import androidx.annotation.OptIn
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import androidx.core.net.toUri import androidx.core.net.toUri
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import kotlin.collections.toList
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@@ -573,6 +576,51 @@ class UISlideOverlays {
return null; return null;
} }
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
if(languageFilters != null) items.add(languageFilters)
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf((if (audioSources != null) listOf(SlideUpMenuItem( listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context, container.context,
@@ -609,7 +657,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
is JSDashManifestRawSource -> { is JSDashManifestRawSource -> {
@@ -629,7 +683,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
is IHLSManifestSource -> { is IHLSManifestSource -> {
@@ -643,7 +703,13 @@ class UISlideOverlays {
showHlsPicker(video, it, it.url, container) showHlsPicker(video, it, it.url, container)
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
else -> { else -> {
@@ -0,0 +1,63 @@
package com.futo.platformplayer
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, cancelIntent)
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
}
}
@@ -0,0 +1,64 @@
package com.futo.platformplayer
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
Logger.i(TAG, "Auto-update disabled, skipping worker run")
return Result.success()
}
return withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient()
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
if (latestVersion == null) {
Logger.w(TAG, "Failed to fetch latest version in worker")
return@withContext Result.retry()
}
val currentVersion = BuildConfig.VERSION_CODE
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
if (latestVersion <= currentVersion) {
return@withContext Result.success()
}
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
}
}
Result.success()
} catch (t: Throwable) {
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
Result.retry()
}
}
}
companion object {
private const val TAG = "UpdateCheckWorker"
const val UNIQUE_WORK_NAME = "updateCheck"
}
}
@@ -0,0 +1,261 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
class UpdateDownloadService : Service() {
companion object {
private const val TAG = "UpdateDownloadService"
const val EXTRA_VERSION = "version"
const val EXTRA_CANCEL = "cancel"
private const val MAX_RETRIES = 5
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
@Volatile
private var isDownloading: Boolean = false
@Volatile
private var cancelRequested: Boolean = false
private var lastProgressUpdateElapsedMs: Long = 0L
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
stopSelf()
return START_NOT_STICKY
}
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
val version = intent.getIntExtra(EXTRA_VERSION, 0)
if (version == 0) {
stopSelf()
return START_NOT_STICKY
}
if (isDownloading) {
Logger.i(TAG, "Download already in progress, ignoring new start")
return START_STICKY
}
isDownloading = true
cancelRequested = false
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
scope.launch {
downloadApk(version)
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
}
}
private suspend fun downloadApk(version: Int) {
val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version)
try {
if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
onDownloadComplete(version, apkFile)
return
}
var backoffMs = INITIAL_BACKOFF_MS
for (attempt in 0 until MAX_RETRIES) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
break
}
try {
performDownload(StateUpdate.APK_URL, partialFile, version)
if (!cancelRequested) {
if (apkFile.exists()) {
apkFile.delete()
}
if (!partialFile.renameTo(apkFile)) {
throw IllegalStateException("Failed to rename partial APK file")
}
onDownloadComplete(version, apkFile)
}
break
} catch (t: Throwable) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled by user", t)
break
}
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
delay(backoffMs)
backoffMs *= 2
}
}
}
} finally {
isDownloading = false
cancelRequested = false
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun performDownload(url: String, partialFile: File, version: Int) {
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
if (startOffset > 0L) {
setRequestProperty("Range", "bytes=$startOffset-")
}
}
connection.connect()
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
partialFile.delete()
startOffset = 0L
} else if (responseCode != HttpURLConnection.HTTP_OK &&
responseCode != HttpURLConnection.HTTP_PARTIAL) {
throw IllegalStateException("Unexpected HTTP response code $responseCode")
}
val contentLength = connection.contentLengthLong
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
val buffer = ByteArray(BUFFER_SIZE)
var downloaded = 0L
var lastProgress = -1
connection.inputStream.use { input ->
FileOutputStream(partialFile, startOffset > 0L).use { output ->
while (!cancelRequested) {
val read = input.read(buffer)
if (read == -1) {
break
}
output.write(buffer, 0, read)
downloaded += read
if (totalBytes > 0L) {
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
if (progress != lastProgress) {
lastProgress = progress
val safeProgress = when {
progress < 0 -> 0
progress > 100 -> 100
else -> progress
}
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
}
} else {
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
}
}
if (!cancelRequested && totalBytes > 0L) {
val finalProgress = 100
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
}
output.flush()
}
}
if (cancelRequested) {
throw CancellationException("Download cancelled")
}
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
}
} finally {
connection?.disconnect()
}
}
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
}
}
}
}
}
}
@@ -0,0 +1,122 @@
package com.futo.platformplayer
import android.annotation.SuppressLint
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable
import android.provider.Settings
import android.view.View
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.InstallReceiver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@SuppressLint("RequestInstallPackagesPolicy")
fun startInstall(context: Context, version: Int, apkFile: File) {
if (!apkFile.exists()) {
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
UIDialogs.toast(context, "Update file missing")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
return
}
if (BuildConfig.IS_PLAYSTORE_BUILD) {
UIDialogs.toast(context, "Updates are managed by the Play Store")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
return
}
try {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
UIDialogs.toast(context, "Allow this app to install updates, then try again")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return
}
} catch (t: Throwable) {
Logger.e(TAG, "Failed to check unknown sources permission", t)
}
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
session.fsync(sessionStream)
}
val intent = Intent(context, InstallReceiver::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
}
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val statusReceiver = pendingIntent.intentSender
InstallReceiver.onReceiveResult.subscribe(this) { message ->
InstallReceiver.onReceiveResult.clear();
onReceiveResult(context, version, apkFile, message);
};
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver)
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
} finally {
session?.close()
inputStream?.close()
}
}
}
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
try {
InstallReceiver.onReceiveResult.remove(this)
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
}
}
}
@@ -0,0 +1,233 @@
package com.futo.platformplayer
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.InstallUpdateActivity
import java.io.File
object UpdateNotificationManager {
private const val CHANNEL_ID = "app_updates"
private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
description = CHANNEL_DESCRIPTION
enableVibration(false)
enableLights(false)
setSound(null, null)
}
manager.createNotificationChannel(channel)
}
}
fun showInstallSucceededNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val launchIntent = context.packageManager
.getLaunchIntentForPackage(context.packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
val launchPendingIntent = launchIntent?.let {
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update installed")
.setContentText("Version $version installed. Tap to open.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
if (launchPendingIntent != null) {
builder.setContentIntent(launchPendingIntent)
builder.addAction(0, "Open app", launchPendingIntent)
}
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context)
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_DOWNLOAD_CANCEL
putExtra(EXTRA_VERSION, version)
}
val cancelPendingIntent = getBroadcast(
context,
3,
cancelIntent,
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Downloading update")
.setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setSilent(true)
.addAction(0, "Cancel", cancelPendingIntent)
if (indeterminate) {
builder.setProgress(0, 0, true)
} else {
builder.setProgress(100, progress, false)
}
return builder.build()
}
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
}
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update downloaded")
.setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(installPendingIntent)
.setAutoCancel(true)
.setSilent(true)
.addAction(0, "Install", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to download update")
.setContentText(error?.message ?: "Unknown error")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
return
ensureChannel(context)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to install update")
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
.setAutoCancel(true)
.setSilent(true)
.setContentIntent(installPendingIntent)
.addAction(0, "Install again", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -5,8 +5,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build import android.os.Build
import android.os.Looper import android.os.Looper
import android.os.OperationCanceledException import android.os.OperationCanceledException
@@ -44,6 +42,9 @@ import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
import androidx.core.graphics.scale
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String { fun getRandomString(sizeOfRandomString: Int): String {
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
it.flush(); it.flush();
}; };
fun loadBitmap(url: String): Bitmap {
try {
val client = ManagedHttpClient();
val response = client.get(url);
if (response.isOk && response.body != null) {
val bitmapStream = response.body.byteStream();
val bitmap = BitmapFactory.decodeStream(bitmapStream);
return bitmap;
} else {
throw Exception("Failed to find data at URL.");
}
} catch (e: Throwable) {
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
throw e;
}
}
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) { fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
this.movementMethod = PlatformLinkMovementMethod(context); this.movementMethod = PlatformLinkMovementMethod(context);
} }
@@ -458,4 +442,11 @@ fun addressScore(addr: InetAddress): Int {
} }
} }
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this) fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
return this;
//.downsample(DownsampleStrategy.AT_MOST)
//.override(maxSizePx, maxSizePx)
//.centerInside()
}
@@ -0,0 +1,49 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import java.io.File
class InstallUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UpdateNotificationManager.cancelAll(this)
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
finish()
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
UIDialogs.Companion.toast(this, "Update file missing")
finish()
return
}
UpdateInstaller.startInstall(this, version, apkFile)
finish()
}
companion object {
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
Intent(context, InstallUpdateActivity::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
@@ -8,6 +8,7 @@ import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -244,19 +245,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
} }
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
};
fun requestNotificationPermissions() {
_notificationPermissionLauncher?.launch(_notifPermission);
}
val mainId = UUID.randomUUID().toString().substring(0, 5) val mainId = UUID.randomUUID().toString().substring(0, 5)
@@ -332,6 +320,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//Preload common files to memory //Preload common files to memory
FragmentedStorage.get<SubscriptionStorage>(); FragmentedStorage.get<SubscriptionStorage>();
FragmentedStorage.get<Settings>(); FragmentedStorage.get<Settings>();
@@ -418,12 +410,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
updateSegmentPaddings(); updateSegmentPaddings();
}; };
_fragVideoDetail.onTransitioning.subscribe { _fragVideoDetail.onTransitioning.subscribe {
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
Logger.i(TAG, "onTransition Setting elevation higher");
_fragContainerOverlay.elevation = _fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics); TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
else }
else {
Logger.i(TAG, "onTransition Setting elevation lower");
_fragContainerOverlay.elevation = _fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
}
} }
_fragVideoDetail.onCloseEvent.subscribe { _fragVideoDetail.onCloseEvent.subscribe {
@@ -621,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply() sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus") val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount() val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
@@ -1298,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(last.first, last.second, false, true); navigate(last.first, last.second, false, true);
} else { } else {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
finish(); finish();
} else { } else {
//UIDialogs.toast("Grayjay continues in background because of an open video.")
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
try {
_fragVideoDetail._viewDetail?.startPictureInPicture();
_fragVideoDetail?.forcePictureInPicture();
} catch (ex: Throwable) {
} //Fail silently
}
else
moveTaskToBack(false);
/*
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", { UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish(); finish();
}) })
*/
} }
} }
} }
@@ -0,0 +1,318 @@
package com.futo.platformplayer.api.http.server.handlers
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.*
class HttpContentUriHandler(
method: String,
path: String,
private val contentResolver: ContentResolver,
private val uri: Uri,
private val explicitContentType: String? = null
) : HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) {
val resolver = contentResolver
val requestHeaders = httpContext.headers
val responseHeaders = this.headers.clone()
val meta = try {
queryMetadata(resolver, uri)
} catch (e: Exception) {
Logger.e(TAG, "Failed to query metadata for $uri", e)
httpContext.respondCode(404, responseHeaders)
return
}
val contentType = explicitContentType
?: resolver.getType(uri)
?: "application/octet-stream"
responseHeaders["Content-Type"] = contentType
meta.lastModifiedMillis?.let { lastModified ->
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
if (ifModifiedSinceHeader != null) {
val ifModifiedSince = try {
httpDateFormat.parse(ifModifiedSinceHeader)
} catch (_: Exception) {
null
}
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
httpContext.respondCode(304, responseHeaders)
return
}
}
}
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
val length = meta.size
if (length == null) {
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
responseHeaders.remove("Content-Length")
responseHeaders.remove("Content-Range")
responseHeaders.remove("Accept-Ranges")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = null,
length = null
)
return
}
responseHeaders["Accept-Ranges"] = "bytes"
val rangeHeader = requestHeaders["Range"]
if (rangeHeader.isNullOrBlank()) {
responseHeaders["Content-Length"] = length.toString()
Logger.i(TAG, "Sending full content for $uri, length=$length")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = 0L,
length = length
)
return
}
val range = parseRange(rangeHeader, length)
if (range == null) {
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
responseHeaders["Content-Range"] = "bytes */$length"
httpContext.respondCode(416, responseHeaders)
return
}
val start = range.first
val endInclusive = range.last
val bytesToSend = endInclusive - start + 1
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
responseHeaders["Content-Length"] = bytesToSend.toString()
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 206,
headers = responseHeaders,
start = start,
length = bytesToSend
)
}
data class ContentMeta(
val displayName: String?,
val size: Long?,
val lastModifiedMillis: Long?
)
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
var displayName: String? = null
var size: Long? = null
var lastModifiedMillis: Long? = null
resolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
displayName = cursor.getString(nameIndex)
}
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
val s = cursor.getLong(sizeIndex)
if (s >= 0) size = s // -1 means unknown
}
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
val seconds = cursor.getLong(dateModifiedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
if (lastModifiedMillis == null) {
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
val seconds = cursor.getLong(dateAddedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
}
}
}
if (displayName == null) {
displayName = uri.lastPathSegment
}
if (size == null) {
try {
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
val assetLen = afd.length
if (assetLen >= 0) {
size = assetLen
}
}
} catch (_: Exception) { }
}
return ContentMeta(
displayName = displayName,
size = size,
lastModifiedMillis = lastModifiedMillis
)
}
private fun parseRange(header: String, totalLength: Long): LongRange? {
if (totalLength <= 0L) return null
val prefix = "bytes="
if (!header.startsWith(prefix, ignoreCase = true)) return null
val spec = header.substring(prefix.length).trim()
if (spec.isEmpty()) return null
if (spec.contains(",")) return null
val dashIndex = spec.indexOf('-')
if (dashIndex < 0) return null
val startPart = spec.substring(0, dashIndex).trim()
val endPart = spec.substring(dashIndex + 1).trim()
return when {
startPart.isNotEmpty() -> {
val start = startPart.toLongOrNull() ?: return null
if (start < 0 || start >= totalLength) return null
val end = if (endPart.isNotEmpty()) {
val rawEnd = endPart.toLongOrNull() ?: return null
if (rawEnd < start) return null
rawEnd.coerceAtMost(totalLength - 1)
} else {
totalLength - 1
}
start..end
}
endPart.isNotEmpty() -> {
val suffixLen = endPart.toLongOrNull() ?: return null
if (suffixLen <= 0L) return null
if (suffixLen >= totalLength) {
0L..(totalLength - 1)
} else {
val start = totalLength - suffixLen
val end = totalLength - 1
start..end
}
}
else -> null
}
}
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
try {
val input = resolver.openInputStream(uri)
if (input == null) {
Logger.w(TAG, "Content not found: $uri")
httpContext.respondCode(404, headers)
return
}
input.use { inputStream ->
httpContext.respond(statusCode, headers) { outputStream ->
try {
val offset = start ?: 0L
if (offset > 0L) {
skipFully(inputStream, offset)
}
copyStream(inputStream, outputStream, length)
outputStream.flush()
} catch (e: Exception) {
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
}
}
}
} catch (e: FileNotFoundException) {
Logger.w(TAG, "Content not found: $uri", e)
httpContext.respondCode(404, headers)
} catch (e: Exception) {
Logger.e(TAG, "Failed to open stream for $uri", e)
httpContext.respondCode(500, headers)
}
}
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
val buffer = ByteArray(8192)
if (limit == null) {
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
} else {
var remaining = limit
while (remaining > 0L) {
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
val read = input.read(buffer, 0, toRead)
if (read < 0) break
output.write(buffer, 0, read)
remaining -= read.toLong()
}
}
}
private fun skipFully(input: InputStream, bytesToSkip: Long) {
var remaining = bytesToSkip
while (remaining > 0L) {
val skipped = input.skip(remaining)
if (skipped <= 0L) {
val b = input.read()
if (b == -1) break
remaining -= 1L
} else {
remaining -= skipped
}
}
}
companion object {
private const val TAG = "HttpContentUriHandler"
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("GMT")
}
}
}
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) { constructor(url : String) {
this.url = url; this.url = url;
} }
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) { constructor(url : String) {
this.url = url; this.url = url;
} }
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
override val priority: Boolean, override val priority: Boolean,
val url: String val url: String
) : IVideoUrlSource { ) : IVideoUrlSource {
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl(): String { override fun getVideoUrl(): String {
return url return url
} }
@@ -9,4 +9,6 @@ interface IVideoSource {
val bitrate : Int?; val bitrate : Int?;
val duration: Long; val duration: Long;
val priority: Boolean; val priority: Boolean;
val language: String?;
val original: Boolean?;
} }
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
val filePath : String; val filePath : String;
val fileSize : Long; val fileSize : Long;
@@ -19,6 +19,9 @@ open class VideoUrlSource(
) : IVideoUrlSource, IStreamMetaDataSource { ) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null; override var streamMetaData: StreamMetaData? = null;
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl() : String { override fun getVideoUrl() : String {
return url; return url;
} }
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false) override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
(LocalVideoUnMuxedSourceDescriptor( (LocalVideoUnMuxedSourceDescriptor(
arrayOf(), arrayOf(),
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name)) arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
)) ))
else (LocalVideoMuxedSourceDescriptor( else (LocalVideoMuxedSourceDescriptor(
LocalVideoContentSource(url, mimeType ?: "", name) LocalVideoContentSource(url, mimeType ?: "", name, duration)
)) ))
); );
override val preview: ISerializedVideoSourceDescriptor? = null; override val preview: ISerializedVideoSourceDescriptor? = null;
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.invokeV8Void
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.Base64 import java.util.Base64
class JSRequestExecutor { class JSRequestExecutor: AutoCloseable {
private val _plugin: JSClient; private val _plugin: JSClient;
private val _config: IV8PluginConfig; private val _config: IV8PluginConfig;
private var _executor: V8ValueObject; private var _executor: V8ValueObject;
@@ -29,6 +32,9 @@ class JSRequestExecutor {
private val hasCleanup: Boolean; private val hasCleanup: Boolean;
private var _cleanLock = Any();
private var _cleaned: Boolean = false;
constructor(plugin: JSClient, executor: V8ValueObject) { constructor(plugin: JSClient, executor: V8ValueObject) {
this._plugin = plugin; this._plugin = plugin;
this._executor = executor; this._executor = executor;
@@ -102,8 +108,12 @@ class JSRequestExecutor {
open fun cleanup() { open fun cleanup() {
if (!hasCleanup || _executor.isClosed) synchronized(_cleanLock) {
return; if (!hasCleanup || _executor.isClosed || _cleaned)
return;
_cleaned = true;
}
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
_plugin.busy { _plugin.busy {
if(_plugin is DevJSClient) if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -125,9 +135,25 @@ class JSRequestExecutor {
} }
} }
protected fun finalize() { override fun close() {
cleanup(); cleanup();
} }
fun closeAsync() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
try {
close();
}
catch(ex: Throwable) {
Logger.e("JSRequestExecutor", "Cleanup failed");
}
}
}
/*
protected fun finalize() {
cleanup();
}*/
} }
//TODO: are these available..? //TODO: are these available..?
@@ -39,6 +39,10 @@ open class JSDashManifestRawSource(
private val ctx = "DashRawSource" private val ctx = "DashRawSource"
private val cfg = plugin.config private val cfg = plugin.config
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override val container: String = override val container: String =
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml" _obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
@@ -185,6 +189,9 @@ class JSDashManifestMergingRawSource(
override val priority: Boolean override val priority: Boolean
get() = video.priority; get() = video.priority;
override val language: String? get() = audio.language
override val original: Boolean? get() = audio.original;
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> { override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
val videoDashDef = video.generateAsync(scope); val videoDashDef = video.generateAsync(scope);
val audioDashDef = audio.generateAsync(scope); val audioDashDef = audio.generateAsync(scope);
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashSource"; val contextName = "DashSource";
val config = plugin.config; val config = plugin.config;
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
duration = _obj.getOrThrow(config, "duration", contextName); duration = _obj.getOrThrow(config, "duration", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = obj.getOrNull(config, "language", contextName);
original = obj.getOrNull(config, "original", contextName);
} }
override fun getVideoUrl(): String { override fun getVideoUrl(): String {
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
override val licenseUri: String override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean override val hasLicenseRequestExecutor: Boolean
override val language: String?;
override val original: Boolean?;
@Suppress("ConvertSecondaryConstructorToPrimary") @Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashWidevineSource" val contextName = "DashWidevineSource"
@@ -40,6 +43,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName) licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor") hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
} }
override fun getLicenseRequestExecutor(): JSRequestExecutor? { override fun getLicenseRequestExecutor(): JSRequestExecutor? {
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSSource"; val contextName = "HLSSource";
val config = plugin.config; val config = plugin.config;
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong(); duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
} }
} }
@@ -44,6 +44,9 @@ open class JSVideoUrlSource(
override var priority: Boolean = override var priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false _obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override fun getVideoUrl(): String = url override fun getVideoUrl(): String = url
override fun toString(): String = override fun toString(): String =
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
var contentUrl: String; var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) { constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File"; this.name = name ?: "File";
container = mime; container = mime;
duration = 0; this.duration = duration;
this.contentUrl = contentUrl; this.contentUrl = contentUrl;
} }
@@ -20,14 +20,17 @@ class LocalVideoContentSource: IVideoSource {
override val duration: Long; override val duration: Long;
override val priority: Boolean = false; override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
var contentUrl: String; var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) { constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File"; this.name = name ?: "File";
width = 0; width = 0;
height = 0; height = 0;
container = mime; container = mime;
duration = 0; this.duration = duration;
this.contentUrl = contentUrl; this.contentUrl = contentUrl;
} }
} }
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
override val duration: Long; override val duration: Long;
override val priority: Boolean = false; override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = null;
var file: File; var file: File;
constructor(file: File) { constructor(file: File) {
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.Metadata
@@ -16,6 +17,7 @@ abstract class CastingDevice {
abstract val onDurationChanged: Event1<Double> abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double> abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double> abstract val onSpeedChanged: Event1<Double>
abstract val onMediaItemEnd: Event0
abstract var connectionState: CastConnectionState abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean abstract var isPlaying: Boolean
@@ -2,12 +2,14 @@ package com.futo.platformplayer.casting
import android.os.Build import android.os.Build
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.polycentric.core.Event
import org.fcast.sender_sdk.ApplicationInfo import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.GenericMediaEvent import org.fcast.sender_sdk.MediaEvent
import org.fcast.sender_sdk.PlaybackState import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source import org.fcast.sender_sdk.Source
import java.net.InetAddress import java.net.InetAddress
@@ -15,8 +17,10 @@ import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr import org.fcast.sender_sdk.urlFormatIpAddr
@@ -63,6 +67,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
var onDurationChanged = Event1<Double>() var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>() var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>() var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
override fun connectionStateChanged(state: DeviceConnectionState) { override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state) onConnectionStateChanged.emit(state)
@@ -92,12 +97,14 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
// TODO // TODO
} }
override fun keyEvent(event: GenericKeyEvent) { override fun keyEvent(event: KeyEvent) {
// Unreachable // Unreachable
} }
override fun mediaEvent(event: GenericMediaEvent) { override fun mediaEvent(event: MediaEvent) {
// Unreachable if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
} }
override fun playbackError(message: String) { override fun playbackError(message: String) {
@@ -127,6 +134,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
get() = eventHandler.onVolumeChanged get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double> override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged get() = eventHandler.onSpeedChanged
override val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
override fun resumePlayback() = device.resumePlayback() override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback() override fun pausePlayback() = device.pausePlayback()
@@ -181,7 +190,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
resumePosition = resumePosition, resumePosition = resumePosition,
speed = speed, speed = speed,
volume = volume, volume = volume,
metadata = metadata metadata = metadata,
requestHeaders = null,
) )
) )
@@ -200,6 +210,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
speed = speed, speed = speed,
volume = volume, volume = volume,
metadata = metadata, metadata = metadata,
requestHeaders = null,
) )
) )
@@ -227,6 +238,13 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
eventHandler.onConnectionStateChanged.subscribe { newState -> eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) { when (newState) {
is DeviceConnectionState.Connected -> { is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr) usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr) localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED connectionState = CastConnectionState.CONNECTED
@@ -239,7 +257,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
} }
DeviceConnectionState.Disconnected -> { DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.CONNECTING connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED) onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
} }
} }
@@ -268,4 +286,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
companion object { companion object {
private val TAG = "CastingDeviceExp" private val TAG = "CastingDeviceExp"
} }
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
@@ -181,6 +182,7 @@ class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override val onMediaItemEnd: Event0 = Event0()
override var connectionState: CastConnectionState override var connectionState: CastConnectionState
get() = inner.connectionState get() = inner.connectionState
set(_) = Unit set(_) = Unit
@@ -6,6 +6,7 @@ import android.content.Context
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
@@ -14,6 +15,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
@@ -34,8 +36,11 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.awaitCancelConverted import com.futo.platformplayer.awaitCancelConverted
import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
@@ -78,6 +83,7 @@ abstract class StateCasting {
val onActiveDeviceTimeChanged = Event1<Double>(); val onActiveDeviceTimeChanged = Event1<Double>();
val onActiveDeviceDurationChanged = Event1<Double>(); val onActiveDeviceDurationChanged = Event1<Double>();
val onActiveDeviceVolumeChanged = Event1<Double>(); val onActiveDeviceVolumeChanged = Event1<Double>();
val onActiveDeviceMediaItemEnd = Event0()
var activeDevice: CastingDevice? = null; var activeDevice: CastingDevice? = null;
private var _videoExecutor: JSRequestExecutor? = null private var _videoExecutor: JSRequestExecutor? = null
private var _audioExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null
@@ -141,6 +147,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
ad.disconnect() ad.disconnect()
} }
@@ -155,6 +162,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
activeDevice = null; activeDevice = null;
} }
@@ -218,6 +226,9 @@ abstract class StateCasting {
device.onTimeChanged.subscribe { device.onTimeChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
}; };
device.onMediaItemEnd.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
}
try { try {
device.connect(); device.connect();
@@ -228,6 +239,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
return; return;
} }
@@ -235,9 +247,9 @@ abstract class StateCasting {
Logger.i(TAG, "Connect to device ${device.name}") Logger.i(TAG, "Connect to device ${device.name}")
} }
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
return Metadata( return Metadata(
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail() title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
) )
} }
@@ -371,6 +383,12 @@ abstract class StateCasting {
} else if (audioSource is LocalAudioSource) { } else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio"); Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition, speed); castLocalAudio(video, audioSource, resumePosition, speed);
} else if (videoSource is LocalVideoContentSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
} else if (audioSource is LocalAudioContentSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
} else if (videoSource is JSDashManifestRawSource) { } else if (videoSource is JSDashManifestRawSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource video"); Logger.i(TAG, "Casting as JSDashManifestRawSource video");
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
@@ -461,6 +479,65 @@ abstract class StateCasting {
} }
return true; return true;
} }
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val videoPath = "/video-${id}"
val videoUrl = url + videoPath;
val thumbnailPath = "/thumbnail-${id}"
val thumbnailUrl = url + thumbnailPath;
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
if (thumbnailContentUrl != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
}
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
return listOf(videoUrl);
}
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath;
val thumbnailPath = "/thumbnail-${id}"
val thumbnailUrl = url + thumbnailPath;
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
if (thumbnailContentUrl != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
}
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
return listOf(audioUrl);
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@@ -1254,8 +1331,14 @@ abstract class StateCasting {
return emptyList() return emptyList()
} }
var hasAudioInDash = false
for (representation in representationRegex.findAll(dashContent)) { for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
if (mediaType.startsWith("audio/")) {
hasAudioInDash = true
}
dashContent = mediaInitializationRegex.replace(dashContent) { dashContent = mediaInitializationRegex.replace(dashContent) {
if (it.range.first < representation.range.first || it.range.last > representation.range.last) { if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
return@replace it.value return@replace it.value
@@ -1279,12 +1362,20 @@ abstract class StateCasting {
throw Exception("Audio source without request executor not supported") throw Exception("Audio source without request executor not supported")
} }
if (audioSource != null && audioSource.hasRequestExecutor) { if (videoSource != null && videoSource.hasRequestExecutor) {
_audioExecutor = audioSource.getRequestExecutor() val oldVideoExecutor = _videoExecutor
oldVideoExecutor?.closeAsync()
_videoExecutor = videoSource.getRequestExecutor()
} }
if (videoSource != null && videoSource.hasRequestExecutor) { if (audioSource != null) {
_videoExecutor = videoSource.getRequestExecutor() val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = audioSource.getRequestExecutor()
} else if (hasAudioInDash && videoSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = _videoExecutor
} }
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also //TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
@@ -1315,7 +1406,7 @@ abstract class StateCasting {
}.withHeader("Access-Control-Allow-Origin", "*"), true }.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castDashRaw"); ).withTag("castDashRaw");
} }
if (audioSource != null) { if (audioSource != null || (audioSource == null && hasAudioInDash)) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler("GET", audioPath) { httpContext -> HttpFunctionHandler("GET", audioPath) { httpContext ->
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
@@ -29,6 +29,8 @@ import com.google.gson.FieldAttributes
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonParser import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.lang.reflect.Field import java.lang.reflect.Field
@@ -269,10 +271,12 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(403, "This plugin doesn't support auth"); context.respondCode(403, "This plugin doesn't support auth");
return; return;
} }
LoginFragment.showLogin(config){ StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
_testPluginVariables.clear(); LoginFragment.showLogin(config){
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config)); _testPluginVariables.clear();
}; _testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
}
/* /*
LoginActivity.showLogin(StateApp.instance.context, config) { LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear(); _testPluginVariables.clear();
@@ -16,9 +16,12 @@ import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -34,6 +37,8 @@ import java.io.InputStream
class AutoUpdateDialog(context: Context?) : AlertDialog(context) { class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
companion object { companion object {
private val TAG = "AutoUpdateDialog"; private val TAG = "AutoUpdateDialog";
var currentDialog: AutoUpdateDialog? = null
} }
private lateinit var _buttonNever: Button; private lateinit var _buttonNever: Button;
@@ -46,7 +51,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0; private var _maxVersion: Int = 0;
private var _updating: Boolean = false; private var _updating: Boolean = false;
private var _apkFile: File? = null;
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -61,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonShowChangelog = findViewById(R.id.button_show_changelog); _buttonShowChangelog = findViewById(R.id.button_show_changelog);
_buttonNever.setOnClickListener { _buttonNever.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
Settings.instance.autoUpdate.check = 1; Settings.instance.autoUpdate.check = 1;
Settings.instance.save(); Settings.instance.save();
dismiss(); dismiss();
}; };
_buttonClose.setOnClickListener { _buttonClose.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
dismiss(); dismiss();
}; };
@@ -76,23 +82,32 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
}; };
_buttonUpdate.setOnClickListener { _buttonUpdate.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
if (_updating) { if (_updating) {
return@setOnClickListener; return@setOnClickListener;
} }
_updating = true; if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
update(); val ctx = context.applicationContext;
val intent = Intent(ctx, UpdateDownloadService::class.java);
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
ContextCompat.startForegroundService(ctx, intent);
UIDialogs.toast(context, "Downloading update in background");
dismiss();
} else {
_updating = true;
update();
}
}; };
}
fun showPredownloaded(apkFile: File) { currentDialog = this
_apkFile = apkFile;
super.show()
} }
override fun dismiss() { override fun dismiss() {
super.dismiss() super.dismiss()
InstallReceiver.onReceiveResult.clear(); InstallReceiver.onReceiveResult.clear();
currentDialog = null
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.") Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
} }
@@ -118,21 +133,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null; var inputStream: InputStream? = null;
try { try {
val apkFile = _apkFile; val client = ManagedHttpClient();
if (apkFile != null) { val response = client.get(StateUpdate.APK_URL);
inputStream = apkFile.inputStream(); if (response.isOk && response.body != null) {
val dataLength = apkFile.length(); inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength); install(inputStream, dataLength);
} else { } else {
val client = ManagedHttpClient(); throw Exception("Failed to download latest version of app.");
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
throw Exception("Failed to download latest version of app.");
}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e); Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
@@ -1,12 +1,17 @@
package com.futo.platformplayer.downloads package com.futo.platformplayer.downloads
import android.content.Context import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaMuxer
import android.util.Log import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
@@ -37,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
@@ -83,6 +91,9 @@ import kotlin.time.times
class VideoDownload { class VideoDownload {
var state: State = State.QUEUED; var state: State = State.QUEUED;
@Contextual
@Transient
var plugin: IPlatformClient? = null;
var video: SerializedPlatformVideo? = null; var video: SerializedPlatformVideo? = null;
var videoDetails: SerializedPlatformVideoDetails? = null; var videoDetails: SerializedPlatformVideoDetails? = null;
@@ -98,6 +109,7 @@ class VideoDownload {
var videoSource: VideoUrlSource?; var videoSource: VideoUrlSource?;
var audioSource: AudioUrlSource?; var audioSource: AudioUrlSource?;
var overrideResultAudioSource: IAudioSource? = null;
@Contextual @Contextual
@Transient @Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?; val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@@ -267,7 +279,7 @@ class VideoDownload {
//Fetch full video object and determine source //Fetch full video object and determine source
if(video != null && videoDetails == null) { if(video != null && videoDetails == null) {
val original = StatePlatform.instance.getContentDetails(video!!.url).await(); val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
if(original !is IPlatformVideoDetails) if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?"); throw IllegalStateException("Original content is not media?");
@@ -434,6 +446,11 @@ class VideoDownload {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName(); videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container); videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath; videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
} }
if(actualAudioSource != null) { if(actualAudioSource != null) {
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName(); audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
@@ -487,7 +504,11 @@ class VideoDownload {
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
} }
else if(actualVideoSource is JSDashManifestRawSource) { else if(actualVideoSource is JSDashManifestRawSource) {
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback); if(actualAudioSource == null)
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
File(downloadDir, audioFileName!!));
else
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
} }
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name); else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
}); });
@@ -527,7 +548,7 @@ class VideoDownload {
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
} }
else if(actualAudioSource is JSDashManifestRawAudioSource) { else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback); audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
} }
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name); else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
}); });
@@ -585,55 +606,60 @@ class VideoDownload {
return cipher.doFinal(encryptedSegment) return cipher.doFinal(encryptedSegment)
} }
private fun remuxWithFfmpegInPlace(inputFile: File): Boolean { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
val inputPath = inputFile.absolutePath require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
if (!inputFile.exists()) {
Logger.w(TAG, "remuxWithFfmpegInPlace: input does not exist: $inputPath")
return false
}
val parent = inputFile.parentFile suspendCancellableCoroutine { continuation ->
if (parent == null) { val concatInput = buildString {
Logger.w(TAG, "remuxWithFfmpegInPlace: input has no parent: $inputPath") append("concat:")
return false append(
} segmentFiles.joinToString("|") { file ->
file.absolutePath
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}")
} }
if (!tmpFile.renameTo(inputFile)) { val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to move tmp: ${tmpFile.absolutePath}")
} else { val statisticsCallback = StatisticsCallback { _ ->
Logger.i(TAG, "remuxWithFfmpegInPlace: success for $inputPath (size=$newLen bytes)") //No callback
} }
return true val executorService = Executors.newSingleThreadExecutor()
} else {
Logger.e(TAG, "FFmpeg remux failed for $inputPath. rc=$returnCode, logs=${session.allLogsAsString}") val session = FFmpegKit.executeAsync(
tmpFile.delete() cmd,
return false { completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${completedSession.state}' " +
"and return code ${completedSession.returnCode}, " +
"stack trace ${completedSession.failStackTrace}"
}
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ log ->
Logger.v(TAG, log.message)
},
statisticsCallback,
executorService
)
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdownNow()
}
} }
} }
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()) if (targetFile.exists())
targetFile.delete() targetFile.delete()
@@ -678,6 +704,7 @@ class VideoDownload {
.array() .array()
} }
val segmentFiles = arrayListOf<File>()
try { try {
val playlistHeaders = mutableMapOf<String, String>() val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders) val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
@@ -713,123 +740,134 @@ class VideoDownload {
val mediaSequence = variantPlaylist.mediaSequence ?: 0L val mediaSequence = variantPlaylist.mediaSequence ?: 0L
val rangeOffsets = mutableMapOf<String, Long>() val rangeOffsets = mutableMapOf<String, Long>()
targetFile.outputStream().use { outStr -> if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
if (!variantPlaylist.mapUrl.isNullOrEmpty()) { if (isCancelled) throw CancellationException("Cancelled")
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Downloading HLS initialization map") Logger.i(TAG, "Downloading HLS initialization map")
var mapRangeStart: Long? = null var mapRangeStart: Long? = null
var mapRangeLength: Long? = null var mapRangeLength: Long? = null
if (variantPlaylist.mapBytesLength > 0) { if (variantPlaylist.mapBytesLength > 0) {
mapRangeLength = variantPlaylist.mapBytesLength mapRangeLength = variantPlaylist.mapBytesLength
val mapUrl = variantPlaylist.mapUrl!! val mapUrl = variantPlaylist.mapUrl
if (variantPlaylist.mapBytesStart >= 0) { if (variantPlaylist.mapBytesStart >= 0) {
mapRangeStart = variantPlaylist.mapBytesStart mapRangeStart = variantPlaylist.mapBytesStart
rangeOffsets[mapUrl] = rangeOffsets[mapUrl] =
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
} else { } else {
val offset = rangeOffsets[mapUrl] ?: 0L val offset = rangeOffsets[mapUrl] ?: 0L
mapRangeStart = offset mapRangeStart = offset
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
}
} }
}
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength) var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
if (useDecryption) { if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.") val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val iv = staticIvBytes val iv = staticIvBytes
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.") ?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
mapBytes = decryptSegment(mapBytes, kb, iv) mapBytes = decryptSegment(mapBytes, kb, iv)
} }
if (mapBytes.size.toLong() > Int.MAX_VALUE) { if (mapBytes.size.toLong() > Int.MAX_VALUE) {
throw IllegalStateException("HLS MAP segment too large to handle.") 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.write(mapBytes)
outStr.flush() outStr.flush()
downloadedTotalLength += mapBytes.size } finally {
} outStr.close()
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++
} }
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") Logger.i(TAG, "Finished HLS Source for $name")
} catch (ioex: IOException) { } catch (ioex: IOException) {
if (targetFile.exists()) if (targetFile.exists())
@@ -843,19 +881,30 @@ class VideoDownload {
targetFile.delete() targetFile.delete()
throw ex throw ex
} }
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength return downloadedTotalLength
} }
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
targetFile.createNewFile(); targetFile.createNewFile();
targetFileAudio?.createNewFile();
val sourceLength: Long?; val sourceLength: Long?;
val sourceLengthAudio: Long?;
val fileStream = FileOutputStream(targetFile); val fileStream = FileOutputStream(targetFile);
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
var executor: JSRequestExecutor? = null;
try{ try{
var manifest = source.manifest; var manifest = source.manifest;
if(source.hasGenerate) if(source.hasGenerate)
@@ -864,15 +913,28 @@ class VideoDownload {
throw IllegalStateException("No manifest after generation"); throw IllegalStateException("No manifest after generation");
//TODO: Temporary naive assume single-sourced dash //TODO: Temporary naive assume single-sourced dash
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest); val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
if(foundTemplate == null || foundTemplate.groupValues.size != 3) val foundTemplate = when(downloadType) {
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
}
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)"); throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
val foundTemplateUrl = foundTemplate.groupValues[1]; val foundTemplateUrl = foundTemplate.groupValues[2];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]); val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
if(foundCues.count() <= 0) if(foundCues.count() <= 0)
throw IllegalStateException("No Cues found in manifest (unsupported dash?)"); throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
val executor = if(source is JSSource && source.hasRequestExecutor) val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
val foundCues2Downloaded = hashSetOf<MatchResult>();
if(foundTemplate2 != null)
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
executor = if(source is JSSource && source.hasRequestExecutor)
source.getRequestExecutor(); source.getRequestExecutor();
else else
null; null;
@@ -886,13 +948,17 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString()); Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0; var written: Long = 0;
var written2: Long = 0;
var indexCounter = 0; var indexCounter = 0;
var indexCounter2 = 0;
onProgress(foundCues.count().toLong(), 0, 0); onProgress(foundCues.count().toLong(), 0, 0);
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
val lastCue = foundCues.lastOrNull();
for(cue in foundCues) { for(cue in foundCues) {
val t = cue.groupValues[1]; val t = cue.groupValues[1];
val d = cue.groupValues[2]; val d = cue.groupValues[2];
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString()); val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf()); val modified = modifier?.modifyRequest(url, mapOf());
@@ -908,17 +974,60 @@ class VideoDownload {
speedTracker.addWork(data.size.toLong()); speedTracker.addWork(data.size.toLong());
written += data.size; written += data.size;
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed); onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
indexCounter++; indexCounter++;
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
val toDownload = if(lastCue != null && cue == lastCue)
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
for(cue2 in toDownload) {
val index2 = foundCues2.indexOf(cue2);
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
indexCounter2++;
foundCues2Downloaded.add(cue2);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
}
}
} }
sourceLength = written; sourceLength = written;
sourceLengthAudio = written2;
Logger.i(TAG, "$name downloadSource Finished"); Logger.i(TAG, "$name downloadSource Finished");
} }
catch(scriptEx: ScriptReloadRequiredException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
createNewPluginClient();
throw scriptEx;
}
catch(ioex: IOException) { catch(ioex: IOException) {
if(targetFile.exists() ?: false) if(targetFile.exists() ?: false)
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
if(ioex.message?.contains("ENOSPC") ?: false) if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex); throw Exception("Not enough space on device", ioex);
else else
@@ -927,13 +1036,37 @@ class VideoDownload {
catch(ex: Throwable) { catch(ex: Throwable) {
if(targetFile.exists() ?: false) if(targetFile.exists() ?: false)
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
throw ex; throw ex;
} }
finally { finally {
fileStream.close(); fileStream.close();
fileStream2?.close();
executor?.closeAsync()
} }
if(sourceLengthAudio != null && sourceLengthAudio > 0)
audioFileSize = sourceLengthAudio
return sourceLength!!; return sourceLength!!;
} }
fun createNewPluginClient() {
UIDialogs.appToast("Download creating new client at request of plugin");
cleanupPluginClient();
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
plugin?.initialize();
}
fun cleanupPluginClient() {
val oldPlugin = plugin;
plugin = null;
try {
oldPlugin?.disable();
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@@ -1293,7 +1426,7 @@ class VideoDownload {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}"); throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
} }
} }
if(audioSourceToUse != null) { if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
if(audioFilePath == null) if(audioFilePath == null)
throw IllegalStateException("Missing audio file name after download"); throw IllegalStateException("Missing audio file name after download");
val expectedFile = File(audioFilePath!!); val expectedFile = File(audioFilePath!!);
@@ -1316,7 +1449,7 @@ class VideoDownload {
Logger.i(TAG, "VideoDownload Complete [${name}]"); Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id); val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) }; val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) }; val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) }; val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource) if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1358,6 +1491,10 @@ class VideoDownload {
} }
} }
fun cleanup(){
cleanupPluginClient()
}
enum class State { enum class State {
QUEUED, QUEUED,
PREPARING, PREPARING,
@@ -1381,6 +1518,8 @@ class VideoDownload {
const val GROUP_WATCHLATER= "WatchLater"; const val GROUP_WATCHLATER= "WatchLater";
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL); val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL); val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
fun videoContainerToExtension(container: String): String? { fun videoContainerToExtension(container: String): String? {
@@ -1400,6 +1539,16 @@ class VideoDownload {
return "video";//throw IllegalStateException("Unknown container: " + container) return "video";//throw IllegalStateException("Unknown container: " + container)
} }
//TODO: Change usages of this to an accurate container instead of infering it.
fun videoAudioContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4a";
else if (container.contains("video/webm"))
return "webm";
else
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
}
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4")) if (container.contains("audio/mp4"))
return "mp4a"; return "mp4a";
@@ -102,6 +102,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
private var currentButtonDefinitions: List<ButtonDefinition>? = null; private var currentButtonDefinitions: List<ButtonDefinition>? = null;
private var moreColumns = 3;
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) { constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment; _fragment = fragment;
_inflater = inflater; _inflater = inflater;
@@ -152,6 +154,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
else { else {
StateApp.instance.setPrivacyMode(true); StateApp.instance.setPrivacyMode(true);
UIDialogs.appToast("Privacy mode enabled"); UIDialogs.appToast("Privacy mode enabled");
if(Settings.instance.other.showPrivacyModeDialog)
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "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,
UIDialogs.Action("Don't show again", {
Settings.instance.other.showPrivacyModeDialog = false;
Settings.instance.save();
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
} }
} }
@@ -170,6 +183,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
setMoreVisible(false); setMoreVisible(false);
} }
}) })
moreColumns = columns;
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true); val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
_layoutMoreButtons.layoutManager = layoutManager; _layoutMoreButtons.layoutManager = layoutManager;
@@ -321,29 +335,37 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutMoreButtons.removeAllViews(); _layoutMoreButtons.removeAllViews();
var insertedButtons = 0; 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 //Force buy to be on top for more buttons
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 }; val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
if (buyIndex != -1) { if (buyIndex != -1) {
val button = buttons[buyIndex] val button = buttons[buyIndex]
buttons.removeAt(buyIndex) buttons.removeAt(buyIndex)
buttons.add(0, button) buttons.add(button)
insertedButtons++; //insertedButtons++;
} }
//Force faq to be second //Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 }; val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) { if (faqIndex != -1) {
val button = buttons[faqIndex] val button = buttons[faqIndex]
buttons.removeAt(faqIndex) buttons.removeAt(faqIndex)
buttons.add(if (insertedButtons == 1) 1 else 0, button) buttons.add(button)
insertedButtons++; //insertedButtons++;
} }
//Force privacy to be third //Force privacy to be third
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 }; val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
if (privacyIndex != -1) { if (privacyIndex != -1) {
val button = buttons[privacyIndex] val button = buttons[privacyIndex]
buttons.removeAt(privacyIndex) buttons.removeAt(privacyIndex)
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button) buttons.add(button)
insertedButtons++; //insertedButtons++;
} }
val newButtons = mutableListOf<MenuButtonItem>(); val newButtons = mutableListOf<MenuButtonItem>();
@@ -591,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { it.currentMain is SettingsFragment }, {
it.navigate<SettingsFragment>(); it.navigate<SettingsFragment>();
/* /*
val c = it.context ?: return@ButtonDefinition; val c = it.context ?: return@ButtonDefinition;
@@ -602,7 +624,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (c is Activity) { if (c is Activity) {
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken); 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 }, { 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", 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, "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 +634,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
UIDialogs.Action("Enable", { UIDialogs.Action("Enable", {
StateApp.instance.setPrivacyMode(true); StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
}), }),*/
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, { 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); it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
}) })
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
@@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.withTimestamp
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
adapter.onContentClicked.subscribe { v, _ -> adapter.onContentClicked.subscribe { v, _ ->
when (v) { when (v) {
is IPlatformVideo -> { is IPlatformVideo -> {
StatePlayer.instance.clearQueue() //StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail() if (StatePlayer.instance.hasQueue) {
StatePlayer.instance.insertToQueue(v, true);
} else {
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
}
} }
is IPlatformPlaylist -> { is IPlatformPlaylist -> {
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
adapter.onContentUrlClicked.subscribe { url, contentType -> adapter.onContentUrlClicked.subscribe { url, contentType ->
when (contentType) { when (contentType) {
ContentType.MEDIA -> { ContentType.MEDIA -> {
StatePlayer.instance.clearQueue() StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail() fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
} }
@@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.onShown(channel) _fragment.topBar?.onShown(channel)
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
UIDialogs.showConfirmationDialog(context, val dialog = UIDialogs.showConfirmationDialog(context,
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist) context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
.replace("{channelName}", channel.name), .replace("{channelName}", channel.name),
{ {
@@ -55,7 +55,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected val _toolbarContentView: LinearLayout; protected val _toolbarContentView: LinearLayout;
protected val _bottomContentView: LinearLayout; protected val _bottomContentView: LinearLayout;
private var _loading: Boolean = true; private var _loading: Boolean = false;
private val _pagerLock = Object(); private val _pagerLock = Object();
private var _cache: ItemCache<TResult>? = null; private var _cache: ItemCache<TResult>? = null;
@@ -180,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val visibleItemCount = _recyclerResults.childCount; val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition() val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount") //Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) { if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
loadNextPage(); loadNextPage();
} }
} }
@@ -197,57 +196,44 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
} }
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) { private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else { _recyclerResults.post {
val height = resources.displayMetrics.heightPixels; val canScroll = _recyclerResults.canScrollVertically(1)
Logger.i(
TAG,
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
)
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if (_automaticNextPageCounter < _automaticBackoff.size) {
if (_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(
_automaticBackoff.size - 1,
_automaticNextPageCounter
)];
val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
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 && rowsHeight < height) {
false;
} else {
true;
}
}
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
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) { withContext(Dispatchers.Main) {
loadNextPage(); setLoading(true);
}
delay(backoff.toLong());
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
}
} else {
withContext(Dispatchers.Main) {
setLoading(false);
}
} }
} }
else { } else
withContext(Dispatchers.Main) { loadNextPage();
setLoading(false);
}
}
}
} }
else } else {
loadNextPage(); Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
} }
} else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
} }
} }
fun resetAutomaticNextPageCounter(){ fun resetAutomaticNextPageCounter(){
@@ -484,6 +470,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.resultsUnfiltered.addAll(toAdd); recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged(); recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle; recyclerData.loadedFeedStyle = feedStyle;
setLoading(false)
if(pager.hasMorePages()) if(pager.hasMorePages())
ensureEnoughContentVisible(filteredResults) ensureEnoughContentVisible(filteredResults)
} }
@@ -124,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
} }
} }
fun leaveDirectory() { fun leaveDirectory() {
if(navStack.size > 1) { if (navStack.size > 1) {
navStack.removeLast(); navStack.removeAt(navStack.size - 1)
openDirectory(navStack.last()); openDirectory(navStack.last())
} }
else {}
} }
fun openDirectory(stack: FileStack, addToStack: Boolean = false) { fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
if(addToStack) if(addToStack)
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
fun onShown() { fun onShown() {
val initialAlbums = StateLibrary.instance.getAlbums(); val initialAlbums = StateLibrary.instance.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size); Logger.i(TAG, "Initial album count: " + initialAlbums.size);
val buckets = StateLibrary.instance.getVideoBucketNames();
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets)); setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
} }
@@ -55,7 +55,7 @@ class LoginFragment : MainFragment() {
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) { fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
if(_callback != null) _callback?.invoke(null); if(_callback != null) _callback?.invoke(null);
_callback = callback; _callback = callback;
StateApp.instance.activity?.navigate<LoginFragment>(config, false); StateApp.instance.activity?.navigate<LoginFragment>(config, true);
} }
} }
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
_imagePlaylistThumbnail.let { _imagePlaylistThumbnail.let {
Glide.with(it) Glide.with(it)
.load(video.thumbnails.getHQThumbnail()) .load(video.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(it); .into(it);
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
@@ -13,10 +15,15 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
@@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
import com.futo.platformplayer.withMaxSizePx
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models import com.futo.polycentric.core.Models
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
} }
val thumbnail = videoDetails.thumbnails.getHQThumbnail() val thumbnail = videoDetails.thumbnails.getHQThumbnail()
/*
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
.load(thumbnail).into(object : CustomTarget<Bitmap>() { .load(thumbnail).withMaxSizePx().into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
player.setArtwork(resource.toDrawable(resources)) player.setArtwork(resource.toDrawable(resources))
} }
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
} }
}) })
else player.setArtwork(null) else player.setArtwork(null)
*/
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
private var _isActive: Boolean = false; private var _isActive: Boolean = false;
private var _viewDetail : VideoDetailView? = null; var _viewDetail : VideoDetailView? = null;
private var _view : SingleViewTouchableMotionLayout? = null; private var _view : SingleViewTouchableMotionLayout? = null;
var isFullscreen : Boolean = false; var isFullscreen : Boolean = false;
@@ -356,38 +356,46 @@ class VideoDetailFragment() : MainFragment() {
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) { override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
_viewDetail?.stopAllGestures() _viewDetail?.stopAllGestures()
if (state != State.MINIMIZED && progress < 0.1) { if (!isTransitioning && (progress < 0.9 && progress > 0.1)) {
state = State.MINIMIZED;
isMinimizingFromFullScreen = false
onMinimize.emit();
}
else if (state != State.MAXIMIZED && progress > 0.9) {
if (_isInitialMaximize) {
state = State.CLOSED;
_isInitialMaximize = false;
}
else {
state = State.MAXIMIZED;
onMaximized.emit();
}
}
if (isTransitioning && (progress > 0.95 || progress < 0.05)) {
isTransitioning = false;
onTransitioning.emit(isTransitioning);
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
}
else if (!isTransitioning && (progress < 0.95 && progress > 0.05)) {
isTransitioning = true; isTransitioning = true;
onTransitioning.emit(isTransitioning); onTransitioning.emit(isTransitioning);
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
} }
} }
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { } override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
val progress = motionLayout?.progress ?: return;
if (state != State.MINIMIZED && progress < 0.1) {
state = State.MINIMIZED;
isMinimizingFromFullScreen = false
onMinimize.emit();
}
else if (state != State.MAXIMIZED && progress > 0.9) {
state = State.MAXIMIZED;
onMaximized.emit();
/*
if (_isInitialMaximize) {
//state = State.CLOSED; Causes issues? might no longer be needed
_isInitialMaximize = false;
}
else {
state = State.MAXIMIZED;
onMaximized.emit();
}
*/
}
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
isTransitioning = false;
onTransitioning.emit(isTransitioning);
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
}
}
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { } override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { } override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
}
}); });
_view?.let { _view?.let {
@@ -446,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
if (viewDetail.shouldEnterPictureInPicture) { if (viewDetail.shouldEnterPictureInPicture) {
_leavingPiP = false _leavingPiP = false
} }
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) { val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture()
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) {
val params = _viewDetail?.getPictureInPictureParams(); val params = _viewDetail?.getPictureInPictureParams();
if(params != null) { if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode") Logger.i(TAG, "enterPictureInPictureMode")
@@ -33,6 +33,7 @@ import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.compose.ui.text.toLowerCase
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C import androidx.media3.common.C
@@ -42,6 +43,7 @@ import androidx.media3.datasource.HttpDataSource
import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.TimeBar import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
@@ -161,6 +163,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.video.FutoVideoPlayer import com.futo.platformplayer.views.video.FutoVideoPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.videometa.UpNextView import com.futo.platformplayer.views.videometa.UpNextView
import com.futo.platformplayer.withMaxSizePx
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models import com.futo.polycentric.core.Models
@@ -552,12 +555,12 @@ class VideoDetailView : ConstraintLayout {
_buttonMore = buttonMore; _buttonMore = buttonMore;
updateMoreButtons(); updateMoreButtons();
val handleLoaderGameVisibilityChanged = { b: Boolean -> val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
_loaderGameVisible = b _loaderGameVisible = b
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
onShouldEnterPictureInPictureChanged.emit() onShouldEnterPictureInPictureChanged.emit()
updateResumeVisibilityFor(lastPositionMilliseconds)
} }
updateResumeVisibilityFor(lastPositionMilliseconds)
} }
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged) _player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged) _cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
@@ -721,15 +724,17 @@ class VideoDetailView : ConstraintLayout {
val activeDevice = StateCasting.instance.activeDevice; val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) { if (activeDevice != null) {
handlePlayChanged(it); handlePlayChanged(it);
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
} }
}; };
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
if (_isCasting) { if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true); setLastPositionMilliseconds((it * 1000.0).toLong(), true);
@@ -1271,6 +1276,7 @@ class VideoDetailView : ConstraintLayout {
StateCasting.instance.onActiveDevicePlayChanged.remove(this); StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
StateApp.instance.preventPictureInPicture.remove(this); StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this); StatePlayer.instance.onVideoChanging.remove(this);
@@ -2049,7 +2055,7 @@ class VideoDetailView : ConstraintLayout {
} else { } else {
val thumbnail = video.thumbnails.getHQThumbnail(); val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail) Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object: CustomTarget<Bitmap>() { .into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource)); _player.setArtwork(BitmapDrawable(resources, resource));
@@ -2418,9 +2424,54 @@ class VideoDetailView : ConstraintLayout {
val doDedup = Settings.instance.playback.simplifySources; val doDedup = Settings.instance.playback.simplifySources;
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width } val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
?.distinct() val langResCombinations = if(videoSources != null) allLanguages.flatMap {
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
Log.i(TAG, "Language count: ${allLanguages}");
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource })) ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
?.distinct() ?.distinct()
?.filterNotNull() ?.filterNotNull()
@@ -2526,11 +2577,10 @@ class VideoDetailView : ConstraintLayout {
call = { _player.selectAudioTrack(it.bitrate) }); call = { _player.selectAudioTrack(it.bitrate) });
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(languageFilters != null) languageFilters else null,
if(bestVideoSources.isNotEmpty()) if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video", SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources (bestVideoSources.map {
.map {
val estSize = VideoHelper.estimateSourceSize(it); val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(this.context, SlideUpMenuItem(this.context,
@@ -2539,8 +2589,14 @@ class VideoDetailView : ConstraintLayout {
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
(prefix + it.codec.trim()).trim(), (prefix + it.codec.trim()).trim(),
tag = it, tag = it,
call = { handleSelectVideoTrack(it) }); call = { handleSelectVideoTrack(it) }).apply {
}.toList().toTypedArray()) videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
};
}).toList())
else null, else null,
if(bestAudioSources.isNotEmpty()) if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio", SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
@@ -3357,9 +3413,11 @@ class VideoDetailView : ConstraintLayout {
false false
else { else {
isLoginStop = true; isLoginStop = true;
onMinimize.emit();
StatePlugins.instance.loginPlugin(context, id) { StatePlugins.instance.loginPlugin(context, id) {
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
fetchVideo(); fetchVideo();
onMaximize.emit(false);
} }
} }
} }
@@ -14,6 +14,7 @@ import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.setPadding import androidx.core.view.setPadding
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.withMaxSizePx
abstract class VideoListEditorView : LinearLayout { abstract class VideoListEditorView : LinearLayout {
private var _videoListEditorView: VideoListEditorView; private var _videoListEditorView: VideoListEditorView;
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
_imagePlaylistThumbnail.let { _imagePlaylistThumbnail.let {
Glide.with(it) Glide.with(it)
.load(video.thumbnails.getHQThumbnail()) .load(video.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(it); .into(it);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.topbar package com.futo.platformplayer.fragment.mainactivity.topbar
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) { } else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST)); navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
} else if (currentMain is LibraryFragment) { } else if (currentMain is LibraryFragment) {
navigate<LibrarySearchFragment>(); if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
UIDialogs.toast("Your Android version is too old for Mediastore search", true);
}
else
navigate<LibrarySearchFragment>();
} else { } else {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO)); navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
} }
@@ -52,8 +52,8 @@ class VideoHelper {
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers, preferredLanguage);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? { fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
val targetVideo = if(desiredPixelCount > 0) { val targetVideo = if(desiredPixelCount > 0) {
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) }; sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
} else { } else {
@@ -63,12 +63,34 @@ class VideoHelper {
val hasPriority = sources.any { it.priority }; val hasPriority = sources.any { it.priority };
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount; val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
val altSources = if(hasPriority) {
//Filter priority
var altSources = if(hasPriority) {
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) }; sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
} else { } else {
sources.filter { it.height == (targetVideo?.height ?: 0) }; sources.filter { it.height == (targetVideo?.height ?: 0) };
} }
//Filter Original
val hasOriginal = altSources.any { it.original == true };
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
altSources = altSources.filter { it.original == true };
//Filter Language
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
preferredLanguage
} else {
if(altSources.any { it.language == Language.ENGLISH })
Language.ENGLISH;
else
Language.UNKNOWN;
}
if(altSources.any { it.language == languageToFilter }) {
altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList();
} else {
altSources.sortedBy { it.bitrate }
}
var bestSource = altSources.firstOrNull(); var bestSource = altSources.firstOrNull();
for (prefContainer in prefContainers) { for (prefContainer in prefContainers) {
val betterSource = altSources.firstOrNull { it.container == prefContainer }; val betterSource = altSources.firstOrNull { it.container == prefContainer };
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
import android.widget.ImageView import android.widget.ImageView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.withMaxSizePx
class GlideHelper { class GlideHelper {
@@ -14,7 +16,7 @@ class GlideHelper {
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) { fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail(); val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
val req = Glide.with(this).load(url); val req = Glide.with(this).load(url).withMaxSizePx()
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads? if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail(); val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
@@ -1,5 +1,6 @@
package com.futo.platformplayer.receivers package com.futo.platformplayer.receivers
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else { } else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT) intent.getParcelableExtra(Intent.EXTRA_INTENT)
} }
if (activityIntent == null) { if (activityIntent == null) {
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.") Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
return; return;
} }
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(activityIntent)
} catch (e: ActivityNotFoundException) {
Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e)
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
}
} }
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null); PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure)); PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
@@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() {
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage)); PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
else -> { else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
Logger.w(TAG, "Received unknown install status $status, message=$msg")
onReceiveResult.emit(msg) onReceiveResult.emit(msg)
} }
} }
@@ -15,6 +15,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.getNowDiffMinutes import com.futo.platformplayer.getNowDiffMinutes
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -169,6 +170,7 @@ class DownloadService : Service() {
Thread.sleep(500); Thread.sleep(500);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
//if(ex is ScriptReloadRequiredException)
Logger.e(TAG, "Download failed", ex); Logger.e(TAG, "Download failed", ex);
if(currentVideo.video == null && currentVideo.videoDetails == null) { if(currentVideo.video == null && currentVideo.videoDetails == null) {
//Corrupt? //Corrupt?
@@ -26,6 +26,7 @@ import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.withMaxSizePx
class MediaPlaybackService : Service() { class MediaPlaybackService : Service() {
private val TAG = "MediaPlaybackService"; private val TAG = "MediaPlaybackService";
@@ -172,21 +174,26 @@ class MediaPlaybackService : Service() {
} }
fun closeMediaSession() { fun closeMediaSession() {
Logger.v(TAG, "closeMediaSession"); Logger.v(TAG, "closeMediaSession")
stopForeground(STOP_FOREGROUND_REMOVE); stopForeground(STOP_FOREGROUND_REMOVE)
abandonAudioFocus() abandonAudioFocus()
val notifManager = _notificationManager; val notifManager = _notificationManager
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})"); Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})")
notifManager?.cancel(MEDIA_NOTIF_ID); notifManager?.cancel(MEDIA_NOTIF_ID)
_notif_last_video = null;
_notif_last_bitmap = null;
_mediaSession = null;
if(_instance == this) _notif_last_video = null
_instance = null; _notif_last_bitmap = null
this.stopSelf();
_mediaSession?.isActive = false
_mediaSession?.release()
_mediaSession = null
if (_instance == this)
_instance = null
stopSelf()
} }
fun updateMediaSession(videoUpdated: IPlatformVideo?) { fun updateMediaSession(videoUpdated: IPlatformVideo?) {
@@ -206,37 +213,37 @@ class MediaPlaybackService : Service() {
if(_notificationChannel == null || _mediaSession == null) if(_notificationChannel == null || _mediaSession == null)
setupNotificationRequirements(); setupNotificationRequirements();
_mediaSession?.setMetadata( updateMediaMetadata(video, lastBitmap)
MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap)
.build());
val thumbnail = video.thumbnails.getHQThumbnail(); val thumbnail = video.thumbnails.getHQThumbnail();
_notif_last_video = video; _notif_last_video = video;
if(isUpdating) if(isUpdating)
notifyMediaSession(video, _notif_last_bitmap); notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled });
else if(thumbnail != null) { else if(thumbnail != null) {
notifyMediaSession(video, null); notifyMediaSession(video, null);
val tag = video; val tag = video;
Glide.with(this).asBitmap() Glide.with(this).asBitmap()
.load(thumbnail) .load(thumbnail)
.withMaxSizePx()
.into(object: CustomTarget<Bitmap>() { .into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
if(tag == _notif_last_video) { if (tag != _notif_last_video) return
notifyMediaSession(video, resource) if (resource.isRecycled) {
_mediaSession?.setMetadata( notifyMediaSession(video, null)
MediaMetadataCompat.Builder() return
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource)
.build());
} }
val albumArt = resource.copy(
resource.config ?: Bitmap.Config.ARGB_8888,
false
)
_notif_last_bitmap = albumArt
notifyMediaSession(video, albumArt)
updateMediaMetadata(video, albumArt)
} }
override fun onLoadCleared(placeholder: Drawable?) { override fun onLoadCleared(placeholder: Drawable?) {
if(tag == _notif_last_video) if(tag == _notif_last_video)
@@ -247,6 +254,19 @@ class MediaPlaybackService : Service() {
else else
notifyMediaSession(video, null); notifyMediaSession(video, null);
} }
private fun updateMediaMetadata(video: IPlatformVideo, bitmap: Bitmap?) {
val builder = MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
val safeBitmap = bitmap?.takeIf { !it.isRecycled }
if (safeBitmap != null) {
builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, safeBitmap)
}
_mediaSession?.setMetadata(builder.build())
}
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action { private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
return NotificationCompat.Action.Builder(icon, title, intent).build(); return NotificationCompat.Action.Builder(icon, title, intent).build();
} }
@@ -436,9 +436,9 @@ class StateApp {
try { try {
val caFile = AppCaUpdater.ensureCaBundle(context) val caFile = AppCaUpdater.ensureCaBundle(context)
Libcurl.setDefaultCAPath(caFile.absolutePath) Libcurl.setDefaultCAPath(caFile.absolutePath)
Logger.i(TAG, "Libcurl initialized")
} catch (t: Throwable) { } catch (t: Throwable) {
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem") Logger.e(TAG, "Failed to initialize Libcurl", t);
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
} }
} }
@@ -572,30 +572,39 @@ class StateApp {
DownloadService.getOrCreateService(context); DownloadService.getOrCreateService(context);
} }
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]"); if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1; val constraints = Constraints.Builder()
when { .setRequiredNetworkType(NetworkType.CONNECTED)
//Background download .build();
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
StateUpdate.instance.setShouldBackgroundUpdate(true);
}
autoUpdateEnabled && !shouldDownload && backgroundDownload -> { val periodicRequest = PeriodicWorkRequest.Builder(
Logger.i(TAG, "Auto update skipped due to wrong network state"); UpdateCheckWorker::class.java,
} 12, TimeUnit.HOURS
)
.setConstraints(constraints)
.build();
//Foreground download val wm = WorkManager.getInstance(context);
autoUpdateEnabled -> { wm.enqueueUniquePeriodicWork(
UpdateCheckWorker.UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
periodicRequest
);
val oneTimeRequest = OneTimeWorkRequest.Builder(UpdateCheckWorker::class.java)
.setConstraints(constraints)
.build();
wm.enqueue(oneTimeRequest);
} else {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
scopeOrNull?.launch(Dispatchers.IO) { scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false) StateUpdate.instance.checkForUpdates(context, false)
} }
} }
} else {
else -> { Logger.i(TAG, "AutoUpdate disabled");
Logger.i(TAG, "Auto update disabled");
}
} }
Logger.i(TAG, "MainApp Started: Initialize [Noisy]"); Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
@@ -781,24 +790,20 @@ class StateApp {
Logger.i("StateApp", "No AutoBackup configured"); Logger.i("StateApp", "No AutoBackup configured");
} }
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) { fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
try { try {
val wm = WorkManager.getInstance(context); val wm = WorkManager.getInstance(context);
if(active) { if (active) {
if(BuildConfig.DEBUG) if (BuildConfig.DEBUG)
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes"); UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES) val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder() .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
.setRequiredNetworkType(NetworkType.UNMETERED)
.build())
.build();
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req); wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
} else {
wm.cancelUniqueWork("backgroundSubscriptions");
} }
else
wm.cancelAllWork();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to schedule background subscription updates.", e) Logger.e(TAG, "Failed to schedule background subscription updates.", e)
UIDialogs.toast(context, "Background subscription update failed: " + e.message) UIDialogs.toast(context, "Background subscription update failed: " + e.message)
@@ -806,6 +811,7 @@ class StateApp {
} }
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) { private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
if(managedStores.size <= index) if(managedStores.size <= index)
return; return;
@@ -903,15 +909,6 @@ class StateApp {
try { try {
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload()) if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
StateDownloads.instance.checkForDownloadsTodos(); StateDownloads.instance.checkForDownloadsTodos();
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
if (autoUpdateEnabled && shouldDownload && backgroundDownload) {
StateUpdate.instance.setShouldBackgroundUpdate(true);
} else {
StateUpdate.instance.setShouldBackgroundUpdate(false);
}
} catch(ex: Throwable) { } catch(ex: Throwable) {
Logger.w(TAG, "Failed to handle capabilities changed event", ex); Logger.w(TAG, "Failed to handle capabilities changed event", ex);
} }
@@ -21,7 +21,7 @@ class StateAssets {
if(part == "." || part == "..") { if(part == "." || part == "..") {
if(parentAllowance <= 0) if(parentAllowance <= 0)
throw IllegalStateException("Path [${path}] attempted to escape path.."); throw IllegalStateException("Path [${path}] attempted to escape path..");
parts1.removeLast(); parts1.removeAt(parts1.size - 1);
toSkip++; toSkip++;
} }
else else
@@ -179,6 +179,7 @@ class StateDownloads {
fun removeDownload(download: VideoDownload) { fun removeDownload(download: VideoDownload) {
download.isCancelled = true; download.isCancelled = true;
download.cleanup();
_downloading.delete(download); _downloading.delete(download);
onDownloadsChanged.emit(); onDownloadsChanged.emit();
} }
@@ -1,10 +1,12 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.Audio.Artists import android.provider.MediaStore.Audio.Artists
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
@@ -154,34 +156,101 @@ class StateLibrary {
fun getArtist(id: Long): Artist? { fun getArtist(id: Long): Artist? {
return Artist.getArtist(id); return Artist.getArtist(id);
} }
fun getVideos(
buckets: List<String>? = null,
pageSize: Int = 20
): IPager<IPlatformContent> {
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return EmptyPager()
val selection: String?
val selectionArgs: Array<String>?
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> { if (!buckets.isNullOrEmpty()) {
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null; val placeholders = buckets.joinToString(",") { "?" }
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO, selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)"
query, selectionArgs = buckets.toTypedArray()
null, } else {
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager(); selection = null
selectionArgs = null
}
//Ongoing usage of cursor..todo disposal val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//return cursor.use { MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
cursor.moveToFirst(); } else {
val list = mutableListOf<IPlatformVideo>() MediaStore.Video.Media.EXTERNAL_CONTENT_URI
while(!cursor.isAfterLast && list.size < 10) { }
list.add(videoFromCursor(cursor));
cursor.moveToNext(); var nextPageIndex = 0
fun loadPage(pageIndex: Int): List<IPlatformContent> {
Logger.i(TAG, "loadPage $pageIndex")
val offset = pageIndex * pageSize
val queryArgs = Bundle().apply {
selection?.let {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, it)
}
selectionArgs?.let {
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it)
}
putStringArray(
ContentResolver.QUERY_ARG_SORT_COLUMNS,
arrayOf(
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media._ID
)
)
putInt(
ContentResolver.QUERY_ARG_SORT_DIRECTION,
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
)
putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize)
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
} }
return AdhocPager<IPlatformContent>({ val cursor = resolver.query(
val list = mutableListOf<IPlatformContent>() collectionUri,
while(!cursor.isAfterLast && list.size < 10) { PROJECTION_VIDEO,
list.add(videoFromCursor(cursor)); queryArgs,
cursor.moveToNext(); null
)
if (cursor == null) {
Logger.i(TAG, "loadPage $pageIndex null, returning empty list")
return emptyList()
}
cursor.use { c ->
if (!c.moveToFirst()) {
Logger.i(TAG, "loadPage $pageIndex moveToFirst failed, returning empty list")
return emptyList()
} }
Logger.i(TAG, "Videos nextPage: ${list.size}")
return@AdhocPager list; val list = ArrayList<IPlatformContent>(pageSize)
}, list); do {
//} list.add(videoFromCursor(c))
} while (c.moveToNext() && list.size < pageSize)
Logger.i(TAG, "loadPage $pageIndex found ${list.size} items")
return list
}
}
val firstPage = loadPage(0)
if (firstPage.isEmpty()) {
return EmptyPager()
}
nextPageIndex = 1
return AdhocPager<IPlatformContent>({
val page = loadPage(nextPageIndex)
nextPageIndex++
Logger.i(TAG, "loadPage nextPage: ${page.size}")
page
}, firstPage)
} }
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> { fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
val videoPager = getVideos(buckets); val videoPager = getVideos(buckets);
val items = mutableListOf<IPlatformVideo>(); val items = mutableListOf<IPlatformVideo>();
@@ -193,48 +262,80 @@ class StateLibrary {
return items; return items;
} }
private var _cacheBucketNames: List<Bucket>? = null; @Volatile
fun getVideoBucketNames(): List<Bucket> { private var _cachedVideoBuckets: List<Bucket>? = null
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) private val _bucketCacheLock = Any()
return listOf();
if(_cacheBucketNames != null)
return _cacheBucketNames ?: listOf();
try {
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
MediaStore.Video.Media.BUCKET_ID,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
), null, null, null
) ?: return listOf();
return cur.use { fun getVideoBucketNames(forceRefresh: Boolean = false): List<Bucket> {
val buckets = mutableListOf<Bucket>(); if (!forceRefresh) {
val list = HashSet<Long>(); _cachedVideoBuckets?.let { return it }
if (cur.moveToFirst()) { }
var id: Long;
var bucket: String val resolver = StateApp.instance.contextOrNull?.contentResolver
do { ?: return emptyList()
try {
id = cur.getLong(0); val projection = arrayOf(
bucket = cur.getStringOrNull(1) ?: continue; MediaStore.Video.VideoColumns.BUCKET_ID,
if (!list.contains(id)) { MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME
list.add(id); )
buckets.add(Bucket(id, bucket));
} val sortOrder = "${MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC"
} catch (ex: Throwable) { val loadedBuckets: List<Bucket> = try {
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex); resolver.query(
} MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
} while (cur.moveToNext()) projection,
null,
null,
sortOrder
)?.use { cursor ->
if (!cursor.moveToFirst()) {
return@use emptyList<Bucket>()
} }
_cacheBucketNames = buckets.toList()
return@use _cacheBucketNames ?: listOf(); val idxId = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_ID)
val idxName = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME)
val seenIds = HashSet<Long>()
val buckets = ArrayList<Bucket>()
do {
try {
val id = cursor.getLong(idxId)
if (!seenIds.add(id)) {
continue
}
val name = cursor.getStringOrNull(idxName) ?: continue
buckets.add(Bucket(id, name))
} catch (e: Exception) {
Logger.e(TAG, "Failed to parse video bucket row: ${e.message}", e)
}
} while (cursor.moveToNext())
buckets
} ?: emptyList()
} catch (e: Exception) {
Logger.e(TAG, "Buckets loading failed, returning empty: ${e.message}", e)
emptyList()
}
if (loadedBuckets.isEmpty()) {
if (!forceRefresh) {
_cachedVideoBuckets?.let { return it }
} }
return emptyList()
} }
catch(ex: Throwable) {
Logger.e(TAG, "Buckets loading failed, returning empty"); synchronized(_bucketCacheLock) {
return listOf(); if (!forceRefresh) {
_cachedVideoBuckets?.let { return it }
}
_cachedVideoBuckets = loadedBuckets
return loadedBuckets
} }
} }
fun invalidateVideoBucketNamesCache() {
_cachedVideoBuckets = null
}
companion object { companion object {
@@ -243,7 +344,8 @@ class StateLibrary {
MediaStore.Video.Media.DISPLAY_NAME, MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.MIME_TYPE, MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
MediaStore.Video.Media.DURATION
); );
val PROJECTION_MEDIA = arrayOf( val PROJECTION_MEDIA = arrayOf(
MediaStore.Audio.Media._ID, //0 MediaStore.Audio.Media._ID, //0
@@ -386,9 +488,10 @@ class StateLibrary {
""; "";
val albumContentUrl = if(albumId > 0) val albumArtBase = Uri.parse("content://media/external/audio/albumart")
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() val albumContentUrl = if (albumId > 0)
else null; ContentUris.withAppendedId(albumArtBase, albumId).toString()
else null
val dateObj = if(date > 0) val dateObj = if(date > 0)
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC) OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
@@ -414,6 +517,8 @@ class StateLibrary {
val date = cursor.getLong(2); val date = cursor.getLong(2);
val contentType = cursor.getString(3); val contentType = cursor.getString(3);
val category = cursor.getString(4); val category = cursor.getString(4);
val durationMs = cursor.getLong(5)
val duration = if (durationMs > 0) durationMs / 1000 else -1
val idLong = id.toLongOrNull(); val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null ) val contentUrl = if(idLong != null )
@@ -433,7 +538,7 @@ class StateLibrary {
PlatformID("FILE", contentUrl, null, 0, -1), PlatformID("FILE", contentUrl, null, 0, -1),
displayName, Thumbnails(arrayOf( displayName, Thumbnails(arrayOf(
Thumbnail(contentUrl, 0) Thumbnail(contentUrl, 0)
)), authorObj, contentUrl, -1, contentType, dateObj); )), authorObj, contentUrl, duration, contentType, dateObj);
} }
private var _instance : StateLibrary? = null; private var _instance : StateLibrary? = null;
@@ -521,11 +626,12 @@ class Artist {
val numTracks = cursor.getInt(2); val numTracks = cursor.getInt(2);
val numAlbums = cursor.getInt(3); val numAlbums = cursor.getInt(3);
val idLong = id.toLongOrNull(); val idLong = id.toLongOrNull()
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; val uri = if (idLong != null)
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong)
else null
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()); return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) }
}
fun getArtist(id: Long): Artist? { fun getArtist(id: Long): Artist? {
val resolver = StateApp.instance.contextOrNull?.contentResolver; val resolver = StateApp.instance.contextOrNull?.contentResolver;
@@ -629,9 +735,10 @@ class Album {
val numTracks = cursor.getInt(2); val numTracks = cursor.getInt(2);
val artist = cursor.getString(3); val artist = cursor.getString(3);
val idLong = id.toLongOrNull(); val idLong = id.toLongOrNull()
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; val albumArtBase = Uri.parse("content://media/external/audio/albumart")
return Album(album, numTracks, artist, id, uri?.toString()); val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null
return Album(album, numTracks, artist, id, uri?.toString())
} }
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> { fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
@@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNowDiffStringMinDay import com.futo.platformplayer.toHumanNowDiffStringMinDay
import com.futo.platformplayer.withMaxSizePx
import java.time.OffsetDateTime import java.time.OffsetDateTime
class StateNotifications { class StateNotifications {
@@ -96,6 +98,7 @@ class StateNotifications {
if(thumbnail != null) if(thumbnail != null)
Glide.with(context).asBitmap() Glide.with(context).asBitmap()
.load(thumbnail) .load(thumbnail)
.withMaxSizePx()
.into(object: CustomTarget<Bitmap>() { .into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
notifyNewContent(context, manager, notificationChannel, id, content, resource); notifyNewContent(context, manager, notificationChannel, id, content, resource);
@@ -1,17 +1,22 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer
import androidx.media3.exoplayer.upstream.DefaultAllocator import androidx.media3.exoplayer.upstream.DefaultAllocator
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
@@ -21,8 +26,10 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.services.MediaPlaybackService import com.futo.platformplayer.services.MediaPlaybackService
import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.video.PlayerManager
import com.google.common.collect.Iterables
import kotlin.random.Random import kotlin.random.Random
/*** /***
* Used to keep track of queue and other player related stuff * Used to keep track of queue and other player related stuff
*/ */
@@ -240,17 +247,29 @@ class StatePlayer {
} }
private fun createShuffledQueue() { private fun createShuffledQueue() {
val currentItem = getCurrentQueueItem(); if (_queue.isEmpty()) {
if (_queuePosition == -1 || currentItem == null) { _queueShuffled = mutableListOf()
_queueShuffled = _queue.shuffled().toMutableList() return
return;
} }
val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled(); val currentItem = getCurrentQueueItem()
val previousItems = _queue.subList(0, _queuePosition).shuffled(); if (currentItem == null || _queuePosition !in _queue.indices) {
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList(); _queueShuffled = _queue.shuffled().toMutableList()
return
}
val previousItems = _queue
.take(_queuePosition)
.shuffled()
val nextItems = _queue
.drop(_queuePosition + 1)
.shuffled()
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList()
} }
private fun addToShuffledQueue(video: IPlatformVideo) { private fun addToShuffledQueue(video: IPlatformVideo) {
val isLastVideo = _queuePosition + 1 >= _queue.size; val isLastVideo = _queuePosition + 1 >= _queue.size;
if (isLastVideo) { if (isLastVideo) {
@@ -662,6 +681,30 @@ class StatePlayer {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun createExoPlayer(context : Context): ExoPlayer { private fun createExoPlayer(context : Context): ExoPlayer {
return ExoPlayer.Builder(context) return ExoPlayer.Builder(context)
.setRenderersFactory(
object : DefaultRenderersFactory(context) {
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: Looper,
extensionRendererMode: Int,
out: java.util.ArrayList<Renderer>
) {
super.buildTextRenderers(
context,
output,
outputLooper,
extensionRendererMode,
out
)
(Iterables.getLast<Renderer?>(out) as TextRenderer)
.experimentalSetLegacyDecodingEnabled(true)
}
})
.setMediaSourceFactory(
DefaultMediaSourceFactory(context)
.experimentalParseSubtitlesDuringExtraction(false)
)
.setLoadControl( .setLoadControl(
DefaultLoadControl.Builder() DefaultLoadControl.Builder()
.setAllocator(DefaultAllocator(true, BUFFER_SIZE)) .setAllocator(DefaultAllocator(true, BUFFER_SIZE))
@@ -169,6 +169,9 @@ class StatePlugins {
return false; return false;
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) { LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
if(it == null)
return@showLogin;
try { try {
StatePlugins.instance.setPluginAuth(config.id, it); StatePlugins.instance.setPluginAuth(config.id, it);
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -15,146 +15,6 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
class StateUpdate { class StateUpdate {
private var _backgroundUpdateFinished = false;
private var _gettingOrDownloadingLastApk = false;
private var _shouldBackgroundUpdate = false;
private val _lockObject = Object();
private fun getOrDownloadLastApkFile(filesDir: File): File? {
try {
Logger.i(TAG, "Started getting or downloading latest APK file.");
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 1.");
return null;
}
Logger.i(TAG, "Started background update download.");
val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client);
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 2.");
return null;
}
if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion <= currentVersion) {
Logger.i(TAG, "Already up to date.");
_backgroundUpdateFinished = true;
return null;
}
val outputDirectory = File(filesDir, "autoupdate");
if (!outputDirectory.exists()) {
outputDirectory.mkdirs();
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 3.");
return null;
}
val apkOutputFile = File(outputDirectory, "last_version.apk");
val versionOutputFile = File(outputDirectory, "last_version.txt");
var cachedVersionInvalid = false;
if (!versionOutputFile.exists() || !apkOutputFile.exists()) {
Logger.i(TAG, "No downloaded version exists.");
cachedVersionInvalid = true;
} else {
try {
val downloadedVersion = versionOutputFile.readText().toInt();
Logger.i(TAG, "Downloaded version is $downloadedVersion.");
if (downloadedVersion != latestVersion) {
Logger.i(TAG, "Downloaded version is not newest version.");
cachedVersionInvalid = true;
}
}
catch(ex: Throwable) {
Logger.w(TAG, "Deleted version file as it was inaccessible");
versionOutputFile.delete();
cachedVersionInvalid = true;
}
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 4.");
return null;
}
if (cachedVersionInvalid) {
Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'...");
downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate };
versionOutputFile.writeText(latestVersion.toString());
Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'.");
} else {
Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'...");
}
if (!_shouldBackgroundUpdate) {
Logger.i(TAG, "Update download cancelled 5.");
return null;
}
return apkOutputFile;
} else {
Logger.w(TAG, "Failed to retrieve version from version URL.");
return null;
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to download APK.", e);
return null;
} finally {
_gettingOrDownloadingLastApk = false;
}
}
fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) {
synchronized (_lockObject) {
if (_backgroundUpdateFinished) {
_shouldBackgroundUpdate = false;
return;
}
_shouldBackgroundUpdate = shouldBackgroundUpdate;
if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) {
Logger.i(TAG, "Auto Updating in Background");
_gettingOrDownloadingLastApk = true;
StateApp.withContext { context ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val file = getOrDownloadLastApkFile(context.filesDir);
if (file == null) {
Logger.i(TAG, "Failed to get or download update.");
return@launch;
}
withContext(Dispatchers.Main) {
try {
context.let { c ->
_backgroundUpdateFinished = true;
UIDialogs.showInstallDownloadedUpdateDialog(c, file);
};
Logger.i(TAG, "Showing install dialog for '${file.path}'.");
} catch (e: Throwable) {
context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); };
Logger.w(TAG, "Error occurred in update dialog.", e);
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get last downloaded APK file.", e)
}
}
}
}
}
}
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) { suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try { try {
val client = ManagedHttpClient(); val client = ManagedHttpClient();
@@ -196,25 +56,6 @@ class StateUpdate {
} }
} }
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
var apkStream: InputStream? = null;
var outputStream: OutputStream? = null;
try {
val response = client.get(APK_URL);
if (response.isOk && response.body != null) {
apkStream = response.body.byteStream();
outputStream = destinationFile.outputStream();
apkStream.copyToOutputStream(outputStream, isCancelled);
apkStream.close();
outputStream.close();
}
} finally {
apkStream?.close();
outputStream?.close();
}
}
fun downloadVersionCode(client: ManagedHttpClient): Int? { fun downloadVersionCode(client: ManagedHttpClient): Int? {
val response = client.get(VERSION_URL); val response = client.get(VERSION_URL);
if (!response.isOk || response.body == null) { if (!response.isOk || response.body == null) {
@@ -267,6 +108,22 @@ class StateUpdate {
} }
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs"; val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
fun getApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
if (!dir.exists()) {
dir.mkdirs();
}
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
}
fun getPartialApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
if (!dir.exists()) {
dir.mkdirs();
}
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
}
fun finish() { fun finish() {
_instance?.let { _instance?.let {
_instance = null; _instance = null;
@@ -11,7 +11,7 @@ class SearchHistoryStorage : FragmentedStorageFileJson() {
if (!lastQueries.contains(text)) { if (!lastQueries.contains(text)) {
lastQueries.add(0, text); lastQueries.add(0, text);
if (lastQueries.size > 10) if (lastQueries.size > 10)
lastQueries.removeLast(); lastQueries.removeAt(lastQueries.size - 1);
} }
else { else {
lastQueries.remove(text); lastQueries.remove(text);
@@ -7,10 +7,12 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.withMaxSizePx
class PlaylistsViewHolder : ViewHolder { class PlaylistsViewHolder : ViewHolder {
private val _root: ConstraintLayout; private val _root: ConstraintLayout;
@@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder {
if (p.videos.isNotEmpty()) { if (p.videos.isNotEmpty()) {
Glide.with(_imageThumbnail) Glide.with(_imageThumbnail)
.load(p.videos[0].thumbnails.getMinimumThumbnail(380)) .load(p.videos[0].thumbnails.getMinimumThumbnail(380))
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(_imageThumbnail); .into(_imageThumbnail);
@@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.withMaxSizePx
class VideoListEditorViewHolder : ViewHolder { class VideoListEditorViewHolder : ViewHolder {
private val _root: ConstraintLayout; private val _root: ConstraintLayout;
@@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder {
fun bind(v: IPlatformVideo, canEdit: Boolean) { fun bind(v: IPlatformVideo, canEdit: Boolean) {
Glide.with(_imageThumbnail) Glide.with(_imageThumbnail)
.load(v.thumbnails.getHQThumbnail()) .load(v.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade() .crossfade()
.into(_imageThumbnail); .into(_imageThumbnail);
@@ -7,6 +7,7 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -16,6 +17,7 @@ import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.withMaxSizePx
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
@@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo
Glide.with(it) Glide.with(it)
.load(content.thumbnails.getHQThumbnail()) .load(content.thumbnails.getHQThumbnail())
.placeholder(R.drawable.unknown_music) .placeholder(R.drawable.unknown_music)
.withMaxSizePx()
.into(it) .into(it)
else else
Glide.with(it).load(R.drawable.unknown_music).into(it); Glide.with(it).load(R.drawable.unknown_music).into(it);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.views.buttons
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
@@ -98,46 +99,58 @@ open class BigButton : LinearLayout {
return this; return this;
} }
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton { private fun applyIcon(resourceId: Int, rounded: Boolean) {
if (resourceId != -1) { if (resourceId != -1) {
_icon.visibility = View.VISIBLE; _icon.visibility = View.VISIBLE
_icon.setImageResource(resourceId); _icon.setImageResource(resourceId)
} else
_icon.visibility = View.GONE;
if (rounded) {
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
.build();
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
_icon.shapeAppearanceModel = shapeAppearanceModel;
} else { } else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP; _icon.visibility = View.GONE
_icon.shapeAppearanceModel = ShapeAppearanceModel();
} }
applyRounded(rounded)
return this;
} }
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
if (Looper.myLooper() == Looper.getMainLooper()) {
applyIcon(resourceId, rounded)
} else {
post { applyIcon(resourceId, rounded) }
}
return this
}
fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton { fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton {
_icon.visibility = View.VISIBLE; if (Looper.myLooper() == Looper.getMainLooper()) {
_icon.setImageBitmap(bitmap); applyIcon(bitmap, rounded)
if (rounded) {
val shapeAppearanceModel = ShapeAppearanceModel().toBuilder()
.setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics))
.build();
_icon.scaleType = ImageView.ScaleType.FIT_CENTER;
_icon.shapeAppearanceModel = shapeAppearanceModel;
} else { } else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP; post { applyIcon(bitmap, rounded) }
_icon.shapeAppearanceModel = ShapeAppearanceModel();
} }
return this
}
return this; private fun applyRounded(rounded: Boolean) {
if (rounded) {
val radiusPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
16.0f,
context.resources.displayMetrics
)
val shapeAppearanceModel = ShapeAppearanceModel()
.toBuilder()
.setAllCornerSizes(radiusPx)
.build()
_icon.scaleType = ImageView.ScaleType.FIT_CENTER
_icon.shapeAppearanceModel = shapeAppearanceModel
} else {
_icon.scaleType = ImageView.ScaleType.CENTER_CROP
_icon.shapeAppearanceModel = ShapeAppearanceModel()
}
}
private fun applyIcon(bitmap: Bitmap, rounded: Boolean) {
_icon.visibility = View.VISIBLE
_icon.setImageBitmap(bitmap)
applyRounded(rounded)
} }
fun withBackground(resourceId: Int): BigButton { fun withBackground(resourceId: Int): BigButton {
@@ -8,6 +8,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
visibility = View.GONE; visibility = View.GONE;
} }
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ ->
updateCastState(); updateCastState(d);
}; };
updateCastState(); updateCastState(StateCasting.instance.activeDevice);
} }
} }
private fun updateCastState() { private fun updateCastState(d: CastingDevice?) {
val c = context ?: return; val c = context ?: return;
val d = StateCasting.instance.activeDevice;
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
val inactiveColor = ContextCompat.getColor(c, R.color.white); val inactiveColor = ContextCompat.getColor(c, R.color.white);
@@ -18,6 +18,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.chapters.IChapter
@@ -32,6 +33,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView import com.futo.platformplayer.views.behavior.GestureControlView
import com.futo.platformplayer.withMaxSizePx
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -306,6 +308,7 @@ class CastView : ConstraintLayout {
Glide.with(_thumbnail) Glide.with(_thumbnail)
.load(video.thumbnails.getHQThumbnail()) .load(video.thumbnails.getHQThumbnail())
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.withMaxSizePx()
.into(_thumbnail); .into(_thumbnail);
_textPosition.text = (position * 1000).formatDuration(); _textPosition.text = (position * 1000).formatDuration();
_textDuration.text = (video.duration * 1000).formatDuration(); _textDuration.text = (video.duration * 1000).formatDuration();
@@ -6,10 +6,12 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -31,6 +33,7 @@ class ActiveDownloadItem: LinearLayout {
private val _videoState: TextView; private val _videoState: TextView;
private val _videoCancel: TextView; private val _videoCancel: TextView;
private val _videoRetry: TextView;
private val _scope: CoroutineScope; private val _scope: CoroutineScope;
@@ -50,17 +53,19 @@ class ActiveDownloadItem: LinearLayout {
_videoSpeed = findViewById(R.id.download_video_speed); _videoSpeed = findViewById(R.id.download_video_speed);
_videoCancel = findViewById(R.id.download_cancel); _videoCancel = findViewById(R.id.download_cancel);
_videoRetry = findViewById(R.id.download_retry);
_videoName.text = download.name; _videoName.text = download.name;
_videoDuration.text = download.videoEither.duration.toHumanTime(false); _videoDuration.text = download.videoEither.duration.toHumanTime(false);
_videoAuthor.text = download.videoEither.author.name; _videoAuthor.text = download.videoEither.author.name;
_videoState.setOnClickListener { _videoState.setOnClickListener {
UIDialogs.toast(context, _videoState.text.toString(), false); UIDialogs.appToast(_videoState.text.toString(), false);
} }
Glide.with(_videoImage) Glide.with(_videoImage)
.load(download.thumbnail) .load(download.thumbnail)
.withMaxSizePx()
.crossfade() .crossfade()
.into(_videoImage); .into(_videoImage);
@@ -70,6 +75,12 @@ class ActiveDownloadItem: LinearLayout {
StateDownloads.instance.removeDownload(_download); StateDownloads.instance.removeDownload(_download);
StateDownloads.instance.preventPlaylistDownload(_download); StateDownloads.instance.preventPlaylistDownload(_download);
}; };
_videoRetry.setOnClickListener {
download.changeState(VideoDownload.State.QUEUED);
DownloadService.getOrCreateService(context) {
}
}
_download.onProgressChanged.subscribe(this) { _download.onProgressChanged.subscribe(this) {
_scope.launch(Dispatchers.Main) { _scope.launch(Dispatchers.Main) {
@@ -120,16 +131,19 @@ class ActiveDownloadItem: LinearLayout {
VideoDownload.State.DOWNLOADING -> { VideoDownload.State.DOWNLOADING -> {
_videoBar.visibility = VISIBLE; _videoBar.visibility = VISIBLE;
_videoSpeed.visibility = VISIBLE; _videoSpeed.visibility = VISIBLE;
_videoRetry.visibility = GONE;
}; };
VideoDownload.State.ERROR -> { VideoDownload.State.ERROR -> {
_videoState.setTextColor(Color.RED); _videoState.setTextColor(Color.RED);
_videoState.text = _download.error ?: context.getString(R.string.error); _videoState.text = _download.error ?: context.getString(R.string.error);
_videoBar.visibility = GONE; _videoBar.visibility = GONE;
_videoSpeed.visibility = GONE; _videoSpeed.visibility = GONE;
_videoRetry.visibility = VISIBLE;
} }
else -> { else -> {
_videoBar.visibility = GONE; _videoBar.visibility = GONE;
_videoSpeed.visibility = GONE; _videoSpeed.visibility = GONE;
_videoRetry.visibility = GONE;
} }
} }
} }
@@ -5,9 +5,11 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.models.PlaylistDownloaded import com.futo.platformplayer.models.PlaylistDownloaded
import com.futo.platformplayer.withMaxSizePx
class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) { class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) {
init { inflate(context, R.layout.list_downloaded_playlist, this) } init { inflate(context, R.layout.list_downloaded_playlist, this) }
@@ -19,6 +21,7 @@ class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumb
imageText.text = playlistName; imageText.text = playlistName;
Glide.with(imageView) Glide.with(imageView)
.load(playlistThumbnail) .load(playlistThumbnail)
.withMaxSizePx()
.crossfade() .crossfade()
.into(imageView); .into(imageView);
} }
@@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout {
executeDelete() executeDelete()
}, cancelAction = { }, cancelAction = {
}, doNotAskAgainAction = { }, dismissAction = {}, doNotAskAgainAction = {
Settings.instance.other.playlistDeleteConfirmation = false Settings.instance.other.playlistDeleteConfirmation = false
Settings.instance.save() Settings.instance.save()
}) })
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
class SlideUpMenuButtonList : LinearLayout { class SlideUpMenuButtonList : LinearLayout {
private val _root: LinearLayout; private val _root: LinearLayout;
@@ -20,10 +21,16 @@ class SlideUpMenuButtonList : LinearLayout {
var _activeText: String? = null; var _activeText: String? = null;
val id: String? val id: String?
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) { val scrollable: Boolean;
this.id = id
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true); constructor(context: Context, attrs: AttributeSet? = null, id: String? = null, scrollable: Boolean = false): super(context, attrs) {
this.id = id
this.scrollable = scrollable ?: false;
LayoutInflater.from(context).inflate(
if(!scrollable)
R.layout.overlay_slide_up_menu_button_list
else R.layout.overlay_slide_up_menu_button_list_scrollable, this, true);
_root = findViewById(R.id.root); _root = findViewById(R.id.root);
} }
@@ -37,8 +44,9 @@ class SlideUpMenuButtonList : LinearLayout {
buttons.clear(); buttons.clear();
for (t in texts) { for (t in texts) {
val button = LinearLayout(context); val button = LinearLayout(context);
button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply { button.layoutParams = LinearLayout.LayoutParams(if(!scrollable) 0 else LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply {
weight = 1.0f; if(!scrollable)
weight = 1.0f;
marginStart = marginLeft; marginStart = marginLeft;
marginEnd = marginRight; marginEnd = marginRight;
}; };
@@ -49,7 +57,11 @@ class SlideUpMenuButtonList : LinearLayout {
onClick.emit(t); onClick.emit(t);
}; };
button.setPadding(0, 0, 0, 0); val dp8 = 8.dp(resources)
if(!scrollable)
button.setPadding(0, 0, 0, 0);
else
button.setPadding(dp8, 0, dp8, 0);
val text = TextView(context); val text = TextView(context);
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
@@ -69,6 +81,18 @@ class SlideUpMenuButtonList : LinearLayout {
fun setSelected(text: String) { fun setSelected(text: String) {
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option); buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected); buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected);
val dp8 = 8.dp(resources)
if(!scrollable) {
buttons[text]?.setPadding(0, 0, 0, 0);
buttons[_activeText]?.setPadding(0, 0, 0, 0);
}
else {
buttons[text]?.setPadding(dp8, 0, dp8, 0);
buttons[_activeText]?.setPadding(dp8, 0, dp8, 0);
}
_activeText = text; _activeText = text;
} }
} }
@@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.withMaxSizePx
class FutoThumbnailPlayer : FutoVideoPlayerBase { class FutoThumbnailPlayer : FutoVideoPlayerBase {
@@ -135,7 +137,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
if (videoSource == null && audioSource != null) { if (videoSource == null && audioSource != null) {
val thumbnail = video.thumbnails.getHQThumbnail(); val thumbnail = video.thumbnails.getHQThumbnail();
if (!thumbnail.isNullOrBlank()) { if (!thumbnail.isNullOrBlank()) {
Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork); Glide.with(videoView).asBitmap().load(thumbnail).withMaxSizePx().into(_loadArtwork);
} else { } else {
Glide.with(videoView).clear(_loadArtwork); Glide.with(videoView).clear(_loadArtwork);
setArtwork(null); setArtwork(null);
@@ -54,6 +54,7 @@ import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView import com.futo.platformplayer.views.behavior.GestureControlView
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.withMaxSizePx
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -488,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
StatePlayer.instance.onQueueChanged.subscribe(this) { StatePlayer.instance.onQueueChanged.subscribe(this) {
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
setLoopVisible(!StatePlayer.instance.hasQueue) //setLoopVisible(!StatePlayer.instance.hasQueue)
updateNextPrevious(); updateNextPrevious();
} }
} }
@@ -885,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
} }
fun updateLoopVideoUI() { fun updateLoopVideoUI() {
if(StatePlayer.instance.loopVideo) { if(StatePlayer.instance.loopVideo) {
_control_loop.setImageResource(R.drawable.ic_loop_active); _control_loop.setImageResource(R.drawable.ic_repeat_one_active);
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active); _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active);
} }
else { else {
_control_loop.setImageResource(R.drawable.ic_loop); _control_loop.setImageResource(R.drawable.ic_repeat_one);
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop); _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one);
} }
} }
@@ -928,11 +929,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
override fun switchToAudioMode(video: IPlatformVideoDetails?) { override fun switchToAudioMode(video: IPlatformVideoDetails?) {
super.switchToAudioMode(video) super.switchToAudioMode(video)
//This causes issues, and is in general confusing, needs improvements
/*
val thumbnail = video?.thumbnails?.getHQThumbnail() val thumbnail = video?.thumbnails?.getHQThumbnail()
if (!thumbnail.isNullOrBlank()) { if (!thumbnail.isNullOrBlank()) {
Glide.with(context).asBitmap().load(thumbnail) Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object : CustomTarget<Bitmap>() { .into(object : CustomTarget<Bitmap>() {
override fun onResourceReady( override fun onResourceReady(
resource: Bitmap, resource: Bitmap,
@@ -946,6 +945,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
} }
}) })
} }
*/
} }
} }
@@ -111,6 +111,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
* @return This factory. * @return This factory.
*/ */
public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) { public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
JSRequestExecutor oldExecutor = this.requestExecutor;
if(oldExecutor != null) {
oldExecutor.closeAsync();
}
this.requestExecutor = requestExecutor; this.requestExecutor = requestExecutor;
return this; return this;
} }
@@ -123,6 +127,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
* @return This factory. * @return This factory.
*/ */
public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) { public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
JSRequestExecutor oldExecutor = this.requestExecutor2;
if(oldExecutor != null) {
oldExecutor.closeAsync();
}
this.requestExecutor2 = requestExecutor; this.requestExecutor2 = requestExecutor;
return this; return this;
} }
@@ -508,6 +516,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
@Override @Override
public void close() throws HttpDataSourceException { public void close() throws HttpDataSourceException {
if(requestExecutor != null)
requestExecutor.closeAsync();
if(requestExecutor2 != null)
requestExecutor2.closeAsync();
try { try {
@Nullable InputStream inputStream = this.inputStream; @Nullable InputStream inputStream = this.inputStream;
if (inputStream != null) { if (inputStream != null) {
@@ -11,11 +11,13 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.withMaxSizePx
class UpNextView : LinearLayout { class UpNextView : LinearLayout {
private val _layoutContainer: LinearLayout; private val _layoutContainer: LinearLayout;
@@ -160,6 +162,7 @@ class UpNextView : LinearLayout {
_textChannelName.text = nextItem.author.name; _textChannelName.text = nextItem.author.name;
Glide.with(_imageThumbnail) Glide.with(_imageThumbnail)
.load(nextItem.thumbnails.getHQThumbnail()) .load(nextItem.thumbnails.getHQThumbnail())
.withMaxSizePx()
.placeholder(R.drawable.placeholder_video_thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail)
.into(_imageThumbnail); .into(_imageThumbnail);
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+2 -1
View File
@@ -77,12 +77,13 @@
android:layout_width="50dp" android:layout_width="50dp"
android:layout_height="50dp" android:layout_height="50dp"
android:contentDescription="@string/cd_incognito_button" android:contentDescription="@string/cd_incognito_button"
android:src="@drawable/ic_disabled_visible_purple" android:src="@drawable/incognito_purple"
android:background="@drawable/background_button_round_black" android:background="@drawable/background_button_round_black"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:visibility="visible" android:visibility="visible"
android:layout_marginLeft="10dp" android:layout_marginLeft="10dp"
android:layout_marginBottom="10dp" android:layout_marginBottom="10dp"
android:padding="8dp"
android:elevation="50dp" android:elevation="50dp"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toTopOf="@id/toast_view" /> app:layout_constraintBottom_toTopOf="@id/toast_view" />
+1 -1
View File
@@ -106,7 +106,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/update" android:text="@string/download"
android:textSize="14dp" android:textSize="14dp"
android:textColor="@color/white" android:textColor="@color/white"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
@@ -75,7 +75,7 @@
<ImageView <ImageView
android:layout_width="30dp" android:layout_width="30dp"
android:layout_height="30dp" android:layout_height="30dp"
android:src="@drawable/ic_disabled_visible" /> android:src="@drawable/incognito" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -109,6 +109,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
android:layoutDirection="rtl"
android:gravity="end"> android:gravity="end">
</androidx.recyclerview.widget.RecyclerView> </androidx.recyclerview.widget.RecyclerView>
+28 -13
View File
@@ -118,6 +118,21 @@
android:ellipsize="end" android:ellipsize="end"
android:layout_marginEnd="10dp" /> android:layout_marginEnd="10dp" />
<TextView
android:id="@+id/downloaded_author"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:gravity="center_vertical"
android:textSize="9dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
app:layout_constraintTop_toBottomOf="@id/downloaded_video_name"
app:layout_constraintLeft_toLeftOf="parent"
tools:text="ShortCircuit"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginStart="10dp" />
<TextView <TextView
android:id="@+id/download_cancel" android:id="@+id/download_cancel"
android:layout_width="60dp" android:layout_width="60dp"
@@ -130,20 +145,20 @@
android:background="@drawable/background_small_button" android:background="@drawable/background_small_button"
android:textAlignment="center" android:textAlignment="center"
android:text="@string/cancel" /> android:text="@string/cancel" />
<TextView
android:id="@+id/download_retry"
android:layout_width="60dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:padding="2dp"
android:visibility="gone"
app:layout_constraintRight_toRightOf="@id/download_cancel"
app:layout_constraintTop_toBottomOf="@id/download_cancel"
android:textSize="10dp"
android:background="@drawable/background_small_button"
android:textAlignment="center"
android:text="@string/retry" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/downloaded_author"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:gravity="center_vertical"
android:textSize="9dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="ShortCircuit"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginStart="10dp" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="35dp"
android:layout_marginTop="10dp"
android:id="@+id/root"
android:orientation="horizontal"
android:paddingStart="0dp"
android:paddingEnd="0dp">
</LinearLayout>
</HorizontalScrollView>
+1 -1
View File
@@ -65,7 +65,7 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:clickable="true" android:clickable="true"
android:padding="12dp" android:padding="12dp"
app:srcCompat="@drawable/ic_loop" /> app:srcCompat="@drawable/ic_repeat_one" />
<ImageButton <ImageButton
android:id="@+id/button_settings" android:id="@+id/button_settings"
android:layout_width="50dp" android:layout_width="50dp"
@@ -93,7 +93,7 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:clickable="true" android:clickable="true"
android:padding="12dp" android:padding="12dp"
app:srcCompat="@drawable/ic_loop" /> app:srcCompat="@drawable/ic_repeat_one" />
<ImageButton <ImageButton
android:id="@+id/button_settings" android:id="@+id/button_settings"
android:layout_width="50dp" android:layout_width="50dp"
+1
View File
@@ -25,6 +25,7 @@
<string name="subscriptions">Subscriptions</string> <string name="subscriptions">Subscriptions</string>
<string name="loading">Loading</string> <string name="loading">Loading</string>
<string name="retry">Retry</string> <string name="retry">Retry</string>
<string name="install_failed_device_installer_broken">Failed to start system installer. Your devices ROM is not compatible with automatic updates.</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string> <string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
+10
View File
@@ -116,4 +116,14 @@
<item name="android:fontFamily">@font/inter_regular</item> <item name="android:fontFamily">@font/inter_regular</item>
</style> </style>
<style name="Theme.App.TransparentNoUi" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
</style>
</resources> </resources>

Some files were not shown because too many files have changed in this diff Show More