Compare commits

...

46 Commits

Author SHA1 Message Date
Kelvin f36e9588cb Use proper calls for thumbnail 2024-04-23 21:15:18 +02:00
Kelvin 8f99f399ee Refs and tests 2024-04-23 19:26:30 +02:00
Kelvin 56166a7948 Support for chinese, japanese, arabic file names for export 2024-04-23 19:24:59 +02:00
Kelvin 4edd8ee1ea Fix crash on extreme pip aspect ratios 2024-04-23 17:31:10 +02:00
Koen a830c918ab Finished embedding bilibili. 2024-04-23 15:07:10 +02:00
Kelvin 53f74c4b6e Fix for hanging app if logging is enabled 2024-04-22 19:58:22 +02:00
Kelvin 959c192762 Fix channel content not showing older non-videos, fix seperated channel contents not being fetched if not both streams and videos are included 2024-04-19 22:40:13 +02:00
Kelvin 8be7b1272b PeekChannelContent and initial algorithm support, DevSubmit support, Prevent crash url search before init, Documentation url scanning for devportal, limit ongoing downloads ui to 4 2024-04-19 20:16:09 +02:00
Kelvin 6b57878275 Fix post detail loader not disappearing 2024-04-16 21:15:47 +02:00
Kelvin 66c7741c38 Deleting playlist video deletes local files, Post links are now clickable, going back to channel page from post now shows channel page correctly, search capabilities correct for channel content search, Fix loader not disappearing in certain cases on post details 2024-04-16 21:15:28 +02:00
Kelvin b370af9d91 Grayjay logo, WatchLater button, WatchLater download, Download notification dismiss fix, Polycentric open platform, Minor utility additions, Dev method documentation url support 2024-04-12 23:39:33 +02:00
Kelvin 40b86cb5de Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-03-19 20:41:56 +01:00
Kelvin 84622e22aa Logo replacement 2024-03-19 20:41:01 +01:00
Koen 092b20041e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-03-08 08:17:51 +01:00
Koen f6cc00f471 Casting. 2024-03-08 08:17:38 +01:00
Kelvin be2067067b Year rounding 2024-03-06 21:59:55 +01:00
Kelvin 67a7dd9698 Refs 2024-03-06 21:44:48 +01:00
Kelvin 6ffc067b24 Support for cache in reconstructions, non-required cache added to exports, playlists shares now add a cache aswell for quicker importing 2024-03-06 21:39:30 +01:00
Kelvin 56e6314c11 Ref 2024-03-05 17:15:36 +01:00
Kelvin e590bb4a19 Fix 0 year issue 2024-03-05 00:05:46 +01:00
Kelvin 35fe7f0e7a Add type to unknown content exception 2024-03-01 15:31:30 +01:00
Koen 45d818ac81 Reverted dependencies. 2024-02-16 15:51:59 +01:00
Kelvin 7729681829 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-02-16 14:58:28 +01:00
Kelvin b12d04b27d Attempted fix for double controls 2024-02-16 14:58:17 +01:00
Koen e6608b9a5c Updated PolycentricAndroid. 2024-02-16 14:07:27 +01:00
Koen 2d503dfaf6 Added scroll to top. Full scrollable parent comment and Polycentric process secret backup and automatic database recovery. 2024-02-16 13:56:14 +01:00
Kelvin 08934ef8de Modify subscription groups in sub settings 2024-02-14 23:25:58 +01:00
Kelvin 62d927739a Sharing from overview options, notification channel names 2024-02-14 20:15:12 +01:00
Kelvin c8db8f58e8 Refs 2024-02-14 19:19:24 +01:00
Kelvin 0fc966a77d Subscription watched filter 2024-02-14 19:18:35 +01:00
Kelvin 9f6c6c8cf3 Fix support, fix membership urls 2024-01-23 23:51:21 +01:00
Kelvin 43a6ff138c Fix queue looping 2024-01-22 20:54:40 +01:00
Kelvin 269a3460e7 Fix live stream retrying 2024-01-22 15:52:51 +01:00
Koen 18150e9e15 Fixed bottom menu button visibility. 2024-01-19 20:29:00 +01:00
Koen 362c7f5b2c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-19 20:24:35 +01:00
Koen 2adb8ad7f9 Fixed brightness not working. 2024-01-19 20:24:23 +01:00
Kelvin 6b5d4e7507 Fix nullable 2024-01-19 19:44:52 +01:00
Kelvin 49c82726f0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-19 17:28:45 +01:00
Kelvin c8ddcda384 Refs, Dev portal improvements and on-device testing, Fix crashes on disabling v8 race conditions, edgecase where history could be null, issue on starting Grayjay with an url 2024-01-19 17:28:35 +01:00
Koen b75217f789 Possible fix for AudioNoisyReceiver popping up 'App is not responding'. 2024-01-19 17:02:24 +01:00
Koen 8ba8e535bd Added check for updates button on exception activity. 2024-01-19 16:13:42 +01:00
Koen e4c574db6b Fixed crash in updateAllButtonVisibility. 2024-01-19 15:38:22 +01:00
Koen fae73293d7 Fixed crash where it would fail to unregister audio noisy receiver. Fixed crash where system brightness setting does not exist. 2024-01-19 15:17:11 +01:00
Koen 3bd0aac4f8 Implemented system brightness in an alternative way. 2024-01-19 14:09:56 +01:00
Kelvin 26b822e04b Text edit 2024-01-17 17:23:38 +01:00
Kelvin 96b9b8843c Fix wrong visibility for no sources ui 2024-01-17 16:57:35 +01:00
120 changed files with 1999 additions and 524 deletions
+6
View File
@@ -58,3 +58,9 @@
[submodule "dep/futopay"] [submodule "dep/futopay"]
path = dep/futopay path = dep/futopay
url = ../futopayclientlibraries.git url = ../futopayclientlibraries.git
[submodule "app/src/unstable/assets/sources/bilibili"]
path = app/src/unstable/assets/sources/bilibili
url = ../plugins/bilibili.git
[submodule "app/src/stable/assets/sources/bilibili"]
path = app/src/stable/assets/sources/bilibili
url = ../plugins/bilibili.git
+1
View File
@@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -233,6 +233,9 @@ function pluginRemoteProp(objID, propName) {
function pluginRemoteCall(objID, methodName, args) { function pluginRemoteCall(objID, methodName, args) {
return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args))); return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args)));
} }
function pluginRemoteTest(methodName, args) {
return JSON.parse(syncPOST("/plugin/remoteTest?method=" + methodName, {}, JSON.stringify(args)));
}
function pluginIsLoggedIn(cb, err) { function pluginIsLoggedIn(cb, err) {
fetch("/plugin/isLoggedIn", { fetch("/plugin/isLoggedIn", {
+62 -2
View File
@@ -385,8 +385,8 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<div style="width: 50%" v-if="Plugin.currentPlugin"> <div style="width: 50%" v-if="Plugin.currentPlugin">
<!--Get Home--> <v-text-field v-model="searchTestMethods" label="Search for source methods.." style="margin-left: 35px; margin-right: 35px;"></v-text-field>
<v-card class="requestCard" v-for="req in Testing.requests"> <v-card class="requestCard" v-for="req in Testing.requests" v-show="req.title.indexOf(searchTestMethods) >= 0">
<v-card-text> <v-card-text>
<div class="title"> <div class="title">
<span v-if="req.isOptional">(Optional)</span> <span v-if="req.isOptional">(Optional)</span>
@@ -402,6 +402,11 @@
<div class="code"> <div class="code">
{{req.code}} {{req.code}}
</div> </div>
<div class="documentation" v-if="req.docUrl" style="position: absolute; right: 15px; top: 15px;">
<a :href="req.docUrl" target="_blank">
Documentation
</a>
</div>
<div> <div>
<div class="parameter" v-for="parameter in req.parameters"> <div class="parameter" v-for="parameter in req.parameters">
<div class="name"> <div class="name">
@@ -416,6 +421,9 @@
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn @click="testSourceRemotely(req)">
Test Android
</v-btn>
<v-btn @click="testSource(req)"> <v-btn @click="testSource(req)">
Test Test
</v-btn> </v-btn>
@@ -535,6 +543,7 @@
<!--<script src="./dependencies/vue.js"></script>--> <!--<script src="./dependencies/vue.js"></script>-->
<!--<script src="./dependencies/vuetify.js"></script>--> <!--<script src="./dependencies/vuetify.js"></script>-->
<script src="./source_docs.js"></script> <script src="./source_docs.js"></script>
<script src="./source_doc_urls.js"></script>
<script src="./source.js"></script> <script src="./source.js"></script>
<script src="./dev_bridge.js"></script> <script src="./dev_bridge.js"></script>
<script> <script>
@@ -545,6 +554,7 @@
new Vue({ new Vue({
el: '#app', el: '#app',
data: { data: {
searchTestMethods: "",
page: "Plugin", page: "Plugin",
pastPluginUrls: [], pastPluginUrls: [],
settings: {}, settings: {},
@@ -570,6 +580,9 @@
Testing: { Testing: {
requests: sourceDocs.map(x=>{ requests: sourceDocs.map(x=>{
x.parameters.forEach(y=>y.value = null); x.parameters.forEach(y=>y.value = null);
if(sourceDocUrls[x.title])
x.docUrl = sourceDocUrls[x.title];
return x; return x;
}), }),
lastResult: "", lastResult: "",
@@ -860,6 +873,53 @@
"Error: " + ex; "Error: " + ex;
} }
}, },
testSourceRemotely(req) {
const name = req.title;
const parameterVals = req.parameters.map(x=>{
if(x.value && x.value.startsWith && x.value.startsWith("json:"))
return JSON.parse(x.value.substring(5));
return x.value
});
if(name == "enable") {
if(parameterVals.length > 0)
parameterVals[0] = this.Plugin.currentPlugin;
else
parameterVals.push(this.Plugin.currentPlugin);
if(parameterVals.length > 1)
parameterVals[1] = __DEV_SETTINGS;
else
parameterVals.push(__DEV_SETTINGS);
}
const func = source[name];
if(!func)
alert("Test func not found");
try {
const remoteResult = pluginRemoteTest(name, parameterVals);
console.log("Result for " + req.title, remoteResult);
this.Testing.lastResult = "//Results [" + name + "]\n" +
JSON.stringify(remoteResult, null, 3);
this.Testing.lastResultError = "";
}
catch(ex) {
if(ex.plugin_type == "CaptchaRequiredException") {
let shouldCaptcha = confirm("Do you want to request captcha?");
if(shouldCaptcha) {
pluginCaptchaTestPlugin(ex.url, ex.body);
}
}
console.error("Failed to run test for " + req.title, ex);
this.Testing.lastResult = ""
if(ex.message)
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
"Error: " + ex.message + "\n\n" + ex.stack;
else
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
"Error: " + ex;
}
},
showTestResults(results) { showTestResults(results) {
}, },
@@ -13,6 +13,8 @@ import java.text.DecimalFormat
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong
//Long //Long
@@ -119,7 +121,8 @@ fun OffsetDateTime.getNowDiffMonths(): Long {
return ChronoUnit.MONTHS.between(this, OffsetDateTime.now()); return ChronoUnit.MONTHS.between(this, OffsetDateTime.now());
} }
fun OffsetDateTime.getNowDiffYears(): Long { fun OffsetDateTime.getNowDiffYears(): Long {
return ChronoUnit.YEARS.between(this, OffsetDateTime.now()); val diff = ChronoUnit.MONTHS.between(this, OffsetDateTime.now()) / 12.0;
return diff.roundToLong();
} }
fun OffsetDateTime.getDiffDays(otherDate: OffsetDateTime): Long { fun OffsetDateTime.getDiffDays(otherDate: OffsetDateTime): Long {
@@ -150,6 +153,7 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
if(value >= secondsInYear) { if(value >= secondsInYear) {
value = getNowDiffYears(); value = getNowDiffYears();
if(abs) value = abs(value); if(abs) value = abs(value);
value = Math.max(1, value);
unit = "year"; unit = "year";
} }
else if(value >= secondsInMonth) { else if(value >= secondsInMonth) {
@@ -311,7 +311,10 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14) @FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
var alwaysReloadFromCache: Boolean = false; var alwaysReloadFromCache: Boolean = false;
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 15) @FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
var peekChannelContents: Boolean = false;
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
fun clearChannelCache() { fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing.."); UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
StateCache.instance.clear(); StateCache.instance.clear();
@@ -546,6 +549,8 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.log_levels) @DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0; var logLevel: Int = 0;
fun isVerbose() = logLevel >= 4;
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1) @FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
fun submitLogs() { fun submitLogs() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
@@ -823,7 +828,7 @@ class Settings : FragmentedStorageFileJson() {
var toggleFullscreen: Boolean = true; var toggleFullscreen: Boolean = true;
@FormField(R.string.system_brightness, FieldForm.TOGGLE, R.string.system_brightness_descr, 4) @FormField(R.string.system_brightness, FieldForm.TOGGLE, R.string.system_brightness_descr, 4)
var useSystemBrightness: Boolean = true; var useSystemBrightness: Boolean = false;
@FormField(R.string.system_volume, FieldForm.TOGGLE, R.string.system_volume_descr, 5) @FormField(R.string.system_volume, FieldForm.TOGGLE, R.string.system_volume_descr, 5)
var useSystemVolume: Boolean = true; var useSystemVolume: Boolean = true;
@@ -34,6 +34,7 @@ import com.futo.platformplayer.dialogs.MigrateDialog
import com.futo.platformplayer.dialogs.ProgressDialog import com.futo.platformplayer.dialogs.ProgressDialog
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
@@ -304,12 +305,16 @@ class UIDialogs {
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
} }
fun showUpdateAvailableDialog(context: Context, lastVersion: Int) { fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
val dialog = AutoUpdateDialog(context); val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog); registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
dialog.setMaxVersion(lastVersion); dialog.setMaxVersion(lastVersion);
if (hideExceptionButtons) {
dialog.hideExceptionButtons()
}
} }
fun showChangelogDialog(context: Context, lastVersion: Int) { fun showChangelogDialog(context: Context, lastVersion: Int) {
@@ -339,8 +344,8 @@ class UIDialogs {
} }
} }
fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, onConcluded: () -> Unit) { fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, cache: ImportCache?, onConcluded: () -> Unit) {
val dialog = ImportDialog(context, store, name, reconstructions, onConcluded); val dialog = ImportDialog(context, store, name, reconstructions, cache, onConcluded);
registerDialogOpened(dialog); registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -37,11 +38,17 @@ import com.futo.platformplayer.states.StateMeta
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.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.LoaderView import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuRecycler
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.pills.RoundButtonGroup
@@ -87,7 +94,37 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false), }, false),
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuGroup(container.context, "Subscription Groups",
"You can select which groups this subscription is part of.",
-1, listOf()) else null,
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuRecycler(container.context, "as") {
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
it.onClick.subscribe {
if(it is SubscriptionGroup.Selectable) {
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
?: return@subscribe;
groups.clear();
if(it.selected)
actualGroup.urls.remove(subscription.channel.url);
else
actualGroup.urls.add(subscription.channel.url);
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
adapter?.notifyContentChanged();
}
}
};
return@SlideUpMenuRecycler adapter;
} else null,
SlideUpMenuGroup(container.context, "Fetch Settings", SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.", "Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()), -1, listOf()),
@@ -473,10 +510,15 @@ class UISlideOverlays {
} }
} }
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) { fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate -> showUnknownVideoDownload(container.context.getString(R.string.playlist), container) { px, bitrate ->
StateDownloads.instance.download(playlist, px, bitrate); StateDownloads.instance.download(playlist, px, bitrate);
}; };
} }
fun showDownloadWatchlaterOverlay(container: ViewGroup) {
showUnknownVideoDownload(container.context.getString(R.string.watch_later), container, { px, bitrate ->
StateDownloads.instance.downloadWatchLater(px, bitrate);
})
}
private fun showUnknownVideoDownload(toDownload: String, container: ViewGroup, cb: (Long?, Long?)->Unit) { private fun showUnknownVideoDownload(toDownload: String, container: ViewGroup, cb: (Long?, Long?)->Unit) {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null; var menu: SlideUpMenuOverlay? = null;
@@ -646,9 +688,17 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf( (listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), { SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
showDownloadVideoOverlay(video, container, true); showDownloadVideoOverlay(video, container, true);
}, false), }, false),
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
container.context.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain";
}, null));
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", { SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
StateMeta.instance.addHiddenCreator(video.author.url); StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home"); UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
@@ -738,8 +788,8 @@ class UISlideOverlays {
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
} }
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters { fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues); val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
overlay.show(); overlay.show();
return overlay; return overlay;
} }
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
@@ -15,6 +16,7 @@ import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -28,6 +30,7 @@ class ExceptionActivity : AppCompatActivity() {
private lateinit var _buttonSubmit: LinearLayout; private lateinit var _buttonSubmit: LinearLayout;
private lateinit var _buttonRestart: LinearLayout; private lateinit var _buttonRestart: LinearLayout;
private lateinit var _buttonClose: LinearLayout; private lateinit var _buttonClose: LinearLayout;
private lateinit var _buttonCheckForUpdates: LinearLayout;
private var _file: File? = null; private var _file: File? = null;
private var _submitted = false; private var _submitted = false;
@@ -45,6 +48,7 @@ class ExceptionActivity : AppCompatActivity() {
_buttonSubmit = findViewById(R.id.button_submit); _buttonSubmit = findViewById(R.id.button_submit);
_buttonRestart = findViewById(R.id.button_restart); _buttonRestart = findViewById(R.id.button_restart);
_buttonClose = findViewById(R.id.button_close); _buttonClose = findViewById(R.id.button_close);
_buttonCheckForUpdates = findViewById(R.id.button_check_for_updates);
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context); val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace); val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
@@ -83,6 +87,17 @@ class ExceptionActivity : AppCompatActivity() {
_buttonClose.setOnClickListener { _buttonClose.setOnClickListener {
finish(); finish();
}; };
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
_buttonCheckForUpdates.visibility = View.VISIBLE
_buttonCheckForUpdates.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(this@ExceptionActivity, true, true)
}
}
} else {
_buttonCheckForUpdates.visibility = View.GONE
}
} }
private fun submitFile() { private fun submitFile() {
@@ -29,6 +29,7 @@ import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
@@ -40,6 +41,7 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.listeners.OrientationManager import com.futo.platformplayer.listeners.OrientationManager
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -141,7 +143,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
try { try {
handleUrlAll(content) runBlocking {
handleUrlAll(content)
}
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.i(TAG, "Failed to handle URL.", e) Logger.i(TAG, "Failed to handle URL.", e)
UIDialogs.toast(this, "Failed to handle URL: ${e.message}") UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
@@ -188,6 +192,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Logger.i(TAG, "MainActivity Starting");
StateApp.instance.setGlobalContext(this, lifecycleScope); StateApp.instance.setGlobalContext(this, lifecycleScope);
StateApp.instance.mainAppStarting(this); StateApp.instance.mainAppStarting(this);
@@ -540,7 +545,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Pair("grayjay") { req -> Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
if(it is MainActivity) { if(it is MainActivity) {
it.handleUrlAll(req.url.toString()); runBlocking {
it.handleUrlAll(req.url.toString());
}
} }
}; };
} }
@@ -552,7 +559,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try { try {
if (targetData != null) { if (targetData != null) {
handleUrlAll(targetData) runBlocking {
handleUrlAll(targetData)
}
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -560,7 +569,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
fun handleUrlAll(url: String) { suspend fun handleUrlAll(url: String) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
when (uri.scheme) { when (uri.scheme) {
"grayjay" -> { "grayjay" -> {
@@ -596,7 +605,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${url}]", getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
"Ok", "Ok",
{ }); { });
} }
@@ -644,31 +653,38 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
fun handleUrl(url: String): Boolean { suspend fun handleUrl(url: String): Boolean {
Logger.i(TAG, "handleUrl(url=$url)") Logger.i(TAG, "handleUrl(url=$url)")
if (StatePlatform.instance.hasEnabledVideoClient(url)) { return withContext(Dispatchers.IO) {
navigate(_fragVideoDetail, url); Logger.i(TAG, "handleUrl(url=$url) on IO");
_fragVideoDetail.maximizeVideoDetail(true); if (StatePlatform.instance.hasEnabledVideoClient(url)) {
return true; Logger.i(TAG, "handleUrl(url=$url) found video client");
} else if(StatePlatform.instance.hasEnabledChannelClient(url)) { lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainChannel, url); navigate(_fragVideoDetail, url);
lifecycleScope.launch { _fragVideoDetail.maximizeVideoDetail(true);
delay(100); }
_fragVideoDetail.minimizeVideoDetail(); return@withContext true;
}; } else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
return true; Logger.i(TAG, "handleUrl(url=$url) found channel client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainChannel, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
return@withContext true;
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainPlaylist, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
return@withContext true;
}
return@withContext false;
} }
else if(StatePlatform.instance.hasEnabledPlaylistClient(url)) {
navigate(_fragMainPlaylist, url);
lifecycleScope.launch {
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
return true;
}
return false;
} }
fun handleContent(file: String, mime: String? = null): Boolean { fun handleContent(file: String, mime: String? = null): Boolean {
Logger.i(TAG, "handleContent(url=$file)"); Logger.i(TAG, "handleContent(url=$file)");
@@ -679,10 +695,22 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(!recon.trim().startsWith("[")) if(!recon.trim().startsWith("["))
return handleUnknownJson(recon); return handleUnknownJson(recon);
val reconLines = Json.decodeFromString<List<String>>(recon); var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if(cacheStr != null)
cache = Json.decodeFromString(cacheStr);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
recon = reconLines.joinToString("\n"); recon = reconLines.joinToString("\n");
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}"); Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon); handleReconstruction(recon, cache);
return true; return true;
} }
else if(file.lowercase().endsWith(".zip") || mime == "application/zip") { else if(file.lowercase().endsWith(".zip") || mime == "application/zip") {
@@ -697,12 +725,25 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun handleFile(file: String): Boolean { fun handleFile(file: String): Boolean {
Logger.i(TAG, "handleFile(url=$file)"); Logger.i(TAG, "handleFile(url=$file)");
if(file.lowercase().endsWith(".json")) { if(file.lowercase().endsWith(".json")) {
val recon = String(readSharedFile(file)); var recon = String(readSharedFile(file));
if(!recon.startsWith("[")) if(!recon.startsWith("["))
return handleUnknownJson(recon); return handleUnknownJson(recon);
var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if(cacheStr != null)
cache = Json.decodeFromString(cacheStr);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
recon = reconLines.joinToString("\n");
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}"); Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon); handleReconstruction(recon, cache);
return true; return true;
} }
else if(file.lowercase().endsWith(".zip")) { else if(file.lowercase().endsWith(".zip")) {
@@ -714,7 +755,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
return false; return false;
} }
fun handleReconstruction(recon: String) { fun handleReconstruction(recon: String, cache: ImportCache? = null) {
val type = ManagedStore.getReconstructionIdentifier(recon); val type = ManagedStore.getReconstructionIdentifier(recon);
val store: ManagedStore<*> = when(type) { val store: ManagedStore<*> = when(type) {
"Playlist" -> StatePlaylists.instance.playlistStore "Playlist" -> StatePlaylists.instance.playlistStore
@@ -731,7 +772,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(!type.isNullOrEmpty()) { if(!type.isNullOrEmpty()) {
UIDialogs.showImportDialog(this, store, name, listOf(recon)) { UIDialogs.showImportDialog(this, store, name, listOf(recon), cache) {
} }
} }
@@ -12,6 +12,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
@@ -70,6 +71,12 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
processHandle = ProcessHandle.create(); processHandle = ProcessHandle.create();
Store.instance.addProcessSecret(processHandle.processSecret); Store.instance.addProcessSecret(processHandle.processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
processHandle.addServer("https://srv1-stg.polycentric.io"); processHandle.addServer("https://srv1-stg.polycentric.io");
processHandle.setUsername(username); processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
@@ -13,6 +13,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
@@ -126,6 +127,12 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
val processSecret = ProcessSecret(keyPair, Process.random()); val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret); Store.instance.addProcessSecret(processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
val processHandle = processSecret.toProcessHandle(); val processHandle = processSecret.toProcessHandle();
for (e in exportBundle.events.eventsList) { for (e in exportBundle.events.eventsList) {
@@ -60,6 +60,9 @@ class CachedPlatformClient : IPlatformClient {
filters: Map<String, List<String>>? filters: Map<String, List<String>>?
): IPager<IPlatformContent> = _client.getChannelContents(channelUrl); ): IPager<IPlatformContent> = _client.getChannelContents(channelUrl);
override fun getPeekChannelTypes(): List<String> = _client.getPeekChannelTypes();
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent> = _client.peekChannelContents(channelUrl, type);
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String? = _client.getChannelUrlByClaim(claimType, claimValues) override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String? = _client.getChannelUrlByClaim(claimType, claimValues)
override fun searchSuggestions(query: String): Array<String> = _client.searchSuggestions(query); override fun searchSuggestions(query: String): Array<String> = _client.searchSuggestions(query);
@@ -84,6 +84,15 @@ interface IPlatformClient {
*/ */
fun getChannelContents(channelUrl: String, type: String? = null, order: String? = null, filters: Map<String, List<String>>? = null): IPager<IPlatformContent>; fun getChannelContents(channelUrl: String, type: String? = null, order: String? = null, filters: Map<String, List<String>>? = null): IPager<IPlatformContent>;
/**
* Describes what the plugin is capable on peek channel results
*/
fun getPeekChannelTypes(): List<String>;
/**
* Peeks contents of a channel, upload time descending
*/
fun peekChannelContents(channelUrl: String, type: String? = null): List<IPlatformContent>
/** /**
* Gets the channel url associated with a claimType * Gets the channel url associated with a claimType
*/ */
@@ -13,10 +13,12 @@ data class PlatformClientCapabilities(
val hasGetChannelUrlByClaim: Boolean = false, val hasGetChannelUrlByClaim: Boolean = false,
val hasGetChannelTemplateByClaimMap: Boolean = false, val hasGetChannelTemplateByClaimMap: Boolean = false,
val hasGetSearchCapabilities: Boolean = false, val hasGetSearchCapabilities: Boolean = false,
val hasGetSearchChannelContentsCapabilities: Boolean = false,
val hasGetChannelCapabilities: Boolean = false, val hasGetChannelCapabilities: Boolean = false,
val hasGetLiveEvents: Boolean = false, val hasGetLiveEvents: Boolean = false,
val hasGetLiveChatWindow: Boolean = false, val hasGetLiveChatWindow: Boolean = false,
val hasGetContentChapters: Boolean = false val hasGetContentChapters: Boolean = false,
val hasPeekChannelContents: Boolean = false
) { ) {
} }
@@ -14,7 +14,7 @@ open class PlatformAuthorLink {
val id: PlatformID; val id: PlatformID;
val name: String; val name: String;
val url: String; val url: String;
val thumbnail: String?; var thumbnail: String?;
var subscribers: Long? = null; //Optional var subscribers: Long? = null; //Optional
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null) constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null)
@@ -3,6 +3,8 @@ package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8PluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
@@ -31,7 +33,7 @@ class Thumbnails {
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails")) return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
.toArray() .toArray()
.map { Thumbnail.fromV8(it as V8ValueObject) } .map { Thumbnail.fromV8(config, it as V8ValueObject) }
.toTypedArray()); .toTypedArray());
} }
} }
@@ -40,10 +42,10 @@ class Thumbnails {
data class Thumbnail(val url : String?, val quality : Int = 0) { data class Thumbnail(val url : String?, val quality : Int = 0) {
companion object { companion object {
fun fromV8(value: V8ValueObject): Thumbnail { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnail {
return Thumbnail( return Thumbnail(
value.getString("url"), value.getOrDefault<String>(config,"url", "Thumbnail", null),
value.getInteger("quality")); value.getOrDefault(config, "quality", "Thumbnail", 0) ?: 0);
} }
} }
}; };
@@ -37,6 +37,10 @@ class SerializedChannel(
TODO("Not yet implemented") TODO("Not yet implemented")
} }
fun isSameUrl(url: String): Boolean {
return this.url == url || urlAlternatives.contains(url);
}
companion object { companion object {
fun fromChannel(channel: IPlatformChannel): SerializedChannel { fun fromChannel(channel: IPlatformChannel): SerializedChannel {
return SerializedChannel( return SerializedChannel(
@@ -27,6 +27,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSOptional import com.futo.platformplayer.api.media.platforms.js.internal.JSOptional
import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
@@ -58,6 +59,7 @@ import kotlinx.serialization.json.Json
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.reflect.full.findAnnotations import kotlin.reflect.full.findAnnotations
import kotlin.reflect.jvm.kotlinFunction import kotlin.reflect.jvm.kotlinFunction
import kotlin.streams.asSequence
open class JSClient : IPlatformClient { open class JSClient : IPlatformClient {
val config: SourcePluginConfig; val config: SourcePluginConfig;
@@ -73,6 +75,7 @@ open class JSClient : IPlatformClient {
private var _searchCapabilities: ResultCapabilities? = null; private var _searchCapabilities: ResultCapabilities? = null;
private var _searchChannelContentsCapabilities: ResultCapabilities? = null; private var _searchChannelContentsCapabilities: ResultCapabilities? = null;
private var _channelCapabilities: ResultCapabilities? = null; private var _channelCapabilities: ResultCapabilities? = null;
private var _peekChannelTypes: List<String>? = null;
protected val _script: String; protected val _script: String;
@@ -91,7 +94,11 @@ open class JSClient : IPlatformClient {
private val _busyLock = Object(); private val _busyLock = Object();
private var _busyCounter = 0; private var _busyCounter = 0;
private var _busyAction = "";
val isBusy: Boolean get() = _busyCounter > 0; val isBusy: Boolean get() = _busyCounter > 0;
val isBusyAction: String get() {
return _busyAction;
}
val settings: HashMap<String, String?> get() = descriptor.settings; val settings: HashMap<String, String?> get() = descriptor.settings;
@@ -150,6 +157,8 @@ open class JSClient : IPlatformClient {
if(it is ScriptCaptchaRequiredException) if(it is ScriptCaptchaRequiredException)
onCaptchaException.emit(this, it); onCaptchaException.emit(this, it);
}; };
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
} }
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) {
this._context = context; this._context = context;
@@ -173,6 +182,8 @@ open class JSClient : IPlatformClient {
if(it is ScriptCaptchaRequiredException) if(it is ScriptCaptchaRequiredException)
onCaptchaException.emit(this, it); onCaptchaException.emit(this, it);
}; };
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
} }
open fun getCopy(): JSClient { open fun getCopy(): JSClient {
@@ -214,9 +225,11 @@ open class JSClient : IPlatformClient {
hasGetChannelTemplateByClaimMap = plugin.executeBoolean("!!source.getChannelTemplateByClaimMap") ?: false, hasGetChannelTemplateByClaimMap = plugin.executeBoolean("!!source.getChannelTemplateByClaimMap") ?: false,
hasGetSearchCapabilities = plugin.executeBoolean("!!source.getSearchCapabilities") ?: false, hasGetSearchCapabilities = plugin.executeBoolean("!!source.getSearchCapabilities") ?: false,
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false, hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
hasGetSearchChannelContentsCapabilities = plugin.executeBoolean("!!source.getSearchChannelContentsCapabilities") ?: false,
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false, hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false, hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false, hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false
); );
try { try {
@@ -260,7 +273,7 @@ open class JSClient : IPlatformClient {
} }
@JSDocs(2, "source.getHome()", "Gets the HomeFeed of the platform") @JSDocs(2, "source.getHome()", "Gets the HomeFeed of the platform")
override fun getHome(): IPager<IPlatformContent> = isBusyWith { override fun getHome(): IPager<IPlatformContent> = isBusyWith("getHome") {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSContentPager(config, this, return@isBusyWith JSContentPager(config, this,
plugin.executeTyped("source.getHome()")); plugin.executeTyped("source.getHome()"));
@@ -268,7 +281,7 @@ open class JSClient : IPlatformClient {
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
@JSDocsParameter("query", "Query to complete suggestions for") @JSDocsParameter("query", "Query to complete suggestions for")
override fun searchSuggestions(query: String): Array<String> = isBusyWith { override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
ensureEnabled(); ensureEnabled();
return@isBusyWith plugin.executeTyped<V8ValueArray>("source.searchSuggestions(${Json.encodeToString(query)})") return@isBusyWith plugin.executeTyped<V8ValueArray>("source.searchSuggestions(${Json.encodeToString(query)})")
.toArray() .toArray()
@@ -298,7 +311,7 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("order", "(optional) Order in which contents should be returned")
@JSDocsParameter("filters", "(optional) Filters to apply on contents") @JSDocsParameter("filters", "(optional) Filters to apply on contents")
@JSDocsParameter("channelId", "(optional) Channel id to search in") @JSDocsParameter("channelId", "(optional) Channel id to search in")
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith { override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("search") {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSContentPager(config, this, return@isBusyWith JSContentPager(config, this,
plugin.executeTyped("source.search(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); plugin.executeTyped("source.search(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
@@ -306,6 +319,9 @@ open class JSClient : IPlatformClient {
@JSDocs(4, "source.getSearchChannelContentsCapabilities()", "Gets capabilities this plugin has for search videos") @JSDocs(4, "source.getSearchChannelContentsCapabilities()", "Gets capabilities this plugin has for search videos")
override fun getSearchChannelContentsCapabilities(): ResultCapabilities { override fun getSearchChannelContentsCapabilities(): ResultCapabilities {
if(!capabilities.hasGetSearchChannelContentsCapabilities)
return ResultCapabilities(listOf(ResultCapabilities.TYPE_MIXED));
ensureEnabled(); ensureEnabled();
if (_searchChannelContentsCapabilities != null) if (_searchChannelContentsCapabilities != null)
return _searchChannelContentsCapabilities!!; return _searchChannelContentsCapabilities!!;
@@ -319,7 +335,7 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("type", "(optional) Type of contents to get from search ") @JSDocsParameter("type", "(optional) Type of contents to get from search ")
@JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("order", "(optional) Order in which contents should be returned")
@JSDocsParameter("filters", "(optional) Filters to apply on contents") @JSDocsParameter("filters", "(optional) Filters to apply on contents")
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith { override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("searchChannelContents") {
ensureEnabled(); ensureEnabled();
if(!capabilities.hasSearchChannelContents) if(!capabilities.hasSearchChannelContents)
throw IllegalStateException("This plugin does not support channel search"); throw IllegalStateException("This plugin does not support channel search");
@@ -331,7 +347,7 @@ open class JSClient : IPlatformClient {
@JSOptional @JSOptional
@JSDocs(5, "source.searchChannels(query)", "Searches for channels on the platform") @JSDocs(5, "source.searchChannels(query)", "Searches for channels on the platform")
@JSDocsParameter("query", "Query that channels should match") @JSDocsParameter("query", "Query that channels should match")
override fun searchChannels(query: String): IPager<PlatformAuthorLink> = isBusyWith { override fun searchChannels(query: String): IPager<PlatformAuthorLink> = isBusyWith("searchChannels") {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSChannelPager(config, this, return@isBusyWith JSChannelPager(config, this,
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
@@ -351,7 +367,7 @@ open class JSClient : IPlatformClient {
} }
@JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url") @JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url")
@JSDocsParameter("channelUrl", "A channel url (this platform)") @JSDocsParameter("channelUrl", "A channel url (this platform)")
override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith { override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith("getChannel") {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSChannel(config, return@isBusyWith JSChannel(config,
plugin.executeTyped("source.getChannel(${Json.encodeToString(channelUrl)})")); plugin.executeTyped("source.getChannel(${Json.encodeToString(channelUrl)})"));
@@ -378,12 +394,46 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("type", "(optional) Type of contents to get from channel") @JSDocsParameter("type", "(optional) Type of contents to get from channel")
@JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("order", "(optional) Order in which contents should be returned")
@JSDocsParameter("filters", "(optional) Filters to apply on contents") @JSDocsParameter("filters", "(optional) Filters to apply on contents")
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith { override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("getChannelContents") {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSContentPager(config, this, return@isBusyWith JSContentPager(config, this,
plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
} }
@JSDocs(10, "source.getPeekChannelTypes()", "Gets types this plugin has for peek channel contents")
override fun getPeekChannelTypes(): List<String> {
if(!capabilities.hasPeekChannelContents)
return listOf();
try {
if (_peekChannelTypes != null) {
return _peekChannelTypes!!;
}
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return _peekChannelTypes ?: listOf();
}
catch(ex: Throwable) {
announcePluginUnhandledException("getPeekChannelTypes", ex);
return listOf();
}
}
@JSDocs(10, "source.peekChannelContents(url, type)", "Peek contents of a channel (reverse chronological order)")
@JSDocsParameter("channelUrl", "A channel url (this platform)")
@JSDocsParameter("type", "(optional) Type of contents to get from channel")
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent> = isBusyWith("peekChannelContents") {
ensureEnabled();
val items: V8ValueArray = plugin.executeTyped("source.peekChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)})");
return@isBusyWith items.keys.mapNotNull {
val obj = items.get<V8ValueObject>(it);
return@mapNotNull IJSContent.fromV8(this, obj);
};
}
@JSOptional @JSOptional
@JSDocs(11, "source.getChannelUrlByClaim(claimType, claimValues)", "Gets the channel url that should be used to fetch a given polycentric claim") @JSDocs(11, "source.getChannelUrlByClaim(claimType, claimValues)", "Gets the channel url that should be used to fetch a given polycentric claim")
@JSDocsParameter("claimType", "Polycentric claimtype id") @JSDocsParameter("claimType", "Polycentric claimtype id")
@@ -444,7 +494,7 @@ open class JSClient : IPlatformClient {
} }
@JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url") @JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url")
@JSDocsParameter("url", "A content url (this platform)") @JSDocsParameter("url", "A content url (this platform)")
override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith { override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith("getContentDetails") {
ensureEnabled(); ensureEnabled();
return@isBusyWith IJSContentDetails.fromV8(this, return@isBusyWith IJSContentDetails.fromV8(this,
plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})")); plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})"));
@@ -453,7 +503,7 @@ open class JSClient : IPlatformClient {
@JSOptional //getContentChapters = function(url, initialData) @JSOptional //getContentChapters = function(url, initialData)
@JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details") @JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details")
@JSDocsParameter("url", "A content url (this platform)") @JSDocsParameter("url", "A content url (this platform)")
override fun getContentChapters(url: String): List<IChapter> = isBusyWith { override fun getContentChapters(url: String): List<IChapter> = isBusyWith("getContentChapters") {
if(!capabilities.hasGetContentChapters) if(!capabilities.hasGetContentChapters)
return@isBusyWith listOf(); return@isBusyWith listOf();
ensureEnabled(); ensureEnabled();
@@ -464,7 +514,7 @@ open class JSClient : IPlatformClient {
@JSOptional @JSOptional
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url") @JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
@JSDocsParameter("url", "A content url (this platform)") @JSDocsParameter("url", "A content url (this platform)")
override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith { override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith("getPlaybackTracker") {
if(!capabilities.hasGetPlaybackTracker) if(!capabilities.hasGetPlaybackTracker)
return@isBusyWith null; return@isBusyWith null;
ensureEnabled(); ensureEnabled();
@@ -478,7 +528,7 @@ open class JSClient : IPlatformClient {
@JSDocs(16, "source.getComments(url)", "Gets comments for a content by its url") @JSDocs(16, "source.getComments(url)", "Gets comments for a content by its url")
@JSDocsParameter("url", "A content url (this platform)") @JSDocsParameter("url", "A content url (this platform)")
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith { override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith("getComments") {
ensureEnabled(); ensureEnabled();
val pager = plugin.executeTyped<V8Value>("source.getComments(${Json.encodeToString(url)})"); val pager = plugin.executeTyped<V8Value>("source.getComments(${Json.encodeToString(url)})");
if (pager !is V8ValueObject) { //TODO: Maybe solve this better if (pager !is V8ValueObject) { //TODO: Maybe solve this better
@@ -496,7 +546,7 @@ open class JSClient : IPlatformClient {
@JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream") @JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream")
@JSDocsParameter("url", "Url of live stream") @JSDocsParameter("url", "Url of live stream")
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith { override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith("getLiveChatWindow") {
if(!capabilities.hasGetLiveChatWindow) if(!capabilities.hasGetLiveChatWindow)
return@isBusyWith null; return@isBusyWith null;
ensureEnabled(); ensureEnabled();
@@ -505,7 +555,7 @@ open class JSClient : IPlatformClient {
} }
@JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream") @JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream")
@JSDocsParameter("url", "Url of live stream") @JSDocsParameter("url", "Url of live stream")
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = isBusyWith { override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = isBusyWith("getLiveEvents") {
if(!capabilities.hasGetLiveEvents) if(!capabilities.hasGetLiveEvents)
return@isBusyWith null; return@isBusyWith null;
ensureEnabled(); ensureEnabled();
@@ -518,7 +568,7 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("order", "(optional) Order in which contents should be returned")
@JSDocsParameter("filters", "(optional) Filters to apply on contents") @JSDocsParameter("filters", "(optional) Filters to apply on contents")
@JSDocsParameter("channelId", "(optional) Channel id to search in") @JSDocsParameter("channelId", "(optional) Channel id to search in")
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith { override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("searchPlaylists") {
ensureEnabled(); ensureEnabled();
if(!capabilities.hasSearchPlaylists) if(!capabilities.hasSearchPlaylists)
throw IllegalStateException("This plugin does not support playlist search"); throw IllegalStateException("This plugin does not support playlist search");
@@ -528,15 +578,22 @@ open class JSClient : IPlatformClient {
@JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform") @JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform")
@JSDocsParameter("url", "Url of playlist") @JSDocsParameter("url", "Url of playlist")
override fun isPlaylistUrl(url: String): Boolean { override fun isPlaylistUrl(url: String): Boolean {
ensureEnabled();
if (!capabilities.hasGetPlaylist) if (!capabilities.hasGetPlaylist)
return false; return false;
return plugin.executeBoolean("source.isPlaylistUrl(${Json.encodeToString(url)})") ?: false;
try {
return plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
.value;
}
catch(ex: Throwable) {
announcePluginUnhandledException("isPlaylistUrl", ex);
return false;
}
} }
@JSOptional @JSOptional
@JSDocs(21, "source.getPlaylist(url)", "Gets the playlist of the current user") @JSDocs(21, "source.getPlaylist(url)", "Gets the playlist of the current user")
@JSDocsParameter("url", "Url of playlist") @JSDocsParameter("url", "Url of playlist")
override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith { override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith("getPlaylist") {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSPlaylistDetails(this, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})")); return@isBusyWith JSPlaylistDetails(this, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})"));
} }
@@ -633,19 +690,24 @@ open class JSClient : IPlatformClient {
} }
private fun <T> isBusyWith(handle: ()->T): T { private fun <T> isBusyWith(actionName: String, handle: ()->T): T {
try { try {
synchronized(_busyLock) { synchronized(_busyLock) {
_busyCounter++; _busyCounter++;
} }
_busyAction = actionName;
return handle(); return handle();
} }
finally { finally {
_busyAction = "";
synchronized(_busyLock) { synchronized(_busyLock) {
_busyCounter--; _busyCounter--;
} }
} }
} }
private fun <T> isBusyWith(handle: ()->T): T {
return isBusyWith("Unknown", handle);
}
private fun announcePluginUnhandledException(method: String, ex: Throwable) { private fun announcePluginUnhandledException(method: String, ex: Throwable) {
if(ex is PluginEngineException) if(ex is PluginEngineException)
@@ -662,10 +724,43 @@ open class JSClient : IPlatformClient {
companion object { companion object {
val TAG = "JSClient"; val TAG = "JSClient";
private val _lock = Object();
private var _docs: Map<String, String>? = null;
fun getMethodDocs(names: List<String>): Map<String, String>? {
synchronized(_lock) {
if(_docs == null) {
val client = ManagedHttpClient();
val docs = names
.map { stringWithoutBrackets(it) }
.distinct()
.parallelStream()
.map {
val url = "https://github.com/futo-org/grayjay-android/blob/master/docs/source/${it}.md";
val resp = client.head(url);
if(resp.isOk)
return@map Pair(it, url);
else
return@map null;
}.asSequence()
.filterNotNull()
.toMap();
_docs = docs;
}
return _docs;
}
}
fun getMethodDocUrls(): Map<String, String>? {
if(_docs != null)
return _docs;
val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null }
return getMethodDocs(methods.map { it.name });
}
fun getJSDocs(): List<JSCallDocs> { fun getJSDocs(): List<JSCallDocs> {
val docs = mutableListOf<JSCallDocs>(); val docs = mutableListOf<JSCallDocs>();
val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null } val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null }
for(method in methods.sortedBy { it.getAnnotation(JSDocs::class.java)?.order }) { for(method in methods.sortedBy { it.getAnnotation(JSDocs::class.java)?.order }) {
val doc = method.getAnnotation(JSDocs::class.java); val doc = method.getAnnotation(JSDocs::class.java);
val parameters = method.kotlinFunction!!.findAnnotations<JSDocsParameter>(); val parameters = method.kotlinFunction!!.findAnnotations<JSDocsParameter>();
@@ -678,5 +773,12 @@ open class JSClient : IPlatformClient {
} }
return docs; return docs;
} }
private fun stringWithoutBrackets(name: String): String {
val index = name.indexOf('(');
if(index >= 0)
return name.substring(0, index);
return name;
}
} }
} }
@@ -45,7 +45,8 @@ class SourcePluginConfig(
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf(), var supportedClaimTypes: List<Int> = listOf(),
var primaryClaimFieldType: Int? = null var primaryClaimFieldType: Int? = null,
var developerSubmitUrl: String? = null
) : IV8PluginConfig { ) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -8,6 +8,7 @@ import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.views.fields.DropdownFieldOptions import com.futo.platformplayer.views.fields.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -90,7 +91,7 @@ class SourcePluginDescriptor {
@Serializable @Serializable
class AppPluginSettings { class AppPluginSettings {
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, 1) @FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, 0)
var checkForUpdates: Boolean = true; var checkForUpdates: Boolean = true;
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2) @FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
@@ -130,6 +131,11 @@ class SourcePluginDescriptor {
} }
@FormField(R.string.allow_developer_submit, FieldForm.TOGGLE, R.string.allow_developer_submit_description, 1, "devSubmit")
var allowDeveloperSubmit: Boolean = false;
fun loadDefaults(config: SourcePluginConfig) { fun loadDefaults(config: SourcePluginConfig) {
if(tabEnabled.enableHome == null) if(tabEnabled.enableHome == null)
tabEnabled.enableHome = config.enableInHome tabEnabled.enableHome = config.enableInHome
@@ -14,6 +14,6 @@ annotation class JSOptional()
annotation class JSDocsParameter(val name: String, val description: String, val order: Int = 0) annotation class JSDocsParameter(val name: String, val description: String, val order: Int = 0)
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class JSCallDocs(val title: String, val code: String, val description: String, val parameters: List<JSParameterDocs>, val isOptional: Boolean = false); data class JSCallDocs(val title: String, val code: String, val description: String, val parameters: List<JSParameterDocs>, val isOptional: Boolean = false, val docsUrl: String? = null);
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class JSParameterDocs(val name: String, val description: String); data class JSParameterDocs(val name: String, val description: String);
@@ -163,24 +163,25 @@ class AirPlayCastingDevice : CastingDevice {
} }
connectionState = CastConnectionState.CONNECTED; connectionState = CastConnectionState.CONNECTED;
delay(1000);
val progressIndex = progressInfo.lowercase().indexOf("position: "); val progressIndex = progressInfo.lowercase().indexOf("position: ");
if (progressIndex == -1) { if (progressIndex == -1) {
delay(1000);
continue; continue;
} }
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue; val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
setTime(progress); setTime(progress);
val durationIndex = progressInfo.lowercase().indexOf("duration: "); val durationIndex = progressInfo.lowercase().indexOf("duration: ");
if (durationIndex == -1) { if (durationIndex == -1) {
delay(1000);
continue; continue;
} }
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue; val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
setDuration(duration); setDuration(duration);
delay(1000);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get server info from AirPlay device.", e) Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
} }
@@ -44,7 +44,9 @@ class ChromecastCastingDevice : CastingDevice {
private var _socket: SSLSocket? = null; private var _socket: SSLSocket? = null;
private var _outputStream: DataOutputStream? = null; private var _outputStream: DataOutputStream? = null;
private var _outputStreamLock = Object();
private var _inputStream: DataInputStream? = null; private var _inputStream: DataInputStream? = null;
private var _inputStreamLock = Object();
private var _scopeIO: CoroutineScope? = null; private var _scopeIO: CoroutineScope? = null;
private var _requestId = 1; private var _requestId = 1;
private var _started: Boolean = false; private var _started: Boolean = false;
@@ -383,39 +385,44 @@ class ChromecastCastingDevice : CastingDevice {
getStatus(); getStatus();
val buffer = ByteArray(4096); val buffer = ByteArray(409600);
Logger.i(TAG, "Started receiving."); Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) { while (_scopeIO?.isActive == true) {
try { try {
val inputStream = _inputStream ?: break; val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size = ((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
continue;
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); synchronized(_inputStreamLock)
inputStream.read(buffer, 0, size); {
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size =
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
return@synchronized
}
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end? Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
val messageBytes = buffer.sliceArray(IntRange(0, size - 1)); inputStream.read(buffer, 0, size);
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
try { //TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
handleMessage(message); val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
} catch (e:Throwable) { Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
Logger.w(TAG, "Failed to handle message.", e); val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
try {
handleMessage(message);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} }
} catch (e: java.net.SocketException) { } catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e); Logger.e(TAG, "Socket exception while receiving.", e);
@@ -588,13 +595,16 @@ class ChromecastCastingDevice : CastingDevice {
return; return;
} }
val serializedSizeBE = ByteArray(4); synchronized(_outputStreamLock)
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte(); {
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte(); val serializedSizeBE = ByteArray(4);
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte(); serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
serializedSizeBE[3] = (data.size and 0xff).toByte(); serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
outputStream.write(serializedSizeBE); serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
outputStream.write(data); serializedSizeBE[3] = (data.size and 0xff).toByte();
outputStream.write(serializedSizeBE);
outputStream.write(data);
}
//Log.d(TAG, "Sent ${data.size} bytes."); //Log.d(TAG, "Sent ${data.size} bytes.");
} }
@@ -242,6 +242,7 @@ class StateCasting {
jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener); jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener); jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener);
jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener); jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
jmDNS.addServiceListener("_fcast._tcp.local.", _fastCastServiceListener);
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
jmDNS.addServiceTypeListener(_serviceTypeListener); jmDNS.addServiceTypeListener(_serviceTypeListener);
@@ -9,7 +9,10 @@ import com.futo.platformplayer.api.http.server.HttpGET
import com.futo.platformplayer.api.http.server.HttpPOST import com.futo.platformplayer.api.http.server.HttpPOST
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.dev.V8RemoteObject import com.futo.platformplayer.engine.dev.V8RemoteObject
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
@@ -20,18 +23,29 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets import com.futo.platformplayer.states.StateAssets
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.google.gson.ExclusionStrategy
import com.google.gson.FieldAttributes
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonParser import com.google.gson.JsonParser
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.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Modifier
import java.util.UUID import java.util.UUID
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberFunctions
import kotlin.reflect.jvm.javaType
import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmErasure
class DeveloperEndpoints(private val context: Context) { class DeveloperEndpoints(private val context: Context) {
private val TAG = "DeveloperEndpoints"; private val TAG = "DeveloperEndpoints";
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
private var _testPlugin: V8Plugin? = null; private var _testPlugin: V8Plugin? = null;
private var _testPluginFull: JSClient? = null;
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin"); private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf(); private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
@@ -90,6 +104,17 @@ class DeveloperEndpoints(private val context: Context) {
@HttpGET("/source_docs.js", "application/javascript") @HttpGET("/source_docs.js", "application/javascript")
val devSourceDocsJS = "const sourceDocs = $devSourceDocsJson"; val devSourceDocsJS = "const sourceDocs = $devSourceDocsJson";
@HttpGET("/source_doc_urls.json", "application/json")
fun devSourceDocUrlsJson(httpContext: HttpContext) {;
val docs = JSClient.getMethodDocUrls();
httpContext.respondCode(200, Json.encodeToString(docs), "application/json");
}
@HttpGET("/source_doc_urls.js", "application/javascript")
fun devSourceDocUrlsJs(httpContext: HttpContext) {;
val docs = JSClient.getMethodDocUrls();
httpContext.respondCode(200, "const sourceDocUrls = " + Json.encodeToString(docs), "application/javascript");
}
//Dependencies //Dependencies
//@HttpGET("/dependencies/vue.js", "application/javascript") //@HttpGET("/dependencies/vue.js", "application/javascript")
//val depVue = StateAssets.readAsset(context, "devportal/dependencies/vue.js", true); //val depVue = StateAssets.readAsset(context, "devportal/dependencies/vue.js", true);
@@ -190,6 +215,17 @@ class DeveloperEndpoints(private val context: Context) {
val client = JSHttpClient(null, null, null, config); val client = JSHttpClient(null, null, null, config);
val clientAuth = JSHttpClient(null, null, null, config); val clientAuth = JSHttpClient(null, null, null, config);
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth); _testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
try {
val script = _client.get(config.absoluteScriptUrl);
_testPluginFull = JSClient(StateApp.instance.context, SourcePluginDescriptor(
config, null, null, null
), null, script.body?.string() ?: "");
_testPluginFull!!.initialize();
}
catch (ex: Throwable) {
Logger.e(TAG, "Loading full client failed", ex);
_testPluginFull = null;
}
context.respondJson(200, testPluginOrThrow.getPackageVariables()); context.respondJson(200, testPluginOrThrow.getPackageVariables());
} }
@@ -440,6 +476,68 @@ class DeveloperEndpoints(private val context: Context) {
} }
} }
private val _fieldAttributesField = FieldAttributes::class.java.getDeclaredField("field");
init {
_fieldAttributesField.isAccessible = true;
}
private val _remoteTestGson = GsonBuilder()
.setExclusionStrategies(object : ExclusionStrategy {
override fun shouldSkipClass(clazz: Class<*>?): Boolean {
return clazz?.simpleName == "JSClient" ||
clazz?.simpleName == "KSerializer[]" ||
clazz?.simpleName == "V8ValueObject";
}
override fun shouldSkipField(f: FieldAttributes?): Boolean {
val isPublic = f?.hasModifier(Modifier.PUBLIC) ?: true;
if(!isPublic) {
val underlyingField = _fieldAttributesField.get(f) as Field;
return !(underlyingField.declaringClass as Class).methods.any { it.name == "get" + underlyingField.name.replaceFirstChar { it.uppercaseChar() } && Modifier.isPublic(it.modifiers) };
}
else
return !isPublic;
}
}).create();
@HttpPOST("/plugin/remoteTest")
fun pluginRemoteTest(context: HttpContext) {
val method = context.query.getOrDefault("method", "");
try {
val parameters = context.readContentString();
val paras = JsonParser.parseString(parameters);
if(!paras.isJsonArray)
throw IllegalArgumentException("Expected json array as body");
val plugin = _testPluginFull ?: throw IllegalStateException("Plugin not loaded");
val function = plugin::class.memberFunctions.filter { it.findAnnotation<JSDocs>() != null }
.find { it.name == method };
if(function == null)
throw java.lang.IllegalArgumentException("Plugin method [${function}] not found");
val callResult = function.call(*(listOf(plugin) + paras.asJsonArray.take(function.parameters.size - 1).mapIndexed { index, jsonElement ->
//For now, manual conversion.
val parameter = function.parameters[index + 1];
val value = _remoteTestGson.fromJson<Any>(jsonElement, parameter.type.javaType);
return@mapIndexed value;
}).toTypedArray());
val json = if(callResult is IPager<*>)
_remoteTestGson.toJson(callResult.getResults())
else
_remoteTestGson.toJson(callResult);
//val json = wrapRemoteResult(callResult, false);
context.respondCode(200, json);
}
catch(ex: InvocationTargetException) {
Logger.e(TAG, "Remote test for [${method}] is failed", ex.targetException);
context.respondCode(500, ex.targetException.message ?: "", "text/plain")
}
catch(ex: Exception) {
Logger.e(TAG, "Remote test for [${method}] is failed", ex);
context.respondCode(500, ex.message ?: "", "text/plain")
}
}
//Internal calls //Internal calls
@HttpPOST("/get") @HttpPOST("/get")
fun get(context: HttpContext) { fun get(context: HttpContext) {
@@ -96,6 +96,11 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.") Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
} }
fun hideExceptionButtons() {
_buttonNever.visibility = View.GONE
_buttonShowChangelog.visibility = View.GONE
}
private fun update() { private fun update() {
_buttonShowChangelog.visibility = Button.GONE; _buttonShowChangelog.visibility = Button.GONE;
_buttonNever.visibility = Button.GONE; _buttonNever.visibility = Button.GONE;
@@ -22,7 +22,9 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.assume import com.futo.platformplayer.assume
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -66,13 +68,15 @@ class ImportDialog : AlertDialog {
private val _name: String; private val _name: String;
private val _toImport: List<String>; private val _toImport: List<String>;
private val _cache: ImportCache?;
constructor(context: Context, importStore: ManagedStore<*>, name: String, toReconstruct: List<String>, onConcluded: ()->Unit): super(context) { constructor(context: Context, importStore: ManagedStore<*>, name: String, toReconstruct: List<String>, cache: ImportCache?, onConcluded: ()->Unit): super(context) {
_context = context; _context = context;
_store = importStore; _store = importStore;
_onConcluded = onConcluded; _onConcluded = onConcluded;
_name = name; _name = name;
_toImport = ArrayList(toReconstruct); _toImport = ArrayList(toReconstruct);
_cache = cache;
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -146,7 +150,7 @@ class ImportDialog : AlertDialog {
val scope = StateApp.instance.scopeOrNull; val scope = StateApp.instance.scopeOrNull;
scope?.launch(Dispatchers.IO) { scope?.launch(Dispatchers.IO) {
try { try {
val migrationResult = _store.importReconstructions(_toImport) { finished, total -> val migrationResult = _store.importReconstructions(_toImport, _cache) { finished, total ->
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
_textProgress.text = "${finished}/${total}"; _textProgress.text = "${finished}/${total}";
} }
@@ -755,6 +755,7 @@ class VideoDownload {
companion object { companion object {
const val TAG = "VideoDownload"; const val TAG = "VideoDownload";
const val GROUP_PLAYLIST = "Playlist"; const val GROUP_PLAYLIST = "Playlist";
const val GROUP_WATCHLATER= "WatchLater";
fun videoContainerToExtension(container: String): String? { fun videoContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl") if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
@@ -7,6 +7,7 @@ import androidx.documentfile.provider.DocumentFile
import com.arthenica.ffmpegkit.* import com.arthenica.ffmpegkit.*
import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
@@ -63,7 +64,7 @@ class VideoExport {
val outputFile: DocumentFile?; val outputFile: DocumentFile?;
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set"); val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
if (sourceCount > 1) { if (sourceCount > 1) {
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile("video/mp4", outputFileName) val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
@@ -79,7 +80,7 @@ class VideoExport {
} }
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile(v.container, outputFileName) val f = downloadRoot.createFile(v.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
@@ -91,7 +92,7 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = downloadRoot.createFile(a.container, outputFileName) val f = downloadRoot.createFile(a.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
@@ -110,11 +111,6 @@ class VideoExport {
return@coroutineScope outputFile; return@coroutineScope outputFile;
} }
private fun toSafeFileName(input: String): String {
val safeCharacters = ('a'..'z') + ('A'..'Z') + ('0'..'9') + listOf('-', '_')
return input.map { if (it in safeCharacters) it else '_' }.joinToString(separator = "")
}
private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) { private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
//ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4 //ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine
import android.content.Context import android.content.Context
import com.caoccao.javet.exceptions.JavetCompilationException import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetException
import com.caoccao.javet.exceptions.JavetExecutionException import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interop.V8Host import com.caoccao.javet.interop.V8Host
import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.interop.V8Runtime
@@ -43,7 +44,6 @@ class V8Plugin {
private val _clientAuth: ManagedHttpClient; private val _clientAuth: ManagedHttpClient;
private val _clientOthers: ConcurrentHashMap<String, JSHttpClient> = ConcurrentHashMap(); private val _clientOthers: ConcurrentHashMap<String, JSHttpClient> = ConcurrentHashMap();
val httpClient: ManagedHttpClient get() = _client; val httpClient: ManagedHttpClient get() = _client;
val httpClientAuth: ManagedHttpClient get() = _clientAuth; val httpClientAuth: ManagedHttpClient get() = _clientAuth;
val httpClientOthers: Map<String, JSHttpClient> get() = _clientOthers; val httpClientOthers: Map<String, JSHttpClient> get() = _clientOthers;
@@ -69,6 +69,11 @@ class V8Plugin {
private var _busyCounter = 0; private var _busyCounter = 0;
val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 }; val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 };
var allowDevSubmit: Boolean = false
private set(value) {
field = value;
}
/** /**
* Called before a busy counter is about to be removed. * Called before a busy counter is about to be removed.
* Is primarily used to prevent additional calls to dead runtimes. * Is primarily used to prevent additional calls to dead runtimes.
@@ -90,6 +95,10 @@ class V8Plugin {
withDependency(getPackage(pack)); withDependency(getPackage(pack));
} }
fun changeAllowDevSubmit(isAllowed: Boolean) {
allowDevSubmit = isAllowed;
}
fun withDependency(context: Context, assetPath: String) : V8Plugin { fun withDependency(context: Context, assetPath: String) : V8Plugin {
if(!_deps.containsKey(assetPath)) if(!_deps.containsKey(assetPath))
_deps.put(assetPath, getAssetFile(context, assetPath)); _deps.put(assetPath, getAssetFile(context, assetPath));
@@ -173,8 +182,16 @@ class V8Plugin {
isStopped = true; isStopped = true;
_runtime?.let { _runtime?.let {
_runtime = null; _runtime = null;
if(!it.isClosed && !it.isDead) if(!it.isClosed && !it.isDead) {
it.close(); try {
it.close();
}
catch(ex: JavetException) {
//In case race conditions are going on, already closed runtimes are fine.
if(ex.message?.contains("Runtime is already closed") != true)
throw ex;
}
}
Logger.i(TAG, "Stopped plugin [${config.name}]"); Logger.i(TAG, "Stopped plugin [${config.name}]");
}; };
} }
@@ -5,6 +5,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.UIDialogs 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.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
@@ -12,6 +13,9 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class PackageBridge : V8Package { class PackageBridge : V8Package {
@Transient @Transient
@@ -21,6 +25,7 @@ class PackageBridge : V8Package {
@Transient @Transient
private val _clientAuth: ManagedHttpClient private val _clientAuth: ManagedHttpClient
override val name: String get() = "Bridge"; override val name: String get() = "Bridge";
override val variableName: String get() = "bridge"; override val variableName: String get() = "bridge";
@@ -47,6 +52,44 @@ class PackageBridge : V8Package {
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", str ?: "null"); StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", str ?: "null");
} }
private val _jsonSerializer = Json { this.prettyPrintIndent = " "; this.prettyPrint = true; };
private var _devSubmitClient: ManagedHttpClient? = null;
@V8Function
fun devSubmit(label: String, data: String) {
if(_plugin.config !is SourcePluginConfig)
return;
if(!_plugin.allowDevSubmit)
return;
val devUrl = _plugin.config.developerSubmitUrl ?: return;
if(_devSubmitClient == null)
_devSubmitClient = ManagedHttpClient();
val stackTrace = Thread.currentThread().stackTrace;
val callerMethod = stackTrace.findLast {
it.className == JSClient::class.java.name
}?.methodName ?: "";
val session = StateApp.instance.sessionId;
val pluginId = _plugin.config.id;
val pluginVersion = _plugin.config.version;
val obj = DevSubmitData(pluginId, pluginVersion, callerMethod, session, label, data);
UIDialogs.toast("DevSubmit [${callerMethod}] (${_plugin.config.name})", false);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val json = _jsonSerializer.encodeToString(obj);
Logger.i(TAG, "DevSubmit [${callerMethod}] - ${devUrl}\n" + json);
val resp = _devSubmitClient?.post(devUrl, json, mutableMapOf(Pair("Content-Type", "application/json")));
Logger.i(TAG, "DevSubmit [${callerMethod}] - ${devUrl} Status: " + (resp?.code?.toString() ?: "-1"))
}
catch(ex: Exception) {
Logger.e(TAG, "DevSubmission to [${devUrl}] failed due to:\n" + ex.message, ex);
}
}
}
@Serializable
class DevSubmitData(val pluginId: String, val pluginVersion: Int, val caller: String, val session: String, val label: String, val data: String)
@V8Function @V8Function
fun throwTest(str: String) { fun throwTest(str: String) {
throw IllegalStateException(str); throw IllegalStateException(str);
@@ -4,8 +4,11 @@ import android.util.Base64
import com.caoccao.javet.annotations.V8Function import com.caoccao.javet.annotations.V8Function
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.google.common.hash.Hashing.md5
import java.security.MessageDigest
import java.util.UUID import java.util.UUID
class PackageUtilities : V8Package { class PackageUtilities : V8Package {
@Transient @Transient
private val _config: IV8PluginConfig; private val _config: IV8PluginConfig;
@@ -19,7 +22,31 @@ class PackageUtilities : V8Package {
@V8Function @V8Function
fun toBase64(arr: ByteArray): String { fun toBase64(arr: ByteArray): String {
return Base64.encodeToString(arr, Base64.NO_WRAP); return Base64.encodeToString(arr, Base64.NO_PADDING or Base64.NO_WRAP);
}
@V8Function
fun fromBase64(str: String): ByteArray {
return Base64.decode(str, Base64.NO_PADDING or Base64.NO_WRAP)
}
@V8Function
fun md5(arr: ByteArray): ByteArray {
return MessageDigest.getInstance("MD5").digest(arr);
}
@V8Function
fun md5String(str: String): String {
return md5(str.toByteArray(Charsets.UTF_8)).fold("") { str, it -> str + "%02x".format(it) };
}
@V8Function
fun sha256(arr: ByteArray): ByteArray {
return MessageDigest.getInstance("SHA-256").digest(arr);
}
@V8Function
fun sha256String(str: String): String {
return sha256(str.toByteArray(Charsets.UTF_8)).fold("") { str, it -> str + "%02x".format(it) };
} }
@V8Function @V8Function
@@ -60,8 +60,10 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>(); val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>(); val onAddToQueueClicked = Event1<IPlatformContent>();
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>(); val onLongPress = Event1<IPlatformContent>();
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> { private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
Logger.i(TAG, "getContentPager"); Logger.i(TAG, "getContentPager");
@@ -103,9 +105,9 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
}).success { }).success {
setLoading(false); setLoading(false);
val posBefore = _results.size; val posBefore = _results.size;
val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo } //val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo }
_results.addAll(toAdd); _results.addAll(it);
_adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), toAdd.size); }; _adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), it.size); };
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it); Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() }); UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() });
@@ -157,6 +159,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit); this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
this.onAddToWatchLaterClicked.subscribe(this@ChannelContentsFragment.onAddToWatchLaterClicked::emit);
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit); this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
} }
@@ -247,11 +247,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
val defs = currentButtonDefinitions?.toMutableList() ?: return val defs = currentButtonDefinitions?.toMutableList() ?: return
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics; val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt(); _buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
if (_buttonsVisible - 1 >= defs.size) { if (_buttonsVisible >= defs.size) {
updateBottomMenuButtons(defs.toMutableList(), false); updateBottomMenuButtons(defs.toMutableList(), false);
} else if (_buttonsVisible > 0) {
updateBottomMenuButtons(defs.take(_buttonsVisible - 1).toMutableList(), true);
updateMoreButtons(defs.drop(_buttonsVisible - 1).toMutableList());
} else { } else {
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true); updateBottomMenuButtons(mutableListOf(), false)
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList()); updateMoreButtons(defs.toMutableList())
} }
} }
@@ -26,6 +26,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
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.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPost
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.SerializedPlatformVideo
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
@@ -206,6 +207,12 @@ class ChannelFragment : MainFragment() {
StatePlayer.instance.addToQueue(content); StatePlayer.instance.addToQueue(content);
} }
} }
adapter.onAddToWatchLaterClicked.subscribe { content ->
if(content is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content));
UIDialogs.toast("Added to watch later\n[${content.name}]");
}
}
adapter.onUrlClicked.subscribe { url -> adapter.onUrlClicked.subscribe { url ->
fragment.navigate<BrowserFragment>(url); fragment.navigate<BrowserFragment>(url);
} }
@@ -264,7 +271,7 @@ class ChannelFragment : MainFragment() {
_taskLoadPolycentricProfile.cancel(); _taskLoadPolycentricProfile.cancel();
_selectedTabIndex = -1; _selectedTabIndex = -1;
if (!isBack) { if (!isBack || _url == null) {
_imageBanner.setImageDrawable(null); _imageBanner.setImageDrawable(null);
if (parameter is String) { if (parameter is String) {
@@ -1,7 +1,9 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Browser
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -118,6 +120,7 @@ class CommentsFragment : MainFragment() {
holder.onDelete.subscribe(::onDelete); holder.onDelete.subscribe(::onDelete);
holder.onRepliesClick.subscribe(::onRepliesClick); holder.onRepliesClick.subscribe(::onRepliesClick);
holder.onClick.subscribe(::onClick); holder.onClick.subscribe(::onClick);
holder.onAuthorClick.subscribe(::onAuthorClick);
return@InsertedViewAdapterWithLoader holder; return@InsertedViewAdapterWithLoader holder;
} }
); );
@@ -211,6 +214,19 @@ class CommentsFragment : MainFragment() {
setRepliesOverlayVisible(true, true) setRepliesOverlayVisible(true, true)
} }
} }
private fun onAuthorClick(c: IPlatformComment) {
if (c !is PolycentricPlatformComment) {
return@onAuthorClick;
}
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
//val navUrl = "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length);
val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
_fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
//_fragment.navigate<BrowserFragment>(navUrl);
}
}
private fun onRepliesClick(c: IPlatformComment) { private fun onRepliesClick(c: IPlatformComment) {
val replyCount = c.replyCount ?: 0; val replyCount = c.replyCount ?: 0;
@@ -12,10 +12,12 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
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.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPost
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.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.* import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
@@ -81,6 +83,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
StatePlayer.instance.addToQueue(it); StatePlayer.instance.addToQueue(it);
} }
}; };
adapter.onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it));
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
};
adapter.onLongPress.subscribe(this) { adapter.onLongPress.subscribe(this) {
if (it is IPlatformVideo) { if (it is IPlatformVideo) {
showVideoOptionsOverlay(it) showVideoOptionsOverlay(it)
@@ -135,6 +143,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
adapter.onChannelClicked.remove(this); adapter.onChannelClicked.remove(this);
adapter.onAddToClicked.remove(this); adapter.onAddToClicked.remove(this);
adapter.onAddToQueueClicked.remove(this); adapter.onAddToQueueClicked.remove(this);
adapter.onAddToWatchLaterClicked.remove(this);
adapter.onLongPress.remove(this); adapter.onLongPress.remove(this);
} }
@@ -129,7 +129,7 @@ class ContentSearchResultsFragment : MainFragment() {
onFilterClick.subscribe(this) { onFilterClick.subscribe(this) {
_overlayContainer.let { _overlayContainer.let {
val filterValuesCopy = HashMap(_filterValues); val filterValuesCopy = HashMap(_filterValues);
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy); val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null);
filtersOverlay.onOK.subscribe { enabledClientIds, changed -> filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
if (changed) { if (changed) {
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
@@ -170,7 +170,11 @@ class ContentSearchResultsFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); val commonCapabilities =
if(_channelUrl == null)
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
val sorts = commonCapabilities?.sorts ?: listOf(); val sorts = commonCapabilities?.sorts ?: listOf();
if (sorts.size > 1) { if (sorts.size > 1) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -12,8 +12,10 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
@@ -143,6 +145,7 @@ class DownloadsFragment : MainFragment() {
val activeDownloads = StateDownloads.instance.getDownloading(); val activeDownloads = StateDownloads.instance.getDownloading();
val playlists = StateDownloads.instance.getCachedPlaylists(); val playlists = StateDownloads.instance.getCachedPlaylists();
val watchLaterDownload = StateDownloads.instance.getWatchLaterDescriptor();
val downloaded = StateDownloads.instance.getDownloadedVideos() val downloaded = StateDownloads.instance.getDownloadedVideos()
.filter { it.groupType != VideoDownload.GROUP_PLAYLIST || it.groupID == null || !StateDownloads.instance.hasCachedPlaylist(it.groupID!!) }; .filter { it.groupType != VideoDownload.GROUP_PLAYLIST || it.groupID == null || !StateDownloads.instance.hasCachedPlaylist(it.groupID!!) };
@@ -150,23 +153,35 @@ class DownloadsFragment : MainFragment() {
_listActiveDownloadsContainer.visibility = GONE; _listActiveDownloadsContainer.visibility = GONE;
else { else {
_listActiveDownloadsContainer.visibility = VISIBLE; _listActiveDownloadsContainer.visibility = VISIBLE;
_listActiveDownloadsMeta.text = "(${activeDownloads.size})"; _listActiveDownloadsMeta.text = "(${activeDownloads.size} videos)";
_listActiveDownloads.removeAllViews(); _listActiveDownloads.removeAllViews();
for(view in activeDownloads.map { ActiveDownloadItem(context, it, _frag.lifecycleScope) }) for(view in activeDownloads.take(4).map { ActiveDownloadItem(context, it, _frag.lifecycleScope) })
_listActiveDownloads.addView(view); _listActiveDownloads.addView(view);
} }
if(playlists.isEmpty()) if(playlists.isEmpty() && watchLaterDownload == null)
_listPlaylistsContainer.visibility = GONE; _listPlaylistsContainer.visibility = GONE;
else { else {
_listPlaylistsContainer.visibility = VISIBLE; _listPlaylistsContainer.visibility = VISIBLE;
_listPlaylistsMeta.text = "(${playlists.size} ${context.getString(R.string.playlists).lowercase()}, ${playlists.sumOf { it.playlist.videos.size }} ${context.getString(R.string.videos).lowercase()})";
val watchLater = if(watchLaterDownload != null) StatePlaylists.instance.getWatchLater() else listOf();
_listPlaylistsMeta.text = "(${playlists.size + (if(watchLaterDownload != null) 1 else 0)} ${context.getString(R.string.playlists).lowercase()}, ${playlists.sumOf { it.playlist.videos.size } + watchLater.size} ${context.getString(R.string.videos).lowercase()})";
_listPlaylists.removeAllViews(); _listPlaylists.removeAllViews();
for(view in playlists.map { PlaylistDownloadItem(context, it) }) { if(watchLaterDownload != null) {
val pdView = PlaylistDownloadItem(context, "Watch Later", watchLater.firstOrNull()?.thumbnails?.getHQThumbnail(), "WATCHLATER");
pdView.setOnClickListener {
_frag.navigate<WatchLaterFragment>();
}
_listPlaylists.addView(pdView);
}
for(view in playlists.map { PlaylistDownloadItem(context, it.playlist.name, it.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail(), it.playlist) }) {
view.setOnClickListener { view.setOnClickListener {
_frag.navigate<PlaylistFragment>(view.playlist.playlist); if(view.obj is Playlist) {
_frag.navigate<PlaylistFragment>(view.obj);
}
}; };
_listPlaylists.addView(view); _listPlaylists.addView(view);
} }
@@ -32,6 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -160,15 +163,17 @@ class HomeFragment : MainFragment() {
val pluginsExist = StatePlatform.instance.getAvailableClients().isNotEmpty(); val pluginsExist = StatePlatform.instance.getAvailableClients().isNotEmpty();
if(StatePlatform.instance.getEnabledClients().isEmpty()) if(StatePlatform.instance.getEnabledClients().isEmpty())
//Initial setup //Initial setup
return NoResultsView(context, "No enabled Sources", if(pluginsExist) return NoResultsView(context, "No enabled sources", if(pluginsExist)
"Enable or install some Sources" "Enable or install some sources"
else "This Grayjay version comes without any sources, install sources externally or using the button below.", R.drawable.ic_sources, else "This Grayjay version comes without any sources, install sources externally or using the button below.", R.drawable.ic_sources,
listOf(BigButton(context, "Browse Online Sources", "View official sources online", R.drawable.ic_explore) { listOf(BigButton(context, "Browse Online Sources", "View official sources online", R.drawable.ic_explore) {
fragment.navigate<BrowserFragment>(BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( fragment.navigate<BrowserFragment>(BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req -> Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
if(it is MainActivity) { if(it is MainActivity) {
it.handleUrlAll(req.url.toString()); runBlocking {
it.handleUrlAll(req.url.toString());
}
} }
}; };
} }
@@ -201,14 +201,18 @@ class PlaylistFragment : MainFragment() {
showConvertPlaylistButton(); showConvertPlaylistButton();
} }
updateDownloadState(); _playlist?.let {
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download);
}
} }
fun onResume() { fun onResume() {
StateDownloads.instance.onDownloadsChanged.subscribe(this) { StateDownloads.instance.onDownloadsChanged.subscribe(this) {
_fragment.lifecycleScope.launch(Dispatchers.Main) { _fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
updateDownloadState(); _playlist?.let {
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this@PlaylistView::download);
}
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to update download state onDownloadedChanged.") Logger.e(TAG, "Failed to update download state onDownloadedChanged.")
} }
@@ -217,7 +221,9 @@ class PlaylistFragment : MainFragment() {
StateDownloads.instance.onDownloadedChanged.subscribe(this) { StateDownloads.instance.onDownloadedChanged.subscribe(this) {
_fragment.lifecycleScope.launch(Dispatchers.Main) { _fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
updateDownloadState(); _playlist?.let {
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this@PlaylistView::download);
}
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to update download state onDownloadedChanged.") Logger.e(TAG, "Failed to update download state onDownloadedChanged.")
} }
@@ -225,6 +231,12 @@ class PlaylistFragment : MainFragment() {
}; };
} }
private fun download() {
_playlist?.let {
UISlideOverlays.showDownloadPlaylistOverlay(it, overlayContainer);
}
}
fun onPause() { fun onPause() {
StateDownloads.instance.onDownloadsChanged.remove(this); StateDownloads.instance.onDownloadsChanged.remove(this);
StateDownloads.instance.onDownloadedChanged.remove(this); StateDownloads.instance.onDownloadedChanged.remove(this);
@@ -268,43 +280,6 @@ class PlaylistFragment : MainFragment() {
} }
} }
private fun updateDownloadState() {
val playlist = _playlist ?: return;
val isDownloading = StateDownloads.instance.getDownloading().any { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == playlist.id };
val isDownloaded = StateDownloads.instance.isPlaylistCached(playlist.id);
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics);
if(isDownloaded && !isDownloading)
_buttonDownload.setBackgroundResource(R.drawable.background_button_round_green);
else
_buttonDownload.setBackgroundResource(R.drawable.background_button_round);
if(isDownloading) {
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
});
}
}
else if(isDownloaded) {
_buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
});
}
}
else {
_buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener {
UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
}
}
_buttonDownload.setPadding(dp10.toInt());
}
override fun canEdit(): Boolean { return _playlist != null; } override fun canEdit(): Boolean { return _playlist != null; }
@@ -35,6 +35,7 @@ import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
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.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
@@ -211,6 +212,8 @@ class PostDetailFragment : MainFragment {
_repliesOverlay = findViewById(R.id.replies_overlay); _repliesOverlay = findViewById(R.id.replies_overlay);
_textContent.setPlatformPlayerLinkMovementMethod(context);
_buttonSubscribe.onSubscribed.subscribe { _buttonSubscribe.onSubscribed.subscribe {
//TODO: add overlay to layout //TODO: add overlay to layout
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); //UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
@@ -473,6 +476,7 @@ class PostDetailFragment : MainFragment {
} }
updateCommentType(true); updateCommentType(true);
setLoading(false);
} }
fun setPostOverview(value: IPlatformPost) { fun setPostOverview(value: IPlatformPost) {
@@ -12,6 +12,7 @@ import android.webkit.CookieManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
@@ -107,17 +108,20 @@ class SourceDetailFragment : MainFragment() {
fun onHide() { fun onHide() {
val id = _config?.id ?: return; val id = _config?.id ?: return;
if(_settingsChanged && _settings != null) { var shouldReload = false;
_settingsChanged = false;
StatePlugins.instance.setPluginSettings(id, _settings!!);
reloadSource(id);
UIDialogs.toast(context.getString(R.string.plugin_settings_saved), false);
}
if(_settingsAppChanged) { if(_settingsAppChanged) {
_settingsAppForm.setObjectValues(); _settingsAppForm.setObjectValues();
StatePlugins.instance.savePlugin(id); StatePlugins.instance.savePlugin(id);
shouldReload = true;
} }
if(_settingsChanged && _settings != null) {
_settingsChanged = false;
StatePlugins.instance.setPluginSettings(id, _settings!!);
shouldReload = true;
UIDialogs.toast(context.getString(R.string.plugin_settings_saved), false);
}
if(shouldReload)
reloadSource(id);
} }
@@ -137,9 +141,25 @@ class SourceDetailFragment : MainFragment() {
//App settings //App settings
try { try {
_settingsAppForm.fromObject(source.descriptor.appSettings); _settingsAppForm.fromObject(source.descriptor.appSettings);
if(source.config.developerSubmitUrl.isNullOrEmpty()) {
val field = _settingsAppForm.findField("devSubmit");
field?.setValue(false);
if(field is View)
field.isVisible = false;
}
_settingsAppForm.onChanged.clear(); _settingsAppForm.onChanged.clear();
_settingsAppForm.onChanged.subscribe { _, _ -> _settingsAppForm.onChanged.subscribe { field, value ->
_settingsAppChanged = true; _settingsAppChanged = true;
if(field.descriptor?.id == "devSubmit") {
if(value is Boolean && value) {
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow,
"Are you sure you trust the developer?",
"Developers may gain access to sensitive data. Only enable this when you are trying to help the developer fix a bug.\nThe following domain is used:",
source.config.developerSubmitUrl ?: "", 0,
UIDialogs.Action("Cancel", { field.setValue(false); }, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Enable", { }, UIDialogs.ActionStyle.DANGEROUS));
}
}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to load app settings form from plugin settings", e) Logger.e(TAG, "Failed to load app settings form from plugin settings", e)
@@ -25,6 +25,7 @@ import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -197,6 +198,7 @@ class SubscriptionsFeedFragment : MainFragment() {
val allowContentTypes: MutableList<ContentType> = mutableListOf(ContentType.MEDIA, ContentType.POST); val allowContentTypes: MutableList<ContentType> = mutableListOf(ContentType.MEDIA, ContentType.POST);
var allowLive: Boolean = true; var allowLive: Boolean = true;
var allowPlanned: Boolean = false; var allowPlanned: Boolean = false;
var allowWatched: Boolean = true;
override fun encode(): String { override fun encode(): String {
return Json.encodeToString(this); return Json.encodeToString(this);
} }
@@ -304,7 +306,8 @@ class SubscriptionsFeedFragment : MainFragment() {
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); }, SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); }, SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); } SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
); );
} }
@@ -336,6 +339,9 @@ class SubscriptionsFeedFragment : MainFragment() {
return results.filter { return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
return@filter false;
//TODO: Check against a sub cache //TODO: Check against a sub cache
if(filterGroup != null && !filterGroup.urls.contains(it.author.url)) if(filterGroup != null && !filterGroup.urls.contains(it.author.url))
return@filter false; return@filter false;
@@ -23,6 +23,7 @@ import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.WindowManager import android.view.WindowManager
import android.webkit.WebView
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
@@ -124,6 +125,7 @@ import com.futo.platformplayer.views.overlays.LiveChatOverlay
import com.futo.platformplayer.views.overlays.QueueEditorOverlay import com.futo.platformplayer.views.overlays.QueueEditorOverlay
import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.overlays.SupportOverlay import com.futo.platformplayer.views.overlays.SupportOverlay
import com.futo.platformplayer.views.overlays.WebviewOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
@@ -244,6 +246,7 @@ class VideoDetailView : ConstraintLayout {
private val _container_content_replies: RepliesOverlay; private val _container_content_replies: RepliesOverlay;
private val _container_content_description: DescriptionOverlay; private val _container_content_description: DescriptionOverlay;
private val _container_content_liveChat: LiveChatOverlay; private val _container_content_liveChat: LiveChatOverlay;
private val _container_content_browser: WebviewOverlay;
private val _container_content_support: SupportOverlay; private val _container_content_support: SupportOverlay;
private var _container_content_current: View; private var _container_content_current: View;
@@ -349,7 +352,8 @@ class VideoDetailView : ConstraintLayout {
_container_content_replies = findViewById(R.id.videodetail_container_replies); _container_content_replies = findViewById(R.id.videodetail_container_replies);
_container_content_description = findViewById(R.id.videodetail_container_description); _container_content_description = findViewById(R.id.videodetail_container_description);
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat); _container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
_container_content_support = findViewById(R.id.videodetail_container_support) _container_content_support = findViewById(R.id.videodetail_container_support);
_container_content_browser = findViewById(R.id.videodetail_container_webview)
_textComments = findViewById(R.id.text_comments); _textComments = findViewById(R.id.text_comments);
_addCommentView = findViewById(R.id.add_comment_view); _addCommentView = findViewById(R.id.add_comment_view);
@@ -398,6 +402,10 @@ class VideoDetailView : ConstraintLayout {
} }
} }
}; };
_monetization.onUrlTap.subscribe {
fragment.navigate<BrowserFragment>(it);
onMinimize.emit();
}
_player.attachPlayer(); _player.attachPlayer();
@@ -620,6 +628,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };
_description_viewMore.setOnClickListener { _description_viewMore.setOnClickListener {
switchContentView(_container_content_description); switchContentView(_container_content_description);
@@ -640,6 +649,20 @@ class VideoDetailView : ConstraintLayout {
_container_content_current = _container_content_main; _container_content_current = _container_content_main;
_commentsList.onAuthorClick.subscribe { c ->
if (c !is PolycentricPlatformComment) {
return@subscribe;
}
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
//val navUrl = "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length);
val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
//_container_content_browser.goto(navUrl);
//switchContentView(_container_content_browser);
}
};
_commentsList.onRepliesClick.subscribe { c -> _commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0; val replyCount = c.replyCount ?: 0;
var metadata = ""; var metadata = "";
@@ -859,11 +882,11 @@ class VideoDetailView : ConstraintLayout {
private val _historyIndexLock = Mutex(false); private val _historyIndexLock = Mutex(false);
suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){ suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index? = withContext(Dispatchers.IO){
_historyIndexLock.withLock { _historyIndexLock.withLock {
val current = _historyIndex; val current = _historyIndex;
if(current == null || current.url != video.url) { if(current == null || current.url != video.url) {
val index = StateHistory.instance.getHistoryByVideo(video, true)!!; val index = StateHistory.instance.getHistoryByVideo(video, true);
_historyIndex = index; _historyIndex = index;
return@withContext index; return@withContext index;
} }
@@ -1035,10 +1058,10 @@ class VideoDetailView : ConstraintLayout {
switchContentView(_container_content_main); switchContentView(_container_content_main);
} }
fun setVideoOverview(video: IPlatformVideo, fetch: Boolean = true, resumeSeconds: Long = 0) { fun setVideoOverview(video: IPlatformVideo, fetch: Boolean = true, resumeSeconds: Long = 0, bypassSameVideoCheck: Boolean = false) {
Logger.i(TAG, "setVideoOverview") Logger.i(TAG, "setVideoOverview")
if(this.video?.url == video.url) if(!bypassSameVideoCheck && this.video?.url == video.url)
return; return;
val cachedVideo = StateDownloads.instance.getCachedVideo(video.id); val cachedVideo = StateDownloads.instance.getCachedVideo(video.id);
@@ -1390,7 +1413,7 @@ class VideoDetailView : ConstraintLayout {
if (video !is TutorialFragment.TutorialVideo) { if (video !is TutorialFragment.TutorialVideo) {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
val historyItem = getHistoryIndex(videoDetail); val historyItem = getHistoryIndex(videoDetail) ?: return@launch;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong()); _historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong());
@@ -1663,7 +1686,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "prevVideo") Logger.i(TAG, "prevVideo")
val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9); val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next != null) { if(next != null) {
setVideoOverview(next); setVideoOverview(next, true, 0, true);
} }
} }
@@ -1673,7 +1696,7 @@ class VideoDetailView : ConstraintLayout {
if(next == null && forceLoop) if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue(); next = StatePlayer.instance.restartQueue();
if(next != null) { if(next != null) {
setVideoOverview(next); setVideoOverview(next, true, 0, true);
return true; return true;
} }
else else
@@ -2208,11 +2231,11 @@ class VideoDetailView : ConstraintLayout {
videoSourceHeight = 9; videoSourceHeight = 9;
} }
val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight; val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight;
if(aspectRatio > 3) { if(aspectRatio > 2.38) {
videoSourceWidth = 16; videoSourceWidth = 16;
videoSourceHeight = 9; videoSourceHeight = 9;
} }
else if(aspectRatio < 0.3) { else if(aspectRatio < 0.43) {
videoSourceHeight = 16; videoSourceHeight = 16;
videoSourceWidth = 9; videoSourceWidth = 9;
} }
@@ -2252,7 +2275,7 @@ class VideoDetailView : ConstraintLayout {
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
if (v !is TutorialFragment.TutorialVideo) { if (v !is TutorialFragment.TutorialVideo) {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
val history = getHistoryIndex(v); val history = getHistoryIndex(v) ?: return@launch;
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
} }
} }
@@ -2562,7 +2585,7 @@ class VideoDetailView : ConstraintLayout {
} }
else else
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setVideoDetails(videoDetail, true); setVideoDetails(videoDetail, false);
_liveTryJob = null; _liveTryJob = null;
} }
} }
@@ -1,6 +1,7 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -8,10 +9,17 @@ 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.core.view.setPadding
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.assume
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.states.StateDownloads
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
abstract class VideoListEditorView : LinearLayout { abstract class VideoListEditorView : LinearLayout {
@@ -85,6 +93,44 @@ abstract class VideoListEditorView : LinearLayout {
} }
protected fun updateDownloadState(groupType: String, playlistId: String, onDownload: ()->Unit) {
//val playlist = _playlist ?: return;
val isDownloading = StateDownloads.instance.getDownloading().any { it.groupType == groupType && it.groupID == playlistId };
val isDownloaded = StateDownloads.instance.isPlaylistCached(playlistId);
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics);
if(isDownloaded && !isDownloading)
_buttonDownload.setBackgroundResource(R.drawable.background_button_round_green);
else
_buttonDownload.setBackgroundResource(R.drawable.background_button_round);
if(isDownloading) {
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlistId);
});
}
}
else if(isDownloaded) {
_buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlistId);
});
}
}
else {
_buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener {
onDownload();
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
}
}
_buttonDownload.setPadding(dp10.toInt());
}
protected fun setName(name: String?) { protected fun setName(name: String?) {
_textName.text = name ?: ""; _textName.text = name ?: "";
@@ -5,10 +5,17 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.UISlideOverlays
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.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class WatchLaterFragment : MainFragment() { class WatchLaterFragment : MainFragment() {
override val isMainView : Boolean = true; override val isMainView : Boolean = true;
@@ -28,6 +35,11 @@ class WatchLaterFragment : MainFragment() {
return view; return view;
} }
override fun onResume() {
super.onResume()
_view?.onResume();
}
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
_view = null; _view = null;
@@ -45,6 +57,34 @@ class WatchLaterFragment : MainFragment() {
fun onShown() { fun onShown() {
setName("Watch Later"); setName("Watch Later");
setVideos(StatePlaylists.instance.getWatchLater(), true); setVideos(StatePlaylists.instance.getWatchLater(), true);
setButtonDownloadVisible(true);
updateDownloadState(VideoDownload.GROUP_WATCHLATER, VideoDownload.GROUP_WATCHLATER, this@WatchLaterView::download);
}
fun onResume(){
StateDownloads.instance.onDownloadsChanged.subscribe(this) {
_fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
updateDownloadState(VideoDownload.GROUP_WATCHLATER, VideoDownload.GROUP_WATCHLATER, this@WatchLaterView::download);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update download state onDownloadedChanged.")
}
}
};
StateDownloads.instance.onDownloadedChanged.subscribe(this) {
_fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
updateDownloadState(VideoDownload.GROUP_WATCHLATER, VideoDownload.GROUP_WATCHLATER, this@WatchLaterView::download);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update download state onDownloadedChanged.")
}
}
};
}
fun download(){
UISlideOverlays.showDownloadWatchlaterOverlay(overlayContainer);
} }
override fun onPlayAllClick() { override fun onPlayAllClick() {
@@ -76,6 +116,7 @@ class WatchLaterFragment : MainFragment() {
} }
companion object { companion object {
val TAG = "WatchLaterFragment";
fun newInstance() = WatchLaterFragment().apply {} fun newInstance() = WatchLaterFragment().apply {}
} }
} }
@@ -2,11 +2,17 @@ package com.futo.platformplayer.helpers
class FileHelper { class FileHelper {
companion object { companion object {
val allowedCharacters = HashSet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-.".toCharArray().toList()); fun String.sanitizeFileName(allowSpace: Boolean = false): String {
return this.filter {
(it in '0' .. '9') ||
fun String.sanitizeFileName(): String { (it in 'a'..'z') ||
return this.filter { allowedCharacters.contains(it) }; (it in 'A'..'Z') ||
(it == '-' || it == '.' || it == '_' || (it == ' ' && allowSpace)) ||
(it in '丁'..'龤') || //Chinese/Kanji
(it in '\u3040'..'\u309f') || //Hiragana
(it in '\u30A0'..'\u30ff') || //Katakana
(it in '\u0600'..'\u06FF') //Arabic
}; //Chinese
} }
} }
} }
@@ -43,7 +43,8 @@ class FileLogConsumer : ILogConsumer, Closeable {
} }
while (_linesToWrite.isNotEmpty()) { while (_linesToWrite.isNotEmpty()) {
_writer?.appendLine(_linesToWrite.remove()); val todo = _linesToWrite.remove()
_writer?.appendLine(todo);
} }
_writer?.flush(); _writer?.flush();
@@ -85,7 +86,7 @@ class FileLogConsumer : ILogConsumer, Closeable {
_running = false; _running = false;
_writer?.close(); _writer?.close();
_writer = null; _writer = null;
_logThread?.join(); //_logThread?.join();
_logThread = null; _logThread = null;
} }
@@ -30,7 +30,7 @@ class HistoryVideo {
} }
companion object { companion object {
fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo)? = null): HistoryVideo { fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo?)? = null): HistoryVideo {
var index = str.indexOf("|||"); var index = str.indexOf("|||");
if(index < 0) throw IllegalArgumentException("Invalid history string: " + str); if(index < 0) throw IllegalArgumentException("Invalid history string: " + str);
val url = str.substring(0, index); val url = str.substring(0, index);
@@ -0,0 +1,11 @@
package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import kotlinx.serialization.Serializable
@Serializable
class ImportCache(
var videos: List<SerializedPlatformVideo>? = null,
var channels: List<SerializedChannel>? = null
);
@@ -40,6 +40,9 @@ class Subscription {
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastPostUpdate : OffsetDateTime = OffsetDateTime.MIN; var lastPostUpdate : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastPeekVideo : OffsetDateTime = OffsetDateTime.MIN;
//Last video interval //Last video interval
var uploadInterval : Int = 0; var uploadInterval : Int = 0;
var uploadStreamInterval : Int = 0; var uploadStreamInterval : Int = 0;
@@ -126,6 +129,7 @@ class Subscription {
else if(lastVideo.year > 3000) else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN; lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now(); lastVideoUpdate = OffsetDateTime.now();
lastPeekVideo = OffsetDateTime.MIN;
} }
ResultCapabilities.TYPE_MIXED -> { ResultCapabilities.TYPE_MIXED -> {
uploadInterval = interval; uploadInterval = interval;
@@ -134,6 +138,7 @@ class Subscription {
else if(lastVideo.year > 3000) else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN; lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now(); lastVideoUpdate = OffsetDateTime.now();
lastPeekVideo = OffsetDateTime.MIN;
} }
ResultCapabilities.TYPE_SUBSCRIPTIONS -> { ResultCapabilities.TYPE_SUBSCRIPTIONS -> {
uploadInterval = interval; uploadInterval = interval;
@@ -12,6 +12,7 @@ import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.timestampRegex import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking
class PlatformLinkMovementMethod : LinkMovementMethod { class PlatformLinkMovementMethod : LinkMovementMethod {
private val _context: Context; private val _context: Context;
@@ -32,33 +33,36 @@ class PlatformLinkMovementMethod : LinkMovementMethod {
val links = buffer.getSpans(off, off, URLSpan::class.java); val links = buffer.getSpans(off, off, URLSpan::class.java);
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
for (link in links) { runBlocking {
Logger.i(TAG) { "Link clicked '${link.url}'." }; for (link in links) {
Logger.i(TAG) { "Link clicked '${link.url}'." };
if (_context is MainActivity) { if (_context is MainActivity) {
if (_context.handleUrl(link.url)) { if (_context.handleUrl(link.url)) {
continue;
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue; continue;
} }
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s =
tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue;
}
}
} }
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
} }
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
} }
return true; return true;
@@ -0,0 +1,42 @@
package com.futo.platformplayer.polycentric
import com.futo.platformplayer.encryption.GEncryptionProviderV1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.polycentric.core.ProcessSecret
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import userpackage.Protocol
class PolycentricStorage {
private val _processSecrets = FragmentedStorage.get<StringArrayStorage>("processSecrets");
fun addProcessSecret(processSecret: ProcessSecret) {
_processSecrets.addDistinct(GEncryptionProviderV1.instance.encrypt(processSecret.toProto().toByteArray()).toBase64())
_processSecrets.saveBlocking()
}
fun getProcessSecrets(): List<ProcessSecret> {
val processSecrets = arrayListOf<ProcessSecret>()
for (p in _processSecrets.getAllValues()) {
try {
processSecrets.add(ProcessSecret.fromProto(Protocol.StorageTypeProcessSecret.parseFrom(GEncryptionProviderV1.instance.decrypt(p.base64ToByteArray()))))
} catch (e: Throwable) {
Logger.i(TAG, "Failed to decrypt process secret", e);
}
}
return processSecrets
}
companion object {
val TAG = "PolycentricStorage";
private var _instance : PolycentricStorage? = null;
val instance : PolycentricStorage
get(){
if(_instance == null)
_instance = PolycentricStorage();
return _instance!!;
};
}
}
@@ -4,13 +4,18 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AudioNoisyReceiver : BroadcastReceiver() { class AudioNoisyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
Logger.i(TAG, "Audio Noisy received"); StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
MediaControlReceiver.onPauseReceived.emit(); Logger.i(TAG, "Audio Noisy received");
MediaControlReceiver.onPauseReceived.emit();
}
} }
companion object { companion object {
@@ -37,6 +37,7 @@ class DownloadService : Service() {
private val DOWNLOAD_NOTIF_ID = 3; private val DOWNLOAD_NOTIF_ID = 3;
private val DOWNLOAD_NOTIF_TAG = "download"; private val DOWNLOAD_NOTIF_TAG = "download";
private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel"; private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel";
private val DOWNLOAD_NOTIF_CHANNEL_NAME = "Downloads";
//Context //Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
@@ -95,7 +96,7 @@ class DownloadService : Service() {
} }
fun setupNotificationRequirements() { fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply { _notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, DOWNLOAD_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false); this.enableVibration(false);
this.setSound(null, null); this.setSound(null, null);
}; };
@@ -269,7 +270,7 @@ class DownloadService : Service() {
fun closeDownloadSession() { fun closeDownloadSession() {
Logger.i(TAG, "closeDownloadSession"); Logger.i(TAG, "closeDownloadSession");
stopForeground(STOP_FOREGROUND_DETACH); stopForeground(STOP_FOREGROUND_REMOVE);
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID); _notificationManager?.cancel(DOWNLOAD_NOTIF_ID);
stopService(); stopService();
_started = false; _started = false;
@@ -36,6 +36,7 @@ class ExportingService : Service() {
private val EXPORT_NOTIF_ID = 4; private val EXPORT_NOTIF_ID = 4;
private val EXPORT_NOTIF_TAG = "export"; private val EXPORT_NOTIF_TAG = "export";
private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel"; private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel";
private val EXPORT_NOTIF_CHANNEL_NAME = "Export";
//Context //Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
@@ -88,7 +89,7 @@ class ExportingService : Service() {
} }
fun setupNotificationRequirements() { fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply { _notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, EXPORT_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false); this.enableVibration(false);
this.setSound(null, null); this.setSound(null, null);
}; };
@@ -187,7 +188,7 @@ class ExportingService : Service() {
fun closeExportSession() { fun closeExportSession() {
Logger.i(TAG, "closeExportSession"); Logger.i(TAG, "closeExportSession");
stopForeground(STOP_FOREGROUND_DETACH); stopForeground(STOP_FOREGROUND_REMOVE);
_notificationManager?.cancel(EXPORT_NOTIF_ID); _notificationManager?.cancel(EXPORT_NOTIF_ID);
stopService(); stopService();
_started = false; _started = false;
@@ -13,6 +13,7 @@ import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -53,6 +54,9 @@ import kotlin.system.measureTimeMillis
class StateApp { class StateApp {
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
val sessionId = UUID.randomUUID().toString();
fun getExternalGeneralDirectory(context: Context): DocumentFile? { fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri(); val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri)) if(isValidStorageUri(context, generalUri))
@@ -329,7 +333,7 @@ class StateApp {
suspend fun backgroundStarting(context: Context, scope: CoroutineScope, withFiles: Boolean, withPlugins: Boolean) { suspend fun backgroundStarting(context: Context, scope: CoroutineScope, withFiles: Boolean, withPlugins: Boolean) {
if(contextOrNull == null) { if(contextOrNull == null) {
Logger.i(TAG, "BACKGROUND STATE: Starting"); Logger.i(TAG, "BACKGROUND STATE: Starting");
if(!Logger.hasConsumers && BuildConfig.DEBUG) { if(!Logger.hasConsumers && (BuildConfig.DEBUG)) {
Logger.i(TAG, "BACKGROUND STATE: Initialize logger"); Logger.i(TAG, "BACKGROUND STATE: Initialize logger");
Logger.setLogConsumers(listOf(AndroidLogConsumer())); Logger.setLogConsumers(listOf(AndroidLogConsumer()));
} }
@@ -473,7 +477,11 @@ class StateApp {
Logger.i(TAG, "MainApp Started: Initialize [Noisy]"); Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
_receiverBecomingNoisy?.let { _receiverBecomingNoisy?.let {
_receiverBecomingNoisy = null; _receiverBecomingNoisy = null;
context.unregisterReceiver(it); try {
context.unregisterReceiver(it);
} catch (e: Throwable) {
Log.e(TAG, "Failed to unregister receiver.", e)
}
} }
_receiverBecomingNoisy = AudioNoisyReceiver(); _receiverBecomingNoisy = AudioNoisyReceiver();
context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
@@ -639,7 +647,11 @@ class StateApp {
Logger.i(TAG, "App ended"); Logger.i(TAG, "App ended");
_receiverBecomingNoisy?.let { _receiverBecomingNoisy?.let {
_receiverBecomingNoisy = null; _receiverBecomingNoisy = null;
context.unregisterReceiver(it); try {
context.unregisterReceiver(it);
} catch (e: Throwable) {
Log.e(TAG, "Failed to unregister receiver.", e)
}
} }
Logger.i(TAG, "Unregistered network callback on connectivityManager.") Logger.i(TAG, "Unregistered network callback on connectivityManager.")
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo import com.futo.platformplayer.copyTo
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
@@ -17,6 +18,7 @@ import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.getNowDiffHours import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.readBytes import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
@@ -58,6 +60,19 @@ class StateBackup {
StatePlaylists.instance.toMigrateCheck() StatePlaylists.instance.toMigrateCheck()
).flatten(); ).flatten();
fun getCache(): ImportCache {
val allPlaylists = StatePlaylists.instance.getPlaylists();
val videos = allPlaylists.flatMap { it.videos }.distinctBy { it.url };
val allSubscriptions = StateSubscriptions.instance.getSubscriptions();
val channels = allSubscriptions.map { it.channel };
return ImportCache(
videos = videos,
channels = channels
);
}
private fun getAutomaticBackupPassword(customPassword: String? = null): String { private fun getAutomaticBackupPassword(customPassword: String? = null): String {
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: ""; val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: "";
@@ -233,11 +248,10 @@ class StateBackup {
.associateBy { it.config.id } .associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! }; .mapValues { it.value.config.sourceUrl!! };
val cache = getCache();
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings, cache);
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
//export.videoCache = StatePlaylists.instance.getHistory()
// .distinctBy { it.video.url }
// .map { it.video };
return export; return export;
} }
@@ -324,7 +338,7 @@ class StateBackup {
continue; continue;
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value) { UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) { synchronized(toAwait) {
toAwait.remove(store.key); toAwait.remove(store.key);
if(toAwait.isEmpty()) if(toAwait.isEmpty())
@@ -453,8 +467,8 @@ class StateBackup {
val stores: Map<String, List<String>>, val stores: Map<String, List<String>>,
val plugins: Map<String, String>, val plugins: Map<String, String>,
val pluginSettings: Map<String, Map<String, String?>>, val pluginSettings: Map<String, Map<String, String?>>,
var cache: ImportCache? = null
) { ) {
var videoCache: List<SerializedPlatformVideo>? = null;
fun asZip(): ByteArray { fun asZip(): ByteArray {
return ByteArrayOutputStream().use { byteStream -> return ByteArrayOutputStream().use { byteStream ->
@@ -478,6 +492,17 @@ class StateBackup {
zipStream.putNextEntry(ZipEntry("plugin_settings")); zipStream.putNextEntry(ZipEntry("plugin_settings"));
zipStream.write(Json.encodeToString(pluginSettings).toByteArray()); zipStream.write(Json.encodeToString(pluginSettings).toByteArray());
if(cache != null) {
if(cache?.videos != null) {
zipStream.putNextEntry(ZipEntry("cache_videos"));
zipStream.write(Json.encodeToString(cache!!.videos).toByteArray());
}
if(cache?.channels != null) {
zipStream.putNextEntry(ZipEntry("cache_channels"));
zipStream.write(Json.encodeToString(cache!!.channels).toByteArray());
}
}
}; };
return byteStream.toByteArray(); return byteStream.toByteArray();
} }
@@ -492,6 +517,8 @@ class StateBackup {
val stores: MutableMap<String, List<String>> = mutableMapOf(); val stores: MutableMap<String, List<String>> = mutableMapOf();
var plugins: Map<String, String> = mapOf(); var plugins: Map<String, String> = mapOf();
var pluginSettings: Map<String, Map<String, String?>> = mapOf(); var pluginSettings: Map<String, Map<String, String?>> = mapOf();
var videoCache: List<SerializedPlatformVideo>? = null
var channelCache: List<SerializedChannel>? = null
while (zipStream.nextEntry.also { entry = it } != null) { while (zipStream.nextEntry.also { entry = it } != null) {
if(entry!!.isDirectory) if(entry!!.isDirectory)
@@ -503,6 +530,22 @@ class StateBackup {
"settings" -> settings = String(zipStream.readBytes()); "settings" -> settings = String(zipStream.readBytes());
"plugins" -> plugins = Json.decodeFromString(String(zipStream.readBytes())); "plugins" -> plugins = Json.decodeFromString(String(zipStream.readBytes()));
"plugin_settings" -> pluginSettings = Json.decodeFromString(String(zipStream.readBytes())); "plugin_settings" -> pluginSettings = Json.decodeFromString(String(zipStream.readBytes()));
"cache_videos" -> {
try {
videoCache = Json.decodeFromString(String(zipStream.readBytes()));
}
catch(ex: Exception) {
Logger.e(TAG, "Couldn't deserialize video cache", ex);
}
};
"cache_channels" -> {
try {
channelCache = Json.decodeFromString(String(zipStream.readBytes()));
}
catch(ex: Exception) {
Logger.e(TAG, "Couldn't deserialize channel cache", ex);
}
};
} }
else else
stores[entry!!.name.substring("stores/".length)] = Json.decodeFromString(String(zipStream.readBytes())); stores[entry!!.name.substring("stores/".length)] = Json.decodeFromString(String(zipStream.readBytes()));
@@ -511,7 +554,10 @@ class StateBackup {
throw IllegalStateException("Failed to parse zip [${entry?.name}] due to ${ex.message}"); throw IllegalStateException("Failed to parse zip [${entry?.name}] due to ${ex.message}");
} }
} }
return ExportStructure(exportInfo, settings, stores, plugins, pluginSettings); return ExportStructure(exportInfo, settings, stores, plugins, pluginSettings, ImportCache(
videos = videoCache,
channels = channelCache
));
} }
} }
} }
@@ -97,6 +97,9 @@ class StateDownloads {
} }
} }
fun getWatchLaterDescriptor(): PlaylistDownloadDescriptor? {
return _downloadPlaylists.getItems().find { it.id == VideoDownload.GROUP_WATCHLATER };
}
fun getCachedPlaylists(): List<PlaylistDownloaded> { fun getCachedPlaylists(): List<PlaylistDownloaded> {
return _downloadPlaylists.getItems() return _downloadPlaylists.getItems()
.map { Pair(it, StatePlaylists.instance.getPlaylist(it.id)) } .map { Pair(it, StatePlaylists.instance.getPlaylist(it.id)) }
@@ -124,19 +127,32 @@ class StateDownloads {
val pdl = getPlaylistDownload(id); val pdl = getPlaylistDownload(id);
if(pdl != null) if(pdl != null)
_downloadPlaylists.delete(pdl); _downloadPlaylists.delete(pdl);
getDownloading().filter { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == id } if(id == VideoDownload.GROUP_WATCHLATER) {
.forEach { removeDownload(it) }; getDownloading().filter { it.groupType == VideoDownload.GROUP_WATCHLATER && it.groupID == id }
getDownloadedVideos().filter { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == id } .forEach { removeDownload(it) };
.forEach { deleteCachedVideo(it.id) }; getDownloadedVideos().filter { it.groupType == VideoDownload.GROUP_WATCHLATER && it.groupID == id }
.forEach { deleteCachedVideo(it.id) };
}
else {
getDownloading().filter { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == id }
.forEach { removeDownload(it) };
getDownloadedVideos().filter { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == id }
.forEach { deleteCachedVideo(it.id) };
}
} }
fun getDownloadedVideos(): List<VideoLocal> { fun getDownloadedVideos(): List<VideoLocal> {
return _downloaded.getItems(); return _downloaded.getItems();
} }
fun getDownloadedVideosPlaylist(str: String): List<VideoLocal> {
val videos = _downloaded.findItems { it.groupID == str };
return videos;
}
fun getDownloadPlaylists(): List<PlaylistDownloadDescriptor> { fun getDownloadPlaylists(): List<PlaylistDownloadDescriptor> {
return _downloadPlaylists.getItems(); return _downloadPlaylists.getItems();
} }
fun isPlaylistCached(id: String): Boolean { fun isPlaylistCached(id: String): Boolean {
return getDownloadPlaylists().any{it.id == id}; return getDownloadPlaylists().any{it.id == id};
} }
@@ -177,6 +193,21 @@ class StateDownloads {
DownloadService.getOrCreateService(it); DownloadService.getOrCreateService(it);
} }
} }
fun checkForOutdatedPlaylistVideos(playlistId: String) {
val playlistVideos = if(playlistId == VideoDownload.GROUP_WATCHLATER)
(if(getWatchLaterDescriptor() != null) StatePlaylists.instance.getWatchLater() else listOf())
else
getCachedPlaylist(playlistId)?.playlist?.videos ?: return;
val playlistVideosDownloaded = getDownloadedVideosPlaylist(playlistId);
val urls = playlistVideos.map { it.url }.toHashSet();
for(item in playlistVideosDownloaded) {
if(!urls.contains(item.url)) {
Logger.i(TAG, "Playlist [${playlistId}] deleting removed video [${item.name}]");
deleteCachedVideo(item.id);
}
}
}
fun checkForOutdatedPlaylists(): Boolean { fun checkForOutdatedPlaylists(): Boolean {
var hasChanged = false; var hasChanged = false;
val playlistsDownloaded = getCachedPlaylists(); val playlistsDownloaded = getCachedPlaylists();
@@ -192,9 +223,59 @@ class StateDownloads {
else else
Logger.v(TAG, "Offline playlist [${playlist.playlist.name}] is up to date"); Logger.v(TAG, "Offline playlist [${playlist.playlist.name}] is up to date");
} }
val downloadWatchLater = getWatchLaterDescriptor();
if(downloadWatchLater != null) {
continueDownloadWatchLater(downloadWatchLater);
}
return hasChanged; return hasChanged;
} }
fun continueDownloadWatchLater(playlistDownload: PlaylistDownloadDescriptor) {
var hasNew = false;
val watchLater = StatePlaylists.instance.getWatchLater();
for(item in watchLater) {
val existing = getCachedVideo(item.id);
if(!playlistDownload.shouldDownload(item)) {
Logger.i(TAG, "Not downloading for watchlater [${playlistDownload.id}] Video [${item.name}]:${item.url}")
continue;
}
if(existing == null) {
val ongoingDownload = getDownloading().find { it.id.value == item.id.value && it.id.value != null };
if(ongoingDownload != null) {
Logger.i(TAG, "New watchlater video (already downloading) ${item.name}");
ongoingDownload.groupID = VideoDownload.GROUP_WATCHLATER;
ongoingDownload.groupType = VideoDownload.GROUP_WATCHLATER;
}
else {
Logger.i(TAG, "New watchlater video ${item.name}");
download(VideoDownload(item, playlistDownload.targetPxCount, playlistDownload.targetBitrate)
.withGroup(VideoDownload.GROUP_WATCHLATER, VideoDownload.GROUP_WATCHLATER), false);
hasNew = true;
}
}
else {
Logger.i(TAG, "New watchlater video (already downloaded) ${item.name}");
if(existing.groupID == null) {
existing.groupID = VideoDownload.GROUP_WATCHLATER;
existing.groupType = VideoDownload.GROUP_WATCHLATER;
synchronized(_downloadedSet) {
_downloadedSet.add(existing.id);
}
_downloaded.save(existing);
}
}
}
if(watchLater.isNotEmpty() && Settings.instance.downloads.shouldDownload()) {
if(hasNew) {
UIDialogs.toast("Downloading [Watch Later]")
StateApp.withContext {
DownloadService.getOrCreateService(it);
}
}
onDownloadsChanged.emit();
}
}
fun continueDownload(playlistDownload: PlaylistDownloadDescriptor, playlist: Playlist) { fun continueDownload(playlistDownload: PlaylistDownloadDescriptor, playlist: Playlist) {
var hasNew = false; var hasNew = false;
for(item in playlist.videos) { for(item in playlist.videos) {
@@ -240,6 +321,11 @@ class StateDownloads {
onDownloadsChanged.emit(); onDownloadsChanged.emit();
} }
} }
fun downloadWatchLater(targetPixelCount: Long?, targetBitrate: Long?) {
val playlistDownload = PlaylistDownloadDescriptor(VideoDownload.GROUP_WATCHLATER, targetPixelCount, targetBitrate);
_downloadPlaylists.save(playlistDownload);
continueDownloadWatchLater(playlistDownload);
}
fun download(playlist: Playlist, targetPixelcount: Long?, targetBitrate: Long?) { fun download(playlist: Playlist, targetPixelcount: Long?, targetBitrate: Long?) {
val playlistDownload = PlaylistDownloadDescriptor(playlist.id, targetPixelcount, targetBitrate); val playlistDownload = PlaylistDownloadDescriptor(playlist.id, targetPixelcount, targetBitrate);
_downloadPlaylists.save(playlistDownload); _downloadPlaylists.save(playlistDownload);
@@ -1,11 +1,13 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import com.futo.platformplayer.UIDialogs
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.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.db.types.DBHistory
@@ -19,8 +21,8 @@ class StateHistory {
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history") private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
.withRestore(object: ReconstructStore<HistoryVideo>() { .withRestore(object: ReconstructStore<HistoryVideo>() {
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString(); override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, cache: ImportCache?): HistoryVideo
= HistoryVideo.fromReconString(backup, null); = HistoryVideo.fromReconString(backup) { url -> cache?.videos?.find { it.url == url } };
}) })
.load(); .load();
@@ -49,6 +51,9 @@ class StateHistory {
fun getHistoryPosition(url: String): Long { fun getHistoryPosition(url: String): Long {
return historyIndex[url]?.position ?: 0; return historyIndex[url]?.position ?: 0;
} }
fun isHistoryWatched(url: String, duration: Long): Boolean {
return getHistoryPosition(url) > duration * 0.7;
}
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long { fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long {
@@ -92,14 +97,20 @@ class StateHistory {
} }
fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? { fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? {
val existing = historyIndex[video.url]; val existing = historyIndex[video.url];
if(existing != null) var result: DBHistory.Index? = null;
return _historyDBStore.get(existing.id!!); if(existing != null) {
result = _historyDBStore.getOrNull(existing.id!!);
if(result == null)
UIDialogs.toast("History item null?\nNo history tracking..");
}
else if(create) { else if(create) {
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now()); val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now());
val id = _historyDBStore.insert(newHistItem); val id = _historyDBStore.insert(newHistItem);
return _historyDBStore.get(id); result = _historyDBStore.getOrNull(id);
if(result == null)
UIDialogs.toast("History creation failed?\nNo history tracking..");
} }
return null; return result;
} }
fun removeHistory(url: String) { fun removeHistory(url: String) {
@@ -46,6 +46,7 @@ import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -166,8 +167,13 @@ class StatePlatform {
var enabled: Array<String>; var enabled: Array<String>;
synchronized(_clientsLock) { synchronized(_clientsLock) {
for(e in _enabledClients) { for(e in _enabledClients) {
e.disable(); try {
onSourceDisabled.emit(e); e.disable();
onSourceDisabled.emit(e);
}
catch(ex: Throwable) {
UIDialogs.appToast(ToastView.Toast("If this happens often, please inform the developers on Github", false, null, "Plugin [${e.name}] failed to disable"));
}
} }
_enabledClients.clear(); _enabledClients.clear();
@@ -523,12 +529,23 @@ class StatePlatform {
} }
fun getCommonSearchCapabilities(clientIds: List<String>): ResultCapabilities? { fun getCommonSearchCapabilities(clientIds: List<String>): ResultCapabilities? {
return getCommonSearchCapabilitiesType(clientIds){
it.getSearchCapabilities()
};
}
fun getCommonSearchChannelContentsCapabilities(clientIds: List<String>): ResultCapabilities? {
return getCommonSearchCapabilitiesType(clientIds){
it.getSearchChannelContentsCapabilities()
};
}
fun getCommonSearchCapabilitiesType(clientIds: List<String>, capabilitiesGetter: (client: IPlatformClient)-> ResultCapabilities): ResultCapabilities? {
try { try {
Logger.i(TAG, "Platform - getCommonSearchCapabilities"); Logger.i(TAG, "Platform - getCommonSearchCapabilities");
val clients = getEnabledClients().filter { clientIds.contains(it.id) }; val clients = getEnabledClients().filter { clientIds.contains(it.id) };
val c = clients.firstOrNull() ?: return null; val c = clients.firstOrNull() ?: return null;
val cap = c.getSearchCapabilities(); val cap = capabilitiesGetter(c)//c.getSearchCapabilities();
//var types = arrayListOf<String>(); //var types = arrayListOf<String>();
var sorts = cap.sorts.toMutableList(); var sorts = cap.sorts.toMutableList();
@@ -538,7 +555,7 @@ class StatePlatform {
val filtersToRemove = arrayListOf<Int>(); val filtersToRemove = arrayListOf<Int>();
for (i in 1 until clients.size) { for (i in 1 until clients.size) {
val clientSearchCapabilities = clients[i].getSearchCapabilities(); val clientSearchCapabilities = capabilitiesGetter(clients[i]);//.getSearchCapabilities();
for (j in 0 until sorts.size) { for (j in 0 until sorts.size) {
if (!clientSearchCapabilities.sorts.contains(sorts[j])) { if (!clientSearchCapabilities.sorts.contains(sorts[j])) {
@@ -659,8 +676,11 @@ class StatePlatform {
val pagerResult: IPager<IPlatformContent>; val pagerResult: IPager<IPlatformContent>;
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) && ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) { clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
)) {
val toQuery = mutableListOf<String>(); val toQuery = mutableListOf<String>();
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
toQuery.add(ResultCapabilities.TYPE_VIDEOS); toQuery.add(ResultCapabilities.TYPE_VIDEOS);
@@ -780,6 +800,10 @@ class StatePlatform {
return client.getChannelContents(channelUrl, type, ordering) ; return client.getChannelContents(channelUrl, type, ordering) ;
} }
fun peekChannelContents(baseClient: IPlatformClient, channelUrl: String, type: String?): List<IPlatformContent> {
val client = _channelClientPool.getClientPooled(baseClient, Settings.instance.subscriptions.getSubscriptionsConcurrency());
return client.peekChannelContents(channelUrl, type) ;
}
fun getChannelLive(url: String, updateSubscriptions: Boolean = true): IPlatformChannel { fun getChannelLive(url: String, updateSubscriptions: Boolean = true): IPlatformChannel {
val channel = getChannelClient(url).getChannel(url); val channel = getChannelClient(url).getChannel(url);
@@ -14,6 +14,7 @@ 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.constructs.Event0 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.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
@@ -633,6 +634,7 @@ class StatePlayer {
val instance = _instance; val instance = _instance;
_instance = null; _instance = null;
instance?.dispose(); instance?.dispose();
Logger.i(TAG, "Disposed StatePlayer");
} }
} }
} }
@@ -11,9 +11,11 @@ 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
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.ReconstructionException import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
@@ -32,8 +34,10 @@ class StatePlaylists {
.withUnique { it.url } .withUnique { it.url }
.withRestore(object: ReconstructStore<SerializedPlatformVideo>() { .withRestore(object: ReconstructStore<SerializedPlatformVideo>() {
override fun toReconstruction(obj: SerializedPlatformVideo): String = obj.url; override fun toReconstruction(obj: SerializedPlatformVideo): String = obj.url;
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): SerializedPlatformVideo override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): SerializedPlatformVideo
= SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails); = SerializedPlatformVideo.fromVideo(
importCache?.videos?.find { it.url == backup }?.let { Logger.i(TAG, "Reconstruction [${backup}] from cache"); return@let it; } ?:
StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails);
}) })
.load(); .load();
private val _watchlistOrderStore = FragmentedStorage.get<StringArrayStorage>("watchListOrder"); //Temporary workaround to add order.. private val _watchlistOrderStore = FragmentedStorage.get<StringArrayStorage>("watchListOrder"); //Temporary workaround to add order..
@@ -63,6 +67,10 @@ class StatePlaylists {
_watchlistOrderStore.save(); _watchlistOrderStore.save();
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
}
} }
fun removeFromWatchLater(video: SerializedPlatformVideo) { fun removeFromWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
@@ -71,6 +79,10 @@ class StatePlaylists {
_watchlistOrderStore.save(); _watchlistOrderStore.save();
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
}
} }
fun addToWatchLater(video: SerializedPlatformVideo) { fun addToWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
@@ -79,6 +91,8 @@ class StatePlaylists {
_watchlistOrderStore.save(); _watchlistOrderStore.save();
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
StateDownloads.instance.checkForOutdatedPlaylists();
} }
fun getLastPlayedPlaylist() : Playlist? { fun getLastPlayedPlaylist() : Playlist? {
@@ -128,6 +142,11 @@ class StatePlaylists {
fun createOrUpdatePlaylist(playlist: Playlist) { fun createOrUpdatePlaylist(playlist: Playlist) {
playlist.dateUpdate = OffsetDateTime.now(); playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true); playlistStore.saveAsync(playlist, true);
if(playlist.id.isNotEmpty()) {
if (StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(playlist.id);
}
}
} }
fun addToPlaylist(id: String, video: IPlatformVideo) { fun addToPlaylist(id: String, video: IPlatformVideo) {
synchronized(playlistStore) { synchronized(playlistStore) {
@@ -140,6 +159,9 @@ class StatePlaylists {
fun removePlaylist(playlist: Playlist) { fun removePlaylist(playlist: Playlist) {
playlistStore.delete(playlist); playlistStore.delete(playlist);
if(StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
}
} }
fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri { fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri {
@@ -154,7 +176,11 @@ class StatePlaylists {
val reconstruction = playlistStore.getReconstructionString(playlist, true); val reconstruction = playlistStore.getReconstructionString(playlist, true);
val newFile = File(playlistShareDir, playlist.name + ".json"); val newFile = File(playlistShareDir, playlist.name + ".json");
newFile.writeText(Json.encodeToString(reconstruction.split("\n")), Charsets.UTF_8); newFile.writeText(Json.encodeToString(reconstruction.split("\n") + listOf(
"__CACHE:" + Json.encodeToString(ImportCache(
videos = playlist.videos.toList()
))
)), Charsets.UTF_8);
return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile); return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile);
} }
@@ -185,7 +211,7 @@ class StatePlaylists {
items.addAll(obj.videos.map { it.url }); items.addAll(obj.videos.map { it.url });
return items.map { it.replace("\n","") }.joinToString("\n"); return items.map { it.replace("\n","") }.joinToString("\n");
} }
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Playlist { override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): Playlist {
val items = backup.split("\n"); val items = backup.split("\n");
if(items.size <= 0) { if(items.size <= 0) {
throw IllegalStateException("Cannot reconstructor playlist ${id}"); throw IllegalStateException("Cannot reconstructor playlist ${id}");
@@ -194,10 +220,17 @@ class StatePlaylists {
val name = items[0]; val name = items[0];
val videos = items.drop(1).filter { it.isNotEmpty() }.map { val videos = items.drop(1).filter { it.isNotEmpty() }.map {
try { try {
val video = StatePlatform.instance.getContentDetails(it).await(); val videoUrl = it;
val video = importCache?.videos?.find { it.url == videoUrl } ?:
StatePlatform.instance.getContentDetails(it).await();
if (video is IPlatformVideoDetails) { if (video is IPlatformVideoDetails) {
return@map SerializedPlatformVideo.fromVideo(video); return@map SerializedPlatformVideo.fromVideo(video);
} else { }
else if(video is SerializedPlatformVideo) {
Logger.i(TAG, "Reconstruction [${it}] from cache");
return@map video;
}
else {
return@map null return@map null
} }
} }
@@ -134,8 +134,11 @@ class StatePlugins {
val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value); val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value);
if(embeddedConfig != null) { if(embeddedConfig != null) {
val existing = getPlugin(embedded.key); val existing = getPlugin(embedded.key);
if(existing != null && (existing.config.version < embeddedConfig.version || (force || FORCE_REINSTALL_EMBEDDED))) { if(existing == null || (existing.config.version < embeddedConfig.version || (force || FORCE_REINSTALL_EMBEDDED))) {
Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig.version}), reinstalling"); if (existing != null)
Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig.version}), reinstalling");
else
Logger.i(TAG, "Embedded plugin nog installed [${embeddedConfig.id}] ${embeddedConfig.name} (${embeddedConfig.version}), installing");
installEmbeddedPlugin(context, embedded.value) installEmbeddedPlugin(context, embedded.value)
} }
else if(existing != null && _isFirstEmbedUpdate) { else if(existing != null && _isFirstEmbedUpdate) {
@@ -23,6 +23,7 @@ import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -67,28 +68,40 @@ class StatePolycentric {
return return
} }
try { for (i in 0 .. 1) {
val db = SqlLiteDbHelper(context); try {
Store.initializeSqlLiteStore(db); val db = SqlLiteDbHelper(context);
Store.initializeSqlLiteStore(db);
val activeProcessHandleString = _activeProcessHandle.value; val activeProcessHandleString = _activeProcessHandle.value;
if (activeProcessHandleString.isNotEmpty()) { if (activeProcessHandleString.isNotEmpty()) {
try { try {
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray())); val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle()); setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
} catch (e: Throwable) { } catch (e: Throwable) {
db.upgradeOldSecrets(db.writableDatabase); db.upgradeOldSecrets(db.writableDatabase);
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray())); val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle()); setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
Log.i(TAG, "Failed to initialize Polycentric.", e)
}
}
getProcessHandles()
break;
} catch (e: Throwable) {
if (i == 0) {
Logger.i(TAG, "Clearing Polycentric database due to corruption");
val db = SqlLiteDbHelper(context);
db.recreate()
} else {
_transientEnabled = false
UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
Log.i(TAG, "Failed to initialize Polycentric.", e) Log.i(TAG, "Failed to initialize Polycentric.", e)
} }
} }
} catch (e: Throwable) {
_transientEnabled = false
UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
Log.i(TAG, "Failed to initialize Polycentric.", e)
} }
} }
@@ -103,7 +116,32 @@ class StatePolycentric {
return listOf() return listOf()
} }
return Store.instance.getProcessSecrets().map { it.toProcessHandle(); }; val storeProcessSecrets = Store.instance.getProcessSecrets().toMutableList()
val processSecrets = PolycentricStorage.instance.getProcessSecrets()
for (processSecret in processSecrets)
{
if (!storeProcessSecrets.contains(processSecret)) {
try {
Store.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill process secret.")
}
}
}
for (processSecret in storeProcessSecrets)
{
if (!processSecrets.contains(processSecret)) {
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill process secret.")
}
}
}
return (storeProcessSecrets + processSecrets).distinct().map { it.toProcessHandle() }
} }
fun setProcessHandle(processHandle: ProcessHandle?) { fun setProcessHandle(processHandle: ProcessHandle?) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.functional.CentralizedFeed import com.futo.platformplayer.functional.CentralizedFeed
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
@@ -38,8 +39,8 @@ class StateSubscriptions {
.withRestore(object: ReconstructStore<Subscription>(){ .withRestore(object: ReconstructStore<Subscription>(){
override fun toReconstruction(obj: Subscription): String = override fun toReconstruction(obj: Subscription): String =
obj.channel.url; obj.channel.url;
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Subscription = override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): Subscription =
Subscription(SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false))); Subscription(importCache?.channels?.find { it.isSameUrl(backup) } ?: SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false)));
}).load(); }).load();
private val _subscriptionOthers = FragmentedStorage.storeJson<Subscription>("subscriptions_others") private val _subscriptionOthers = FragmentedStorage.storeJson<Subscription>("subscriptions_others")
.withUnique { it.channel.url } .withUnique { it.channel.url }
@@ -155,7 +155,7 @@ class StateUpdate {
} }
} }
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean) = withContext(Dispatchers.IO) { suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try { try {
val client = ManagedHttpClient(); val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client); val latestVersion = downloadVersionCode(client);
@@ -167,7 +167,7 @@ class StateUpdate {
if (latestVersion > currentVersion) { if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
try { try {
UIDialogs.showUpdateAvailableDialog(context, latestVersion); UIDialogs.showUpdateAvailableDialog(context, latestVersion, hideExceptionButtons);
} catch (e: Throwable) { } catch (e: Throwable) {
UIDialogs.toast(context, "Failed to show update dialog"); UIDialogs.toast(context, "Failed to show update dialog");
Logger.w(TAG, "Error occurred in update dialog."); Logger.w(TAG, "Error occurred in update dialog.");
@@ -12,6 +12,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.JsonStoreSerializer
import com.futo.platformplayer.stores.v2.StoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import java.lang.IllegalArgumentException
import java.lang.reflect.Field import java.lang.reflect.Field
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap import java.util.concurrent.ConcurrentMap
@@ -209,7 +210,9 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
fun getObject(id: Long) = get(id).obj!!; fun getObject(id: Long) = get(id).obj!!;
fun get(id: Long): I { fun get(id: Long): I {
return deserializeIndex(dbDaoBase.get(_sqlGet(id))); val result = dbDaoBase.getNullable(_sqlGet(id))
?: throw IllegalArgumentException("DB [${name}] has no entry with id ${id}");
return deserializeIndex(result);
} }
fun getOrNull(id: Long): I? { fun getOrNull(id: Long): I? {
val result = dbDaoBase.getNullable(_sqlGet(id)); val result = dbDaoBase.getNullable(_sqlGet(id));
@@ -2,6 +2,7 @@ package com.futo.platformplayer.stores.v2
import com.futo.platformplayer.assume import com.futo.platformplayer.assume
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -105,7 +106,7 @@ class ManagedStore<T>{
_toReconstruct.clear(); _toReconstruct.clear();
} }
} }
suspend fun importReconstructions(items: List<String>, onProgress: ((Int, Int)->Unit)? = null): ReconstructionResult { suspend fun importReconstructions(items: List<String>, cache: ImportCache? = null, onProgress: ((Int, Int)->Unit)? = null): ReconstructionResult {
var successes = 0; var successes = 0;
val exs = ArrayList<Throwable>(); val exs = ArrayList<Throwable>();
@@ -120,7 +121,7 @@ class ManagedStore<T>{
for (i in 0 .. 1) { for (i in 0 .. 1) {
try { try {
Logger.i(TAG, "Importing ${logName(recon)}"); Logger.i(TAG, "Importing ${logName(recon)}");
val reconId = createFromReconstruction(recon, builder); val reconId = createFromReconstruction(recon, builder, cache);
successes++; successes++;
Logger.i(TAG, "Imported ${logName(reconId)}"); Logger.i(TAG, "Imported ${logName(reconId)}");
break; break;
@@ -272,12 +273,12 @@ class ManagedStore<T>{
save(obj, withReconstruction, onlyExisting); save(obj, withReconstruction, onlyExisting);
} }
suspend fun createFromReconstruction(reconstruction: String, builder: ReconstructStore.Builder): String { suspend fun createFromReconstruction(reconstruction: String, builder: ReconstructStore.Builder, cache: ImportCache? = null): String {
if(_reconstructStore == null) if(_reconstructStore == null)
throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type"); throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type");
val id = UUID.randomUUID().toString(); val id = UUID.randomUUID().toString();
val reconstruct = _reconstructStore!!.toObjectWithHeader(id, reconstruction, builder); val reconstruct = _reconstructStore!!.toObjectWithHeader(id, reconstruction, builder, cache);
save(reconstruct); save(reconstruct);
return id; return id;
} }
@@ -1,5 +1,7 @@
package com.futo.platformplayer.stores.v2 package com.futo.platformplayer.stores.v2
import com.futo.platformplayer.models.ImportCache
abstract class ReconstructStore<T> { abstract class ReconstructStore<T> {
open val backupOnSave: Boolean = false; open val backupOnSave: Boolean = false;
open val backupOnCreate: Boolean = true; open val backupOnCreate: Boolean = true;
@@ -11,18 +13,18 @@ abstract class ReconstructStore<T> {
} }
abstract fun toReconstruction(obj: T): String; abstract fun toReconstruction(obj: T): String;
abstract suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): T; abstract suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache? = null): T;
fun toReconstructionWithHeader(obj: T, fallbackName: String): String { fun toReconstructionWithHeader(obj: T, fallbackName: String): String {
val identifier = identifierName ?: fallbackName; val identifier = identifierName ?: fallbackName;
return "@/${identifier}\n${toReconstruction(obj)}"; return "@/${identifier}\n${toReconstruction(obj)}";
} }
suspend fun toObjectWithHeader(id: String, backup: String, builder: Builder): T { suspend fun toObjectWithHeader(id: String, backup: String, builder: Builder, importCache: ImportCache? = null): T {
if(backup.startsWith("@/") && backup.contains("\n")) if(backup.startsWith("@/") && backup.contains("\n"))
return toObject(id, backup.substring(backup.indexOf("\n") + 1), builder); return toObject(id, backup.substring(backup.indexOf("\n") + 1), builder, importCache);
else else
return toObject(id, backup, builder); return toObject(id, backup, builder, importCache);
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.subscription package com.futo.platformplayer.subscription
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.getNowDiffHours import com.futo.platformplayer.getNowDiffHours
@@ -7,6 +8,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import java.time.OffsetDateTime
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
class SmartSubscriptionAlgorithm( class SmartSubscriptionAlgorithm(
@@ -70,18 +72,30 @@ class SmartSubscriptionAlgorithm(
} else { } else {
val fetchTasks = mutableListOf<SubscriptionTask>(); val fetchTasks = mutableListOf<SubscriptionTask>();
val cacheTasks = mutableListOf<SubscriptionTask>(); val cacheTasks = mutableListOf<SubscriptionTask>();
var peekTasks = mutableListOf<SubscriptionTask>();
for(task in clientTasks.second) { for(task in clientTasks.second) {
if (!task.fromCache && fetchTasks.size < limit) { if (!task.fromCache && fetchTasks.size < limit) {
fetchTasks.add(task); fetchTasks.add(task);
} else { } else {
task.fromCache = true; if(peekTasks.size < 100 &&
cacheTasks.add(task); Settings.instance.subscriptions.peekChannelContents &&
(task.sub.lastPeekVideo.year < 1971 || task.sub.lastPeekVideo < task.sub.lastVideoUpdate) &&
task.client.capabilities.hasPeekChannelContents &&
task.client.getPeekChannelTypes().contains(task.type)) {
task.fromPeek = true;
task.fromCache = true;
peekTasks.add(task);
}
else {
task.fromCache = true;
cacheTasks.add(task);
}
} }
} }
Logger.i(TAG, "Subscription Client Budget [${clientTasks.first.name}]: ${fetchTasks.size}/${limit}") Logger.i(TAG, "Subscription Client Budget [${clientTasks.first.name}]: ${fetchTasks.size}/${limit}")
finalTasks.addAll(fetchTasks + cacheTasks); finalTasks.addAll(fetchTasks + peekTasks + cacheTasks);
} }
} }
@@ -115,6 +129,9 @@ class SmartSubscriptionAlgorithm(
val lastUpdateHoursAgo = lastUpdate.getNowDiffHours(); val lastUpdateHoursAgo = lastUpdate.getNowDiffHours();
val expectedHours = (interval * 24) - lastUpdateHoursAgo.toDouble(); val expectedHours = (interval * 24) - lastUpdateHoursAgo.toDouble();
return (expectedHours * 100).toInt(); if((type == ResultCapabilities.TYPE_MIXED || type == ResultCapabilities.TYPE_VIDEOS) && (sub.lastPeekVideo.year > 1970 && sub.lastPeekVideo > sub.lastVideoUpdate))
return 0;
else
return (expectedHours * 100).toInt();
} }
} }
@@ -24,6 +24,7 @@ import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
@@ -48,15 +49,17 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val tasksGrouped = tasks.groupBy { it.client } val tasksGrouped = tasks.groupBy { it.client }
Logger.i(TAG, "Starting Subscriptions Fetch:\n" + Logger.i(TAG, "Starting Subscriptions Fetch:\n" +
tasksGrouped.map { " ${it.key.name}: ${it.value.count { !it.fromCache }}, Cached(${it.value.count { it.fromCache } })" }.joinToString("\n")); tasksGrouped.map { " ${it.key.name}: ${it.value.count { !it.fromCache }}, Cached(${it.value.count { it.fromCache } - it.value.count { it.fromPeek && it.fromCache }}), Peek(${it.value.count { it.fromPeek }})" }.joinToString("\n"));
try { try {
for(clientTasks in tasksGrouped) { for(clientTasks in tasksGrouped) {
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size; val clientTaskCount = clientTasks.value.count { !it.fromCache };
val clientCacheCount = clientTasks.value.size - clientTaskCount; val clientCacheCount = clientTasks.value.count { it.fromCache && !it.fromPeek };
val clientPeekCount = clientTasks.value.count { it.fromPeek };
val limit = clientTasks.key.getSubscriptionRateLimit(); val limit = clientTasks.key.getSubscriptionRateLimit();
if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) { if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
UIDialogs.appToast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)"); UIDialogs.appToast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). " +
"(${if(clientPeekCount > 0) "${clientPeekCount} peek, " else ""}${clientCacheCount} cached)");
} }
} }
@@ -135,8 +138,30 @@ abstract class SubscriptionsTaskFetchAlgorithm(
for(task in tasks) { for(task in tasks) {
val forkTask = threadPool.submit<SubscriptionTaskResult> { val forkTask = threadPool.submit<SubscriptionTaskResult> {
if(task.fromPeek) {
try {
val time = measureTimeMillis {
val peekResults = StatePlatform.instance.peekChannelContents(task.client, task.url, task.type);
val mostRecent = peekResults.firstOrNull();
task.sub.lastPeekVideo = mostRecent?.datetime ?: OffsetDateTime.MIN;
task.sub.saveAsync();
val cacheItems = peekResults.filter { it.datetime != null && it.datetime!! > task.sub.lastVideoUpdate };
//Fix for current situation
for(item in cacheItems) {
if(item.author.thumbnail.isNullOrEmpty())
item.author.thumbnail = task.sub.channel.thumbnail;
}
StateCache.instance.cacheContents(cacheItems, false);
}
Logger.i("StateSubscriptions", "Subscription peek [${task.sub.channel.name}]:${task.type} results in ${time}ms");
}
catch(ex: Throwable) {
Logger.e(StateSubscriptions.TAG, "Subscription peek [${task.sub.channel.name}] failed", ex);
}
}
synchronized(cachedChannels) { synchronized(cachedChannels) {
if(task.fromCache) { if(task.fromCache || task.fromPeek) {
finished++; finished++;
onProgress.emit(finished, forkTasks.size); onProgress.emit(finished, forkTasks.size);
if(cachedChannels.contains(task.url)) { if(cachedChannels.contains(task.url)) {
@@ -218,6 +243,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val url: String, val url: String,
val type: String, val type: String,
var fromCache: Boolean = false, var fromCache: Boolean = false,
var fromPeek: Boolean = false,
var urgency: Int = 0 var urgency: Int = 0
); );
@@ -8,11 +8,13 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.HorizontalSpaceItemDecoration import com.futo.platformplayer.HorizontalSpaceItemDecoration
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
@@ -61,6 +63,7 @@ class MonetizationView : LinearLayout {
val onSupportTap = Event0(); val onSupportTap = Event0();
val onStoreTap = Event0(); val onStoreTap = Event0();
val onUrlTap = Event1<String>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_monetization, this); inflate(context, R.layout.view_monetization, this);
@@ -70,10 +73,12 @@ class MonetizationView : LinearLayout {
_membershipPlatform = findViewById(R.id.membership_platform); _membershipPlatform = findViewById(R.id.membership_platform);
_buttonMembership.setOnClickListener { _buttonMembership.setOnClickListener {
_membershipUrl?.let { _membershipUrl?.let {
/*
val uri = Uri.parse(it); val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW); val intent = Intent(Intent.ACTION_VIEW);
intent.data = uri; intent.data = uri;
context.startActivity(intent); context.startActivity(intent);*/
onUrlTap.emit(it);
} }
} }
@@ -129,9 +134,18 @@ class MonetizationView : LinearLayout {
_buttonStore.visibility = View.GONE; _buttonStore.visibility = View.GONE;
} }
if(profile.systemState.donationDestinations.isNotEmpty() ||
profile.systemState.membershipUrls.isNotEmpty() ||
profile.systemState.store.isNotEmpty() ||
profile.systemState.promotion.isNotEmpty())
_buttonSupport.isVisible = true;
else
_buttonSupport.isVisible = false;
_root.visibility = View.VISIBLE; _root.visibility = View.VISIBLE;
} else { } else {
_root.visibility = View.GONE; _root.visibility = View.GONE;
_buttonSupport.isVisible = false;
} }
setMerchandise(null); setMerchandise(null);
@@ -10,6 +10,8 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.view.size
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
@@ -33,6 +35,13 @@ class SupportView : LinearLayout {
private var _textNoSupportOptionsSet: TextView private var _textNoSupportOptionsSet: TextView
private var _polycentricProfile: PolycentricProfile? = null private var _polycentricProfile: PolycentricProfile? = null
val hasSupportItems: Boolean get() {
return (_layoutPromotions.isVisible && _buttonPromotion.isVisible) ||
(_layoutMemberships.isVisible && _layoutMembershipEntries.isVisible && _layoutMembershipEntries.size > 0) ||
(_layoutDonation.isVisible && _layoutDonationEntries.isVisible && _layoutDonationEntries.size > 0) ||
_buttonStore.isVisible;
};
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_support, this); inflate(context, R.layout.view_support, this);
@@ -20,6 +20,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>(); val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>(); val onAddToQueueClicked = Event1<IPlatformContent>();
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>(); val onLongPress = Event1<IPlatformContent>();
override fun getItemCount(): Int { override fun getItemCount(): Int {
@@ -56,6 +57,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit); onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit); onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit); onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit);
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit); onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit);
}; };
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) }; 1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
@@ -48,6 +48,7 @@ class CommentViewHolder : ViewHolder {
var onRepliesClick = Event1<IPlatformComment>(); var onRepliesClick = Event1<IPlatformComment>();
var onDelete = Event1<IPlatformComment>(); var onDelete = Event1<IPlatformComment>();
var onAuthorClick = Event1<IPlatformComment>();
var comment: IPlatformComment? = null var comment: IPlatformComment? = null
private set; private set;
@@ -95,6 +96,19 @@ class CommentViewHolder : ViewHolder {
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked) StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
}; };
_creatorThumbnail.onClick.subscribe {
val c = comment ?: return@subscribe;
onAuthorClick.emit(c);
}
_creatorThumbnail.setOnClickListener {
val c = comment ?: return@setOnClickListener;
onAuthorClick.emit(c);
}
_textAuthor.setOnClickListener {
val c = comment ?: return@setOnClickListener;
onAuthorClick.emit(c);
}
_buttonReplies.onClick.subscribe { _buttonReplies.onClick.subscribe {
val c = comment ?: return@subscribe; val c = comment ?: return@subscribe;
onRepliesClick.emit(c); onRepliesClick.emit(c);
@@ -53,9 +53,10 @@ class CommentWithReferenceViewHolder : ViewHolder {
hideLikesDislikesReplies() hideLikesDislikesReplies()
} }
var onRepliesClick = Event1<IPlatformComment>(); val onRepliesClick = Event1<IPlatformComment>();
var onDelete = Event1<IPlatformComment>(); val onDelete = Event1<IPlatformComment>();
var onClick = Event1<IPlatformComment>(); val onClick = Event1<IPlatformComment>();
val onAuthorClick = Event1<IPlatformComment>();
var comment: IPlatformComment? = null var comment: IPlatformComment? = null
private set; private set;
@@ -99,6 +100,14 @@ class CommentWithReferenceViewHolder : ViewHolder {
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked) StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
}; };
_creatorThumbnail.onClick.subscribe {
val c = comment ?: return@subscribe;
onAuthorClick.emit(c);
}
_textAuthor.setOnClickListener {
val c = comment ?: return@setOnClickListener;
onAuthorClick.emit(c);
}
_buttonReplies.onClick.subscribe { _buttonReplies.onClick.subscribe {
val c = comment ?: return@subscribe; val c = comment ?: return@subscribe;
onRepliesClick.emit(c); onRepliesClick.emit(c);
@@ -39,6 +39,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>(); val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>(); val onAddToQueueClicked = Event1<IPlatformContent>();
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>(); val onLongPress = Event1<IPlatformContent>();
private var _taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>( private var _taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
@@ -95,6 +96,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit); this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit); this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit); this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit);
this.onAddToWatchLaterClicked.subscribe(this@PreviewContentListAdapter.onAddToWatchLaterClicked::emit);
}; };
private fun createLockedViewHolder(viewGroup: ViewGroup): PreviewLockedViewHolder = PreviewLockedViewHolder(viewGroup, _feedStyle).apply { private fun createLockedViewHolder(viewGroup: ViewGroup): PreviewLockedViewHolder = PreviewLockedViewHolder(viewGroup, _feedStyle).apply {
this.onLockedUrlClicked.subscribe(this@PreviewContentListAdapter.onUrlClicked::emit); this.onLockedUrlClicked.subscribe(this@PreviewContentListAdapter.onUrlClicked::emit);
@@ -106,6 +108,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit); this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit); this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit); this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit);
this.onAddToWatchLaterClicked.subscribe(this@PreviewContentListAdapter.onAddToWatchLaterClicked::emit);
this.onLongPress.subscribe(this@PreviewContentListAdapter.onLongPress::emit); this.onLongPress.subscribe(this@PreviewContentListAdapter.onLongPress::emit);
}; };
private fun createPlaylistViewHolder(viewGroup: ViewGroup): PreviewPlaylistViewHolder = PreviewPlaylistViewHolder(viewGroup, _feedStyle).apply { private fun createPlaylistViewHolder(viewGroup: ViewGroup): PreviewPlaylistViewHolder = PreviewPlaylistViewHolder(viewGroup, _feedStyle).apply {
@@ -161,6 +164,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
onChannelClicked.clear(); onChannelClicked.clear();
onAddToClicked.clear(); onAddToClicked.clear();
onAddToQueueClicked.clear(); onAddToQueueClicked.clear();
onAddToWatchLaterClicked.clear();
} }
private fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) { private fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) {
@@ -19,6 +19,7 @@ class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformVideo>(); val onAddToClicked = Event1<IPlatformVideo>();
val onAddToQueueClicked = Event1<IPlatformVideo>(); val onAddToQueueClicked = Event1<IPlatformVideo>();
val onAddToWatchLaterClicked = Event1<IPlatformVideo>();
override val content: IPlatformContent? get() = view.content; override val content: IPlatformContent? get() = view.content;
private val view: PreviewNestedVideoView get() = itemView as PreviewNestedVideoView; private val view: PreviewNestedVideoView get() = itemView as PreviewNestedVideoView;
@@ -31,6 +32,7 @@ class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
view.onChannelClicked.subscribe(onChannelClicked::emit); view.onChannelClicked.subscribe(onChannelClicked::emit);
view.onAddToClicked.subscribe(onAddToClicked::emit); view.onAddToClicked.subscribe(onAddToClicked::emit);
view.onAddToQueueClicked.subscribe(onAddToQueueClicked::emit); view.onAddToQueueClicked.subscribe(onAddToQueueClicked::emit);
view.onAddToWatchLaterClicked.subscribe(onAddToWatchLaterClicked::emit);
} }
@@ -61,6 +61,7 @@ open class PreviewVideoView : LinearLayout {
protected val _layoutDownloaded: FrameLayout; protected val _layoutDownloaded: FrameLayout;
protected val _button_add_to_queue : View; protected val _button_add_to_queue : View;
protected val _button_add_to_watch_later : View;
protected val _button_add_to : View; protected val _button_add_to : View;
protected val _exoPlayer: PlayerManager?; protected val _exoPlayer: PlayerManager?;
@@ -80,6 +81,7 @@ open class PreviewVideoView : LinearLayout {
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformVideo>(); val onAddToClicked = Event1<IPlatformVideo>();
val onAddToQueueClicked = Event1<IPlatformVideo>(); val onAddToQueueClicked = Event1<IPlatformVideo>();
val onAddToWatchLaterClicked = Event1<IPlatformVideo>();
var currentVideo: IPlatformVideo? = null var currentVideo: IPlatformVideo? = null
private set private set
@@ -104,6 +106,7 @@ open class PreviewVideoView : LinearLayout {
_containerDuration = findViewById(R.id.thumbnail_duration_container); _containerDuration = findViewById(R.id.thumbnail_duration_container);
_containerLive = findViewById(R.id.thumbnail_live_container); _containerLive = findViewById(R.id.thumbnail_live_container);
_button_add_to_queue = findViewById(R.id.button_add_to_queue); _button_add_to_queue = findViewById(R.id.button_add_to_queue);
_button_add_to_watch_later = findViewById(R.id.button_add_to_watch_later);
_button_add_to = findViewById(R.id.button_add_to); _button_add_to = findViewById(R.id.button_add_to);
_imageNeopassChannel = findViewById(R.id.image_neopass_channel); _imageNeopassChannel = findViewById(R.id.image_neopass_channel);
_layoutDownloaded = findViewById(R.id.layout_downloaded); _layoutDownloaded = findViewById(R.id.layout_downloaded);
@@ -124,7 +127,7 @@ open class PreviewVideoView : LinearLayout {
_textVideoMetadata.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } }; _textVideoMetadata.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } };
_button_add_to.setOnClickListener { currentVideo?.let { onAddToClicked.emit(it) } }; _button_add_to.setOnClickListener { currentVideo?.let { onAddToClicked.emit(it) } };
_button_add_to_queue.setOnClickListener { currentVideo?.let { onAddToQueueClicked.emit(it) } }; _button_add_to_queue.setOnClickListener { currentVideo?.let { onAddToQueueClicked.emit(it) } };
_button_add_to_watch_later.setOnClickListener { currentVideo?.let { onAddToWatchLaterClicked.emit(it); } }
} }
protected open fun inflate(feedStyle: FeedStyle) { protected open fun inflate(feedStyle: FeedStyle) {
@@ -18,6 +18,7 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder {
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformVideo>(); val onAddToClicked = Event1<IPlatformVideo>();
val onAddToQueueClicked = Event1<IPlatformVideo>(); val onAddToQueueClicked = Event1<IPlatformVideo>();
val onAddToWatchLaterClicked = Event1<IPlatformVideo>();
val onLongPress = Event1<IPlatformVideo>(); val onLongPress = Event1<IPlatformVideo>();
//val context: Context; //val context: Context;
@@ -34,6 +35,7 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder {
view.onChannelClicked.subscribe(onChannelClicked::emit); view.onChannelClicked.subscribe(onChannelClicked::emit);
view.onAddToClicked.subscribe(onAddToClicked::emit); view.onAddToClicked.subscribe(onAddToClicked::emit);
view.onAddToQueueClicked.subscribe(onAddToQueueClicked::emit); view.onAddToQueueClicked.subscribe(onAddToQueueClicked::emit);
view.onAddToWatchLaterClicked.subscribe(onAddToWatchLaterClicked::emit);
view.onLongPress.subscribe(onLongPress::emit); view.onLongPress.subscribe(onLongPress::emit);
} }
@@ -3,12 +3,12 @@ package com.futo.platformplayer.views.behavior
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorSet import android.animation.AnimatorSet
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.media.AudioManager import android.media.AudioManager
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector import android.view.GestureDetector
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
@@ -24,6 +24,7 @@ import androidx.core.animation.doOnStart
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
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.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
@@ -67,6 +68,7 @@ class GestureControlView : LinearLayout {
private var _animatorSound: ObjectAnimator? = null; private var _animatorSound: ObjectAnimator? = null;
private var _brightnessFactor = 1.0f; private var _brightnessFactor = 1.0f;
private var _originalBrightnessFactor = 1.0f; private var _originalBrightnessFactor = 1.0f;
private var _originalBrightnessMode: Int = 0;
private var _adjustingBrightness: Boolean = false; private var _adjustingBrightness: Boolean = false;
private val _layoutControlsBrightness: FrameLayout; private val _layoutControlsBrightness: FrameLayout;
private val _progressBrightness: CircularProgressBar; private val _progressBrightness: CircularProgressBar;
@@ -168,8 +170,6 @@ class GestureControlView : LinearLayout {
if(p0 == null) if(p0 == null)
return false; return false;
Logger.i(TAG, "p0.pointerCount: " + p0.pointerCount)
if (!_isPanning && p1.pointerCount == 1) { if (!_isPanning && p1.pointerCount == 1) {
val minDistance = Math.min(width, height) val minDistance = Math.min(width, height)
if (_isFullScreen && _adjustingBrightness) { if (_isFullScreen && _adjustingBrightness) {
@@ -739,16 +739,24 @@ class GestureControlView : LinearLayout {
resetZoomPan() resetZoomPan()
if (isFullScreen) { if (isFullScreen) {
val c = context if (Settings.instance.gestureControls.useSystemBrightness) {
if (c is Activity && Settings.instance.gestureControls.useSystemBrightness) { try {
_brightnessFactor = c.window.attributes.screenBrightness _originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE)
if (_brightnessFactor == -1.0f) {
_brightnessFactor = android.provider.Settings.System.getInt( val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS)
context.contentResolver, _brightnessFactor = brightness / 255.0f;
android.provider.Settings.System.SCREEN_BRIGHTNESS Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode")
) / 255.0f;
_originalBrightnessFactor = _brightnessFactor
} catch (e: Throwable) {
Settings.instance.gestureControls.useSystemBrightness = false
Settings.instance.save()
UIDialogs.toast(context, "useSystemBrightness disabled due to an error")
} }
_originalBrightnessFactor = _brightnessFactor }
if (!Settings.instance.gestureControls.useSystemBrightness) {
_brightnessFactor = 1.0f;
} }
if (Settings.instance.gestureControls.useSystemVolume) { if (Settings.instance.gestureControls.useSystemVolume) {
@@ -761,10 +769,19 @@ class GestureControlView : LinearLayout {
onBrightnessAdjusted.emit(_brightnessFactor); onBrightnessAdjusted.emit(_brightnessFactor);
onSoundAdjusted.emit(_soundFactor); onSoundAdjusted.emit(_soundFactor);
} else { } else {
val c = context if (Settings.instance.gestureControls.useSystemBrightness) {
if (c is Activity && Settings.instance.gestureControls.useSystemBrightness) {
if (Settings.instance.gestureControls.restoreSystemBrightness) { if (Settings.instance.gestureControls.restoreSystemBrightness) {
onBrightnessAdjusted.emit(_originalBrightnessFactor); onBrightnessAdjusted.emit(_originalBrightnessFactor)
if (android.provider.Settings.System.canWrite(context)) {
Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode")
android.provider.Settings.System.putInt(
context.contentResolver,
android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE,
_originalBrightnessMode
)
}
} }
} else { } else {
onBrightnessAdjusted.emit(1.0f); onBrightnessAdjusted.emit(1.0f);
@@ -13,6 +13,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.PlatformLinkMovementMethod import com.futo.platformplayer.others.PlatformLinkMovementMethod
import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.timestampRegex import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking
class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
constructor(context: Context) : super(context) {} constructor(context: Context) : super(context) {}
@@ -40,32 +41,34 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
if (text is Spannable) { if (text is Spannable) {
val links = text.getSpans(offset, offset, URLSpan::class.java) val links = text.getSpans(offset, offset, URLSpan::class.java)
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
for (link in links) { runBlocking {
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." }; for (link in links) {
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." };
val c = context; val c = context;
if (c is MainActivity) { if (c is MainActivity) {
if (c.handleUrl(link.url)) { if (c.handleUrl(link.url)) {
continue;
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue; continue;
} }
}
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))); if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue;
}
}
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
}
} }
} }
@@ -9,16 +9,16 @@ 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
class PlaylistDownloadItem(context: Context, val playlist: PlaylistDownloaded): 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) }
var imageView: ImageView = findViewById(R.id.downloaded_playlist_image); var imageView: ImageView = findViewById(R.id.downloaded_playlist_image);
var imageText: TextView = findViewById(R.id.downloaded_playlist_name); var imageText: TextView = findViewById(R.id.downloaded_playlist_name);
init { init {
imageText.text = playlist.playlist.name; imageText.text = playlistName;
Glide.with(imageView) Glide.with(imageView)
.load(playlist.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail()) .load(playlistThumbnail)
.crossfade() .crossfade()
.into(imageView); .into(imageView);
} }
@@ -51,9 +51,11 @@ class RepliesOverlay : LinearLayout {
private var _onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null; private var _onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null;
private val _loaderOverlay: LoaderOverlay private val _loaderOverlay: LoaderOverlay
private val _client = ManagedHttpClient() private val _client = ManagedHttpClient()
private val _layoutItems: LinearLayout
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.overlay_replies, this) inflate(context, R.layout.overlay_replies, this)
_layoutItems = findViewById(R.id.layout_items)
_topbar = findViewById(R.id.topbar); _topbar = findViewById(R.id.topbar);
_commentsList = findViewById(R.id.comments_list); _commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view); _addCommentView = findViewById(R.id.add_comment_view);
@@ -65,6 +67,9 @@ class RepliesOverlay : LinearLayout {
_loaderOverlay = findViewById(R.id.loader_overlay) _loaderOverlay = findViewById(R.id.loader_overlay)
setLoading(false); setLoading(false);
_layoutItems.removeView(_layoutParentComment)
_commentsList.setPrependedView(_layoutParentComment)
_addCommentView.onCommentAdded.subscribe { _addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it); _commentsList.addComment(it);
_onCommentAdded?.invoke(it); _onCommentAdded?.invoke(it);
@@ -14,6 +14,10 @@ class SupportOverlay : LinearLayout {
private val _topbar: OverlayTopbar; private val _topbar: OverlayTopbar;
private val _support: SupportView; private val _support: SupportView;
val hasSupportItems: Boolean get() {
return _support.hasSupportItems;
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.overlay_support, this) inflate(context, R.layout.overlay_support, this)
_topbar = findViewById(R.id.topbar); _topbar = findViewById(R.id.topbar);
@@ -0,0 +1,38 @@
package com.futo.platformplayer.views.overlays
import android.content.Context
import android.util.AttributeSet
import android.webkit.WebView
import android.widget.LinearLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.SupportView
class WebviewOverlay : LinearLayout {
val onClose = Event0();
private val _topbar: OverlayTopbar;
private val _webview: WebView;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.overlay_webview, this)
_topbar = findViewById(R.id.topbar);
_webview = findViewById(R.id.webview);
_webview.settings.javaScriptEnabled = true;
_topbar.onClose.subscribe(this, onClose::emit);
}
fun goto(url: String) {
Logger.i("WebviewOverlay", "Loading [${url}]");
_topbar.setInfo(url, "");
_webview.loadUrl(url);
}
fun cleanup() {
_topbar.onClose.remove(this);
}
}
@@ -28,13 +28,17 @@ class SlideUpMenuFilters {
private var _changed: Boolean = false; private var _changed: Boolean = false;
private val _lifecycleScope: CoroutineScope; private val _lifecycleScope: CoroutineScope;
private var _isChannelSearch = false;
var commonCapabilities: ResultCapabilities? = null; var commonCapabilities: ResultCapabilities? = null;
constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>) {
constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false) {
_lifecycleScope = lifecycleScope; _lifecycleScope = lifecycleScope;
_container = container; _container = container;
_enabledClientsIds = enabledClientsIds; _enabledClientsIds = enabledClientsIds;
_filterValues = filterValues; _filterValues = filterValues;
_isChannelSearch = isChannelSearch;
_slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf()); _slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf());
_slideUpMenuOverlay.onOK.subscribe { _slideUpMenuOverlay.onOK.subscribe {
onOK.emit(_enabledClientsIds, _changed); onOK.emit(_enabledClientsIds, _changed);
@@ -47,7 +51,10 @@ class SlideUpMenuFilters {
private fun updateCommonCapabilities() { private fun updateCommonCapabilities() {
_lifecycleScope.launch(Dispatchers.IO) { _lifecycleScope.launch(Dispatchers.IO) {
try { try {
val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); val caps = if(!_isChannelSearch)
StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds);
synchronized(_filterValues) { synchronized(_filterValues) {
if (caps != null) { if (caps != null) {
val keysToRemove = arrayListOf<String>(); val keysToRemove = arrayListOf<String>();
@@ -0,0 +1,38 @@
package com.futo.platformplayer.views.overlays.slideup
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.AnyAdapter
class SlideUpMenuRecycler<T : Any, VType : AnyAdapter.AnyViewHolder<T>> : LinearLayout {
private lateinit var recyclerView: RecyclerView;
private val adapter: AnyAdapterView<T, VType>?;
var groupTag: Any? = null;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
init();
adapter = null;
}
constructor(context: Context, tag: Any, creation: (RecyclerView)->AnyAdapterView<T, VType>) : super(context){
init();
groupTag = tag;
adapter = creation(recyclerView);
}
private fun init(){
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_recycler, this, true);
recyclerView = findViewById(R.id.slide_up_menu_recycler);
}
}
@@ -71,6 +71,9 @@ class CommentsList : ConstraintLayout {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy); super.onScrolled(recyclerView, dx, dy);
onScrolled(); onScrolled();
val totalScrollDistance = recyclerView.computeVerticalScrollOffset()
_layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE
} }
}; };
@@ -82,14 +85,23 @@ class CommentsList : ConstraintLayout {
private var _loading = false; private var _loading = false;
private val _prependedView: FrameLayout; private val _prependedView: FrameLayout;
private var _readonly: Boolean = false; private var _readonly: Boolean = false;
private val _layoutScrollToTop: FrameLayout;
var onRepliesClick = Event1<IPlatformComment>(); var onRepliesClick = Event1<IPlatformComment>();
var onAuthorClick = Event1<IPlatformComment>();
var onCommentsLoaded = Event1<Int>(); var onCommentsLoaded = Event1<Int>();
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_comments_list, this, true); LayoutInflater.from(context).inflate(R.layout.view_comments_list, this, true);
_recyclerComments = findViewById(R.id.recycler_comments); _recyclerComments = findViewById(R.id.recycler_comments);
_layoutScrollToTop = findViewById(R.id.layout_scroll_to_top);
_layoutScrollToTop.setOnClickListener {
_recyclerComments.smoothScrollToPosition(0)
}
_layoutScrollToTop.visibility = View.GONE
_textMessage = TextView(context).apply { _textMessage = TextView(context).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 30, 0, 0) setMargins(0, 30, 0, 0)
@@ -109,6 +121,7 @@ class CommentsList : ConstraintLayout {
childViewHolderFactory = { viewGroup, _ -> childViewHolderFactory = { viewGroup, _ ->
val holder = CommentViewHolder(viewGroup); val holder = CommentViewHolder(viewGroup);
holder.onRepliesClick.subscribe { c -> onRepliesClick.emit(c) }; holder.onRepliesClick.subscribe { c -> onRepliesClick.emit(c) };
holder.onAuthorClick.subscribe { c -> onAuthorClick.emit(c) };
holder.onDelete.subscribe(::onDelete); holder.onDelete.subscribe(::onDelete);
return@InsertedViewAdapterWithLoader holder; return@InsertedViewAdapterWithLoader holder;
} }
@@ -1,18 +1,18 @@
package com.futo.platformplayer.views.video package com.futo.platformplayer.views.video
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
@@ -122,6 +122,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private var _currentChapterLoopActive = false; private var _currentChapterLoopActive = false;
private var _currentChapterLoopId: Int = 0; private var _currentChapterLoopId: Int = 0;
private var _currentChapter: IChapter? = null; private var _currentChapter: IChapter? = null;
private var _promptedForPermissions: Boolean = false;
//Events //Events
@@ -249,11 +250,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}; };
gestureControl.onToggleFullscreen.subscribe { setFullScreen(!isFullScreen) }; gestureControl.onToggleFullscreen.subscribe { setFullScreen(!isFullScreen) };
gestureControl.onBrightnessAdjusted.subscribe { gestureControl.onBrightnessAdjusted.subscribe {
if (context is Activity && Settings.instance.gestureControls.useSystemBrightness) { if (Settings.instance.gestureControls.useSystemBrightness) {
val window = context.window setSystemBrightness(it)
val layout: WindowManager.LayoutParams = window.attributes
layout.screenBrightness = it
window.attributes = layout
} else { } else {
if (it == 1.0f) { if (it == 1.0f) {
_overlay_brightness.visibility = View.GONE; _overlay_brightness.visibility = View.GONE;
@@ -433,6 +431,30 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
} }
} }
private fun setSystemBrightness(brightness: Float) {
Log.i(TAG, "setSystemBrightness $brightness")
if (android.provider.Settings.System.canWrite(context)) {
Log.i(TAG, "setSystemBrightness canWrite $brightness")
android.provider.Settings.System.putInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL);
android.provider.Settings.System.putInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS, (brightness * 255.0f).toInt().coerceAtLeast(1).coerceAtMost(255));
} else if (!_promptedForPermissions) {
Log.i(TAG, "setSystemBrightness prompt $brightness")
_promptedForPermissions = true
UIDialogs.showConfirmationDialog(context, "System brightness controls require explicit permission", action = {
openAndroidPermissionsMenu()
})
} else {
Log.i(TAG, "setSystemBrightness no permission?")
//No permissions but already prompted, ignore
}
}
private fun openAndroidPermissionsMenu() {
val intent = Intent(android.provider.Settings.ACTION_MANAGE_WRITE_SETTINGS)
intent.setData(Uri.parse("package:" + context.packageName))
context.startActivity(intent)
}
fun updateNextPrevious() { fun updateNextPrevious() {
val vidPrev = StatePlayer.instance.getPrevQueueItem(true); val vidPrev = StatePlayer.instance.getPrevQueueItem(true);
val vidNext = StatePlayer.instance.getNextQueueItem(true); val vidNext = StatePlayer.instance.getNextQueueItem(true);
@@ -560,6 +582,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_videoControls_fullscreen.show(); _videoControls_fullscreen.show();
videoControls.hideImmediately(); videoControls.hideImmediately();
videoControls.visibility = View.GONE;
} }
else { else {
val lp = background.layoutParams as ConstraintLayout.LayoutParams; val lp = background.layoutParams as ConstraintLayout.LayoutParams;
@@ -572,6 +595,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
videoControls.show(); videoControls.show();
_videoControls_fullscreen.hideImmediately(); _videoControls_fullscreen.hideImmediately();
_videoControls_fullscreen.visibility = View.GONE;
} }
fitOrFill(fullScreen); fitOrFill(fullScreen);
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M612,668L668,612L520,464L520,280L440,280L440,496L612,668ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,800Q613,800 706.5,706.5Q800,613 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,613 253.5,706.5Q347,800 480,800Z"/>
</vector>
+16 -3
View File
@@ -37,9 +37,22 @@
android:fontFamily="@font/inter_extra_light" /> android:fontFamily="@font/inter_extra_light" />
</FrameLayout> </FrameLayout>
<Space <LinearLayout
android:layout_width="20dp" android:id="@+id/button_check_for_updates"
android:layout_height="match_parent" /> android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:gravity="center"
android:background="@drawable/background_button_primary_round_4dp"
android:layout_gravity="center_horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="@string/check_for_updates"/>
</LinearLayout>
</LinearLayout> </LinearLayout>
@@ -13,7 +13,7 @@
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:scaleType="fitCenter" android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_construction" /> app:srcCompat="@drawable/foreground" />
<!--<ImageButton <!--<ImageButton
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -22,17 +22,19 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_futo_logo_text" />--> app:srcCompat="@drawable/ic_futo_logo_text" />-->
<!--
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:textSize="28dp" android:textSize="22dp"
android:fontFamily="@font/inter_bold" android:layout_marginTop="-2dp"
android:text="TEST BUILD" android:fontFamily="@font/inter_light"
android:text="Grayjay"
android:textColor="@color/white" android:textColor="@color/white"
android:gravity="center_vertical" android:gravity="center_vertical"
android:layout_marginStart="8dp"/>--> android:layout_marginStart="8dp"/>
<!--
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
@@ -54,7 +56,7 @@
android:text="@string/construction" android:text="@string/construction"
android:textColor="@color/white" android:textColor="@color/white"
android:layout_marginTop="-8dp"/> android:layout_marginTop="-8dp"/>
</LinearLayout> </LinearLayout>-->
<Space <Space
android:layout_width="0dp" android:layout_width="0dp"
+1 -1
View File
@@ -22,7 +22,7 @@
android:id="@+id/no_sources" android:id="@+id/no_sources"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="visible" android:visibility="gone"
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout <LinearLayout
@@ -542,6 +542,12 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<com.futo.platformplayer.views.overlays.WebviewOverlay
android:id="@+id/videodetail_container_webview"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.futo.platformplayer.views.overlays.QueueEditorOverlay <com.futo.platformplayer.views.overlays.QueueEditorOverlay
android:id="@+id/videodetail_container_queue" android:id="@+id/videodetail_container_queue"
android:visibility="gone" android:visibility="gone"
+16 -10
View File
@@ -226,10 +226,18 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"> app:layout_constraintRight_toRightOf="parent">
<ImageButton
android:id="@+id/button_add_to_watch_later"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginEnd="5dp"
android:background="@drawable/edit_text_background"
app:srcCompat="@drawable/ic_clock_white" />
<ImageButton <ImageButton
android:id="@+id/button_add_to_queue" android:id="@+id/button_add_to_queue"
android:layout_width="wrap_content" android:layout_width="30dp"
android:layout_height="wrap_content" android:layout_height="30dp"
android:layout_marginEnd="1dp" android:layout_marginEnd="1dp"
android:background="@drawable/edit_text_background" android:background="@drawable/edit_text_background"
android:contentDescription="@string/add_to_queue" android:contentDescription="@string/add_to_queue"
@@ -242,20 +250,18 @@
<LinearLayout <LinearLayout
android:id="@+id/button_add_to" android:id="@+id/button_add_to"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="30dp"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:background="@drawable/edit_text_background" android:background="@drawable/edit_text_background"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="4dp"> android:padding="4dp">
<ImageButton <ImageView
android:layout_width="wrap_content" android:layout_width="20dp"
android:layout_height="wrap_content" android:layout_height="16dp"
android:layout_marginStart="4dp" android:paddingTop="1dp"
android:layout_marginEnd="4dp" android:src="@drawable/ic_settings" />
android:contentDescription="@string/options"
app:srcCompat="@drawable/ic_add_white_8dp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -262,45 +262,53 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingEnd="6dp"> android:paddingEnd="6dp">
<ImageButton
android:id="@+id/button_add_to_watch_later"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginEnd="5dp"
android:background="@drawable/edit_text_background"
app:srcCompat="@drawable/ic_clock_white" />
<ImageButton <ImageButton
android:id="@+id/button_add_to_queue" android:id="@+id/button_add_to_queue"
android:layout_width="wrap_content" android:layout_width="30dp"
android:layout_height="wrap_content" android:layout_height="30dp"
android:layout_marginEnd="1dp" android:layout_marginEnd="1dp"
android:paddingTop="7dp" android:background="@drawable/edit_text_background"
android:contentDescription="@string/add_to_queue"
android:paddingStart="6dp" android:paddingStart="6dp"
android:paddingTop="7dp"
android:paddingEnd="5dp" android:paddingEnd="5dp"
android:paddingBottom="3dp" android:paddingBottom="3dp"
app:srcCompat="@drawable/ic_queue_16dp" app:srcCompat="@drawable/ic_queue_16dp" />
android:background="@drawable/edit_text_background"
android:contentDescription="@string/add_to_queue" />
<LinearLayout <LinearLayout
android:id="@+id/button_add_to" android:id="@+id/button_add_to"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="30dp"
android:orientation="horizontal"
android:background="@drawable/edit_text_background"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:background="@drawable/edit_text_background"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="4dp"> android:padding="4dp">
<ImageButton
android:layout_width="wrap_content" <ImageView
android:layout_height="wrap_content" android:layout_width="20dp"
android:layout_marginEnd="4dp" android:layout_height="16dp"
app:srcCompat="@drawable/ic_add_white_8dp" android:paddingTop="1dp"
android:layout_marginStart="4dp" android:src="@drawable/ic_settings" />
android:contentDescription="@string/options" />
<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/options" android:layout_marginEnd="4dp"
android:background="@color/transparent" android:background="@color/transparent"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light" android:fontFamily="@font/inter_light"
android:layout_marginEnd="4dp"/> android:text="@string/options"
android:textColor="@color/white"
android:textSize="12dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

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