diff --git a/LICENSE.md b/LICENSE.md index 38414394..2cc664ad 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -# Grayjay Core License 1.0 +# Source First License 1.1 ## Acceptance By using the software, you agree to all of the terms and conditions below. @@ -16,7 +16,7 @@ Notwithstanding the above, you may not remove or obscure any functionality in th You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensor’s trademarks is subject to applicable law. ## Patents -If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. +If you make any written claim that the software infringes or contributes to infringement of any patent, your license for the software granted under these terms ends immediately. If your company makes such a claim, your license ends immediately for work on behalf of your company. ## Notices You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section. diff --git a/README.md b/README.md index 263689a2..b05b5b82 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation. - - + + @@ -24,12 +24,10 @@ The FUTO media app is a player that exposes multiple video websites as sources i
Video
- - + - - +
Sources (all enabled)Sources (one disabled)Sources
@@ -38,7 +36,7 @@ Additional sources can also be installed. These sources are JavaScript sources, - + @@ -54,8 +52,8 @@ When a user enters a search term into the search bar, the query is posted to th
Install a new source
- - + + @@ -71,7 +69,7 @@ Creators are able to configure their profile using NeoPass.
Search (list)
- + @@ -112,7 +110,7 @@ The app offers a lot of settings customizing how the app looks and feels. An exa
Channel
- + @@ -125,8 +123,8 @@ Playlists allow you to make a collection of videos that you can create and custo
Settings
- - + + @@ -142,7 +140,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
Playlists
- + @@ -157,7 +155,7 @@ For more information about casting please click [here](./docs/casting.md).
Downloads
- + @@ -182,6 +180,12 @@ In the future we hope to offer users the choice of their desired recommendation 1. Download a copy of the repository. 2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository. +3. Open the terminal in Android Studio by clicking on the terminal icon on bottom left and run the following command: + +```sh +git submodule update --init --recursive +``` + 3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator. 4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes. @@ -199,7 +203,6 @@ Create a tag on the master branch, incrementing the last version number by 1 (fo Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update. - ## Documentation The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview). diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 4e64f43e..6c73aaec 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -486,6 +486,9 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21) var autoplay: Boolean = false; + + @FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22) + var deleteFromWatchLaterAuto: Boolean = true; } @FormField(R.string.comments, "group", R.string.comments_description, 6) @@ -843,10 +846,14 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2) fun clearPayment() { - StatePayment.instance.clearLicenses(); - SettingsActivity.getActivity()?.let { - UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart)); - it.reloadSettings(); + SettingsActivity.getActivity()?.let { context -> + UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", { + StatePayment.instance.clearLicenses(); + SettingsActivity.getActivity()?.let { + UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart)); + it.reloadSettings(); + } + }) } } } @@ -855,7 +862,10 @@ class Settings : FragmentedStorageFileJson() { var other = Other(); @Serializable class Other { - @FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 1) + @FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2) + var playlistDeleteConfirmation: Boolean = true; + + @FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 3) var polycentricEnabled: Boolean = true; } diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 7c975bf0..5e73638c 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -348,6 +348,13 @@ class UIDialogs { showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) } + fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) { + val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) + val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) + val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE) + showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) + } + fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) { val dialog = AutoUpdateDialog(context); registerDialogOpened(dialog); diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index c5b15eaf..be7a5e78 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.downloads.VideoLocal @@ -879,6 +880,12 @@ class UISlideOverlays { val items = arrayListOf(); val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); + val isLimited = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let { + if (it is JSClient) + return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD + else false; + } ?: false; + if (lastUpdated != null) { items.add( SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist", @@ -899,17 +906,18 @@ class UISlideOverlays { val watchLater = StatePlaylists.instance.getWatchLater(); items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", (listOf( - SlideUpMenuItem( - container.context, - R.drawable.ic_download, - container.context.getString(R.string.download), - container.context.getString(R.string.download_the_video), - tag = "download", - call = { - showDownloadVideoOverlay(video, container, true); - }, - invokeParent = false - ), + if(!isLimited) + SlideUpMenuItem( + container.context, + R.drawable.ic_download, + container.context.getString(R.string.download), + container.context.getString(R.string.download_the_video), + tag = "download", + call = { + showDownloadVideoOverlay(video, container, true); + }, + invokeParent = false + ) else null, SlideUpMenuItem( container.context, R.drawable.ic_share, @@ -936,7 +944,7 @@ class UISlideOverlays { StateMeta.instance.addHiddenCreator(video.author.url); UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home"); })) - + actions) + + actions).filterNotNull() )); items.add( SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", @@ -1033,15 +1041,7 @@ class UISlideOverlays { "${watchLater.size} " + container.context.getString(R.string.videos), tag = "watch later", call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), - SlideUpMenuItem( - container.context, - R.drawable.ic_download, - container.context.getString(R.string.download), - container.context.getString(R.string.download_the_video), - tag = container.context.getString(R.string.download), - call = { showDownloadVideoOverlay(video, container, true); }, - invokeParent = false - )) + ) ); val playlistItems = arrayListOf(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index bd3c2ded..3af034aa 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -50,7 +50,8 @@ class SourcePluginConfig( var primaryClaimFieldType: Int? = null, var developerSubmitUrl: String? = null, var allowAllHttpHeaderAccess: Boolean = false, - var maxDownloadParallelism: Int = 0 + var maxDownloadParallelism: Int = 0, + var reduceFunctionsInLimitedVersion: Boolean = false, ) : IV8PluginConfig { val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt index a7672823..8782b742 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt @@ -71,6 +71,8 @@ abstract class JSPager : IPager { warnIfMainThread("JSPager.getResults"); val items = pager.getOrThrow(config, "results", "JSPager"); + if(items.v8Runtime.isDead || items.v8Runtime.isClosed) + throw IllegalStateException("Runtime closed"); val newResults = items.toArray() .map { convertResult(it as V8ValueObject) } .toList(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticBackupDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticBackupDialog.kt index 93e6a330..18cb086b 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticBackupDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticBackupDialog.kt @@ -22,6 +22,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) { private lateinit var _buttonCancel: ImageButton; private lateinit var _editPassword: EditText; + private lateinit var _editPassword2: EditText; private lateinit var _inputMethodManager: InputMethodManager; @@ -34,6 +35,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) { _buttonStop = findViewById(R.id.button_stop); _buttonStart = findViewById(R.id.button_start); _editPassword = findViewById(R.id.edit_password); + _editPassword2 = findViewById(R.id.edit_password2); _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; @@ -52,6 +54,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) { } _buttonStart.setOnClickListener { + val p1 = _editPassword.text.toString(); + val p2 = _editPassword2.text.toString(); + if(!(p1?.equals(p2) ?: false)) { + UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly."); + return@setOnClickListener; + } + val pbytes = _editPassword.text.toString().toByteArray(); if(pbytes.size < 4 || pbytes.size > 32) { UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt index 6fca4178..44c2ca2d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt @@ -10,6 +10,7 @@ import android.widget.TextView import androidx.lifecycle.lifecycleScope import com.futo.futopay.PaymentConfigurations import com.futo.futopay.PaymentManager +import com.futo.futopay.formatMoney import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs @@ -94,9 +95,8 @@ class BuyFragment : MainFragment() { if(currency != null && prices.containsKey(currency.id)) { val price = prices[currency.id]!!; - val priceDecimal = (price.toDouble() / 100); withContext(Dispatchers.Main) { - _buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax); + _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax); } } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt index f2b4aa8d..9fd4d7d6 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt @@ -180,7 +180,7 @@ class SubscriptionGroupFragment : MainFragment() { UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${g.name}]?", null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Delete", { - StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id); + StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id, true); _didDelete = true; fragment.close(true); }, UIDialogs.ActionStyle.DANGEROUS)) @@ -253,7 +253,7 @@ class SubscriptionGroupFragment : MainFragment() { if(g.urls.isEmpty() && g.image == null) { //Obtain image for(sub in it) { - val sub = StateSubscriptions.instance.getSubscription(sub); + val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub); if(sub != null && sub.channel.thumbnail != null) { g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!); g.image?.setImageView(_imageGroup); @@ -308,8 +308,10 @@ class SubscriptionGroupFragment : MainFragment() { if(group != null) { val urls = group.urls.toList(); - val subs = StateSubscriptions.instance.getSubscriptions().map { it.channel } - _enabledCreators.addAll(subs.filter { urls.contains(it.url) }); + val subs = urls.map { + (StateSubscriptions.instance.getSubscription(it) ?: StateSubscriptions.instance.getSubscriptionOther(it))?.channel + }.filterNotNull(); + _enabledCreators.addAll(subs); } updateMeta(); filterCreators(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt index 71402e60..792aae77 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.activities.AddSourceOptionsActivity import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment @@ -57,10 +58,19 @@ class SubscriptionGroupListFragment : MainFragment() { }; it.onDelete.subscribe { group -> - val loc = _subs.indexOf(group); - _subs.remove(group); - _list?.adapter?.notifyItemRangeRemoved(loc); - StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id); + context?.let { context -> + UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${group.name}]?", null, 0, + UIDialogs.Action("Cancel", {}), + UIDialogs.Action("Delete", { + StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true); + + val loc = _subs.indexOf(group); + _subs.remove(group); + _list?.adapter?.notifyItemRangeRemoved(loc); + StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true); + + }, UIDialogs.ActionStyle.DANGEROUS)); + } }; it.onDragDrop.subscribe { _touchHelper?.startDrag(it); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index 9f1964c8..fa89e63d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -26,6 +26,7 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorageFileJson @@ -362,6 +363,7 @@ class SubscriptionsFeedFragment : MainFragment() { } override fun reload() { + StatePlugins.instance.clearUpdating(); //Fallback in case it doesnt clear, UI should be blocked. loadResults(true); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index fc411de7..f224b6ae 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -143,9 +143,7 @@ class VideoDetailFragment : MainFragment { @SuppressLint("SourceLockedOrientationActivity") fun updateOrientation() { val a = activity ?: return - // only applies to small windows val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait - val isReversePortraitAllowed = Settings.instance.playback.reversePortrait val rotationLock = StatePlayer.instance.rotationLock diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 78d1d41d..73571123 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -41,6 +41,7 @@ import androidx.media3.ui.TimeBar import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -73,6 +74,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +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.models.JSVideoDetails import com.futo.platformplayer.api.media.structures.IPager @@ -113,9 +115,12 @@ import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.db.types.DBHistory +import com.futo.platformplayer.sync.internal.GJSyncOpcodes +import com.futo.platformplayer.sync.models.SendToDevicePackage import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanNowDiffString @@ -642,6 +647,27 @@ class VideoDetailView : ConstraintLayout { StatePlayer.instance.onVideoChanging.subscribe(this) { setVideoOverview(it); }; + + var hadDevice = false; + StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session -> + val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice(); + if(hasDevice != hadDevice) { + hadDevice = hasDevice; + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateMoreButtons(); + } + } + }; + StateSync.instance.deviceRemoved.subscribe(this) { id -> + val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice(); + if(hasDevice != hadDevice) { + hadDevice = hasDevice; + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateMoreButtons(); + } + } + } + MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() }; MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() }; MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() }; @@ -717,6 +743,7 @@ class VideoDetailView : ConstraintLayout { }; onClose.subscribe { + checkAndRemoveWatchLater(); _lastVideoSource = null; _lastAudioSource = null; _lastSubtitleSource = null; @@ -825,6 +852,11 @@ class VideoDetailView : ConstraintLayout { } fun updateMoreButtons() { + val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let { + if (it is JSClient) + return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD + else false; + } ?: false; val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) { (video ?: _searchVideo)?.let { _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) { @@ -844,38 +876,44 @@ class VideoDetailView : ConstraintLayout { } _slideUpOverlay?.hide(); } else null, - RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) { - if(!allowBackground) { - _player.switchToAudioMode(); - allowBackground = true; - it.text.text = resources.getString(R.string.background_revert); + if(!isLimitedVersion) + RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) { + if(!allowBackground) { + _player.switchToAudioMode(); + allowBackground = true; + it.text.text = resources.getString(R.string.background_revert); + } + else { + _player.switchToVideoMode(); + allowBackground = false; + it.text.text = resources.getString(R.string.background); + } + _slideUpOverlay?.hide(); } - else { - _player.switchToVideoMode(); - allowBackground = false; - it.text.text = resources.getString(R.string.background); + else null, + if(!isLimitedVersion) + RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { + video?.let { + _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); + }; } - _slideUpOverlay?.hide(); - }, - RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { - video?.let { - _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); - }; - }, - RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) { - video?.let { - Logger.i(TAG, "Share preventPictureInPicture = true"); - preventPictureInPicture = true; - shareVideo(); - }; - _slideUpOverlay?.hide(); - }, - RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) { - this.startPictureInPicture(); - fragment.forcePictureInPicture(); - //PiPActivity.startPiP(context); - _slideUpOverlay?.hide(); - }, + else null, + RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) { + video?.let { + Logger.i(TAG, "Share preventPictureInPicture = true"); + preventPictureInPicture = true; + shareVideo(); + }; + _slideUpOverlay?.hide(); + }, + if(!isLimitedVersion) + RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) { + this.startPictureInPicture(); + fragment.forcePictureInPicture(); + //PiPActivity.startPiP(context); + _slideUpOverlay?.hide(); + } + else null, RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) { video?.let { val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url; @@ -884,6 +922,22 @@ class VideoDetailView : ConstraintLayout { }; _slideUpOverlay?.hide(); }, + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) { + val devices = StateSync.instance.getSessions(); + val videoToSend = video ?: return@RoundButton; + if(devices.size > 1) { + //not implemented + } + else if(devices.size == 1){ + val device = devices.first(); + UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , { + fragment.lifecycleScope.launch(Dispatchers.IO) { + device.sendJson(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds/1000).toInt())); + } + }) + } + }} else null, RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") { reloadVideo(); _slideUpOverlay?.hide(); @@ -1031,6 +1085,8 @@ class VideoDetailView : ConstraintLayout { StateApp.instance.preventPictureInPicture.remove(this); StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onVideoChanging.remove(this); + StateSync.instance.deviceUpdatedOrAdded.remove(this); + StateSync.instance.deviceRemoved.remove(this); MediaControlReceiver.onLowerVolumeReceived.remove(this); MediaControlReceiver.onPlayReceived.remove(this); MediaControlReceiver.onPauseReceived.remove(this); @@ -1853,6 +1909,8 @@ class VideoDetailView : ConstraintLayout { fun prevVideo(withoutRemoval: Boolean = false) { Logger.i(TAG, "prevVideo") + checkAndRemoveWatchLater(); + val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9); if(next != null) { setVideoOverview(next, true, 0, true); @@ -1861,6 +1919,8 @@ class VideoDetailView : ConstraintLayout { fun nextVideo(forceLoop: Boolean = false, withoutRemoval: Boolean = false, bypassVideoLoop: Boolean = false): Boolean { Logger.i(TAG, "nextVideo") + checkAndRemoveWatchLater(); + var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop); val autoplayVideo = _autoplayVideo if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) { @@ -1869,7 +1929,8 @@ class VideoDetailView : ConstraintLayout { next = autoplayVideo } _autoplayVideo = null - Logger.i(TAG, "Autoplay video cleared (nextVideo)") + Logger.i(TAG, "Autoplay video cleared (nextVideo)"); + if(next == null && forceLoop) next = StatePlayer.instance.restartQueue(); if(next != null) { @@ -1881,6 +1942,20 @@ class VideoDetailView : ConstraintLayout { return false; } + fun checkAndRemoveWatchLater(){ + val watchCurrent = video ?: videoLocal ?: _searchVideo; + if(Settings.instance.playback.deleteFromWatchLaterAuto) { + if(watchCurrent?.duration != null && + watchCurrent.duration > 0 && + (lastPositionMilliseconds / 1000) > watchCurrent.duration * 0.7) { + if(!watchCurrent.url.isNullOrEmpty()) { + StatePlaylists.instance.removeFromWatchLater(watchCurrent.url); + } + } + } + } + + //Quality Selector data private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List?, liveStreamAudioFormats : List?) { val v = video ?: return; @@ -2952,6 +3027,7 @@ class VideoDetailView : ConstraintLayout { const val TAG_OVERLAY = "overlay"; const val TAG_LIVECHAT = "livechat"; const val TAG_OPEN = "open"; + const val TAG_SEND_TO_DEVICE = "send_to_device"; const val TAG_MORE = "MORE"; private val _buttonPinStore = FragmentedStorage.get("videoPinnedButtons"); diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index 23fa2b73..8b4b5847 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -191,21 +191,21 @@ class VideoHelper { } - fun estimateSourceSize(source: IVideoSource?): Int { + fun estimateSourceSize(source: IVideoSource?): Long { if(source == null) return 0; if(source is IVideoSource) { if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0) return 0; - return (source.duration / 8).toInt() * source.bitrate!!; + return (source.duration / 8) * source.bitrate!!; } else return 0; } - fun estimateSourceSize(source: IAudioSource?): Int { + fun estimateSourceSize(source: IAudioSource?): Long { if(source == null) return 0; if(source is IAudioSource) { if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0) return 0; - return (source.duration!! / 8).toInt() * source.bitrate; + return (source.duration!! / 8) * source.bitrate; } else return 0; } diff --git a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt index 70268ec0..2b972d87 100644 --- a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt +++ b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt @@ -46,7 +46,10 @@ class MDNSListener { } fun start() { - if (_started) throw Exception("Already running.") + if (_started) { + Logger.i(TAG, "Already started.") + return + } _started = true _scope = CoroutineScope(Dispatchers.IO); diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt index 79d29736..f4a3e5e9 100644 --- a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt +++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt @@ -37,7 +37,10 @@ class ServiceDiscoverer(names: Array, private val _onServicesUpdated: (L } fun start() { - if (_started) throw Exception("Already running.") + if (_started) { + Logger.i(TAG, "Already started.") + return + } _started = true val listener = MDNSListener() diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt index a5337dc5..bb4d1007 100644 --- a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt +++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt @@ -100,33 +100,34 @@ class ServiceRecordAggregator { Logger.i(TAG, "$builder")*/ val currentServices: MutableList + ptrRecords.forEach { record -> + val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() } + val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName) + cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName } + } + + aRecords.forEach { aRecord -> + val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() } + val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address) + cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address } + } + + aaaaRecords.forEach { aaaaRecord -> + val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() } + val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address) + cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address } + } + + txtRecords.forEach { txtRecord -> + _cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts) + } + + srvRecords.forEach { srvRecord -> + _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second) + } + + //TODO: Maybe this can be debounced? synchronized(this._currentServices) { - ptrRecords.forEach { record -> - val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() } - val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName) - cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName } - } - - aRecords.forEach { aRecord -> - val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() } - val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address) - cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address } - } - - aaaaRecords.forEach { aaaaRecord -> - val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() } - val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address) - cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address } - } - - txtRecords.forEach { txtRecord -> - _cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts) - } - - srvRecords.forEach { srvRecord -> - _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second) - } - currentServices = getCurrentServices() this._currentServices.clear() this._currentServices.addAll(currentServices) diff --git a/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt b/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt index c46aa3b8..b5f34415 100644 --- a/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt +++ b/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt @@ -1,5 +1,7 @@ package com.futo.platformplayer.models +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import java.time.OffsetDateTime import java.util.UUID @kotlinx.serialization.Serializable @@ -10,6 +12,11 @@ open class SubscriptionGroup { var urls: MutableList = mutableListOf(); var priority: Int = 99; + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var lastChange : OffsetDateTime = OffsetDateTime.MIN; + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var creationTime : OffsetDateTime = OffsetDateTime.now(); + constructor(name: String) { this.name = name; } @@ -19,6 +26,8 @@ open class SubscriptionGroup { this.image = parent.image; this.urls = parent.urls; this.priority = parent.priority; + this.lastChange = parent.lastChange; + this.creationTime = parent.creationTime; } class Selectable(parent: SubscriptionGroup, isSelected: Boolean = false): SubscriptionGroup(parent) { diff --git a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt index a9c83202..c36a1942 100644 --- a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt +++ b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt @@ -12,6 +12,8 @@ import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.timestampRegex +import com.futo.platformplayer.views.behavior.NonScrollingTextView +import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion import kotlinx.coroutines.runBlocking class PlatformLinkMovementMethod : LinkMovementMethod { @@ -23,6 +25,7 @@ class PlatformLinkMovementMethod : LinkMovementMethod { override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { val action = event.action; + Logger.i(TAG, "onTouchEvent (action = $action)") if (action == MotionEvent.ACTION_UP) { val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX; val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 52103822..b05d25c8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -2,6 +2,8 @@ package com.futo.platformplayer.states import android.annotation.SuppressLint import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -20,9 +22,13 @@ import androidx.lifecycle.lifecycleScope import androidx.work.* import com.futo.platformplayer.* import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs.Action +import com.futo.platformplayer.UIDialogs.ActionStyle +import com.futo.platformplayer.UIDialogs.Companion.showDialog import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.background.BackgroundWorker @@ -419,8 +425,17 @@ class StateApp { Logger.onLogSubmitted.subscribe { scopeOrNull?.launch(Dispatchers.Main) { try { - if (it != null) { - UIDialogs.toast("Uploaded $it", true); + if (!it.isNullOrEmpty()) { + (SettingsActivity.getActivity() ?: contextOrNull)?.let { c -> + val okButtonAction = Action(c.getString(R.string.ok), {}, ActionStyle.PRIMARY) + val copyButtonAction = Action(c.getString(R.string.copy), { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Log id", it) + clipboard.setPrimaryClip(clip) + }, ActionStyle.NONE) + + showDialog(c, R.drawable.ic_error, "Uploaded $it", null, null, 0, copyButtonAction, okButtonAction) + } } else { UIDialogs.toast("Failed to upload"); } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt index dcebaf95..7cf3d976 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt @@ -11,6 +11,7 @@ import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.copyTo import com.futo.platformplayer.encryption.GPasswordEncryptionProvider @@ -18,7 +19,9 @@ import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0 import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment import com.futo.platformplayer.getNowDiffHours import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.ImportCache +import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.readBytes import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.v2.ManagedStore @@ -61,9 +64,9 @@ class StateBackup { StatePlaylists.instance.toMigrateCheck() ).flatten(); - fun getCache(): ImportCache { + fun getCache(additionalVideos: List = listOf()): ImportCache { val allPlaylists = StatePlaylists.instance.getPlaylists(); - val videos = allPlaylists.flatMap { it.videos }.distinctBy { it.url }; + val videos = allPlaylists.flatMap { it.videos }.plus(additionalVideos).distinctBy { it.url }; val allSubscriptions = StateSubscriptions.instance.getSubscriptions(); val channels = allSubscriptions.map { it.channel }; @@ -240,6 +243,23 @@ class StateBackup { .associateBy { it.name } .mapValues { it.value.getAllReconstructionStrings() } .toMutableMap(); + + var historyVideos: List? = null; + try { + storesToSave.set("subscription_groups", StateSubscriptionGroups.instance.getSubscriptionGroups().map { Json.encodeToString(it) }); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to serialize subscription groups"); + } + try { + val history = StateHistory.instance.getRecentHistory(OffsetDateTime.MIN, 2000); + historyVideos = history.map { it.video }; + storesToSave.set("history", history.map { it.toReconString() }); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to serialize history"); + } + val settings = Settings.instance.encode(); val pluginSettings = StatePlugins.instance.getPlugins() .associateBy { it.config.id } @@ -249,7 +269,7 @@ class StateBackup { .associateBy { it.config.id } .mapValues { it.value.config.sourceUrl!! }; - val cache = getCache(); + val cache = getCache(historyVideos ?: listOf()); val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings, cache); @@ -333,19 +353,64 @@ class StateBackup { if(doImportStores) { for(store in export.stores) { Logger.i(TAG, "Importing store [${store.key}]"); - val relevantStore = availableStores.find { it.name == store.key }; - if(relevantStore == null) { - Logger.w(TAG, "Unknown store [${store.key}] import"); - continue; + if(store.key == "history") { + withContext(Dispatchers.Main) { + UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0, + UIDialogs.Action("No", { + }, UIDialogs.ActionStyle.NONE), + UIDialogs.Action("Yes", { + for(historyStr in store.value) { + try { + val histObj = HistoryVideo.fromReconString(historyStr) { url -> + return@fromReconString export.cache?.videos?.firstOrNull { it.url == url }; + } + val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date); + if(hist != null) + StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to import subscription group", ex); + } + } + }, UIDialogs.ActionStyle.PRIMARY)) + } } - withContext(Dispatchers.Main) { - UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) { - synchronized(toAwait) { - toAwait.remove(store.key); - if(toAwait.isEmpty()) - onConclusion(); - } - }; + else if(store.key == "subscription_groups") { + withContext(Dispatchers.Main) { + UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0, + UIDialogs.Action("No", { + }, UIDialogs.ActionStyle.NONE), + UIDialogs.Action("Yes", { + for(groupStr in store.value) { + try { + val group = Json.decodeFromString(groupStr); + val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); + if(existing != null) + StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false); + StateSubscriptionGroups.instance.updateSubscriptionGroup(group); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to import subscription group", ex); + } + } + }, UIDialogs.ActionStyle.PRIMARY)) + } + } + else { + val relevantStore = availableStores.find { it.name == store.key }; + if (relevantStore == null) { + Logger.w(TAG, "Unknown store [${store.key}] import"); + continue; + } + withContext(Dispatchers.Main) { + UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) { + synchronized(toAwait) { + toAwait.remove(store.key); + if(toAwait.isEmpty()) + onConclusion(); + } + }; + } } } } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt index d5da7880..acd92a67 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt @@ -59,7 +59,6 @@ class StateHistory { return getHistoryPosition(url) > duration * 0.7; } - fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false): Long { val pos = if(position < 0) 0 else position; val historyVideo = index.obj; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index d5f53af4..2826cb91 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -17,10 +17,18 @@ import com.futo.platformplayer.exceptions.ReconstructionException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage +import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ReconstructStore +import com.futo.platformplayer.sync.internal.GJSyncOpcodes +import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File @@ -45,6 +53,7 @@ class StatePlaylists { val playlistStore = FragmentedStorage.storeJson("playlists") .withRestore(PlaylistBackup()) .load(); + private val _playlistRemoved = FragmentedStorage.get("playlist_removed"); val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); @@ -81,6 +90,18 @@ class StatePlaylists { StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); } } + fun getWatchLaterFromUrl(url: String): SerializedPlatformVideo?{ + synchronized(_watchlistStore) { + val order = _watchlistOrderStore.getAllValues(); + return _watchlistStore.getItems().firstOrNull { it.url == url }; + } + } + fun removeFromWatchLater(url: String) { + val item = getWatchLaterFromUrl(url); + if(item != null){ + removeFromWatchLater(item); + } + } fun removeFromWatchLater(video: SerializedPlatformVideo) { synchronized(_watchlistStore) { _watchlistStore.delete(video); @@ -118,6 +139,9 @@ class StatePlaylists { return playlistStore.findItem { it.id == id }; } + fun getPlaylistRemovals(): Map { + return _playlistRemoved.all(); + } fun didPlay(playlistId: String) { val playlist = getPlaylist(playlistId); @@ -148,13 +172,15 @@ class StatePlaylists { createOrUpdatePlaylist(newPlaylist); return newPlaylist; } - fun createOrUpdatePlaylist(playlist: Playlist) { + fun createOrUpdatePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) { playlist.dateUpdate = OffsetDateTime.now(); playlistStore.saveAsync(playlist, true); if(playlist.id.isNotEmpty()) { if (StateDownloads.instance.isPlaylistCached(playlist.id)) { StateDownloads.instance.checkForOutdatedPlaylistVideos(playlist.id); } + if(isUserInteraction) + broadcastSyncPlaylist(playlist); } } fun addToPlaylist(id: String, video: IPlatformVideo) { @@ -163,14 +189,41 @@ class StatePlaylists { playlist.videos.add(SerializedPlatformVideo.fromVideo(video)); playlist.dateUpdate = OffsetDateTime.now(); playlistStore.saveAsync(playlist, true); + + broadcastSyncPlaylist(playlist); } } - fun removePlaylist(playlist: Playlist) { + private fun broadcastSyncPlaylist(playlist: Playlist){ + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncPlaylists, + SyncPlaylistsPackage(listOf(playlist), mapOf()) + ); + } + }; + } + + fun removePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) { playlistStore.delete(playlist); if(StateDownloads.instance.isPlaylistCached(playlist.id)) { StateDownloads.instance.deleteCachedPlaylist(playlist.id); } + if(isUserInteraction) { + _playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now()); + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncPlaylists, + SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond()))) + ); + } + }; + } } fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri { @@ -194,6 +247,16 @@ class StatePlaylists { return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile); } + + fun getSyncPlaylistsPackageString(): String{ + return Json.encodeToString( + SyncPlaylistsPackage( + getPlaylists(), + getPlaylistRemovals() + ) + ); + } + companion object { val TAG = "StatePlaylists"; private var _instance : StatePlaylists? = null; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index 71aa6d6f..02154677 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -19,6 +19,7 @@ import com.futo.platformplayer.stores.PluginIconStorage import com.futo.platformplayer.stores.PluginScriptsDirectory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -47,6 +48,8 @@ class StatePlugins { private var _updatesAvailableMap: HashSet = hashSetOf(); + private val _isUpdating: HashSet = hashSetOf(); + fun getPluginIconOrNull(id: String): ImageVariable? { if(iconsDir.hasIcon(id)) return iconsDir.getIconBinary(id); @@ -58,6 +61,38 @@ class StatePlugins { .load(); } + fun isUpdating(id: String): Boolean{ + synchronized(_isUpdating){ + return _isUpdating.contains(id); + } + } + fun setIsUpdating(id: String, value: Boolean){ + synchronized(_isUpdating){ + if(value && !_isUpdating.contains(id)) { + Logger.i(TAG, "PLUGIN [${id}] UPDATING"); + _isUpdating.add(id); + } + if(!value && _isUpdating.contains(id)) { + Logger.i(TAG, "PLUGIN [${id}] NOT UPDATING"); + _isUpdating.remove(id); + } + } + } + suspend fun whileUpdating(id: String, handle: suspend ()->Unit){ + try { + setIsUpdating(id, true); + handle(); + } + finally { + setIsUpdating(id, false); + } + } + fun clearUpdating(){ + synchronized(_isUpdating) { + _isUpdating.clear(); + } + } + suspend fun checkForUpdates(): List> = withContext(Dispatchers.IO) { var configs = mutableListOf>() @@ -430,42 +465,49 @@ class StatePlugins { fun installPluginBackground(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, onProgress: (text: String, progress: Double)->Unit, onConcluded: (ex: Throwable?)->Unit) { scope.launch(Dispatchers.IO) { - val client = ManagedHttpClient(); - try { + whileUpdating(config.id) { withContext(Dispatchers.Main) { - onProgress.invoke("Validating script", 0.25); + onProgress.invoke("Waiting for plugins to finish", 0.1); } + delay(500); - val tempDescriptor = SourcePluginDescriptor(config); - val plugin = JSClient(context, tempDescriptor, null, script); - plugin.validate(); - - withContext(Dispatchers.Main) { - onProgress.invoke("Downloading Icon", 0.5); - } - - val icon = config.absoluteIconUrl?.let { absIconUrl -> + val client = ManagedHttpClient(); + try { withContext(Dispatchers.Main) { - onProgress.invoke("Saving plugin", 0.75); + onProgress.invoke("Validating script", 0.25); } - val iconResp = client.get(absIconUrl); - if(iconResp.isOk) - return@let iconResp.body?.byteStream()?.use { it.readBytes() }; - return@let null; - } - val installEx = StatePlugins.instance.createPlugin(config, script, icon, true); - if(installEx != null) - throw installEx; - StatePlatform.instance.updateAvailableClients(context); - withContext(Dispatchers.Main) { - onProgress.invoke("Finished", 1.0) - onConcluded.invoke(null); - } - } catch (ex: Exception) { - Logger.e(TAG, ex.message ?: "null", ex); - withContext(Dispatchers.Main) { - onConcluded.invoke(ex); + val tempDescriptor = SourcePluginDescriptor(config); + val plugin = JSClient(context, tempDescriptor, null, script); + plugin.validate(); + + withContext(Dispatchers.Main) { + onProgress.invoke("Downloading Icon", 0.5); + } + + val icon = config.absoluteIconUrl?.let { absIconUrl -> + withContext(Dispatchers.Main) { + onProgress.invoke("Saving plugin", 0.75); + } + val iconResp = client.get(absIconUrl); + if (iconResp.isOk) + return@let iconResp.body?.byteStream()?.use { it.readBytes() }; + return@let null; + } + val installEx = StatePlugins.instance.createPlugin(config, script, icon, true); + if (installEx != null) + throw installEx; + StatePlatform.instance.updateAvailableClients(context); + + withContext(Dispatchers.Main) { + onProgress.invoke("Finished", 1.0) + onConcluded.invoke(null); + } + } catch (ex: Exception) { + Logger.e(TAG, ex.message ?: "null", ex); + withContext(Dispatchers.Main) { + onConcluded.invoke(ex); + } } } } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt index f77e2fad..2b4883da 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt @@ -25,13 +25,20 @@ import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.states.StateHistory.Companion import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms +import com.futo.platformplayer.sync.internal.GJSyncOpcodes +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import kotlinx.coroutines.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.time.OffsetDateTime import java.util.concurrent.ExecutionException import java.util.concurrent.ForkJoinPool @@ -51,6 +58,9 @@ class StateSubscriptionGroups { .withUnique { it.id } .load(); + + private val _groupsRemoved = FragmentedStorage.get("group_removed"); + val onGroupsChanged = Event0(); fun getSubscriptionGroup(id: String): SubscriptionGroup? { @@ -59,19 +69,66 @@ class StateSubscriptionGroups { fun getSubscriptionGroups(): List { return _subGroups.getItems(); } - fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false) { + fun getSubscriptionGroupsRemovals(): Map { + return _groupsRemoved.all(); + } + fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false, preventSync: Boolean = false) { + subGroup.lastChange = OffsetDateTime.now(); _subGroups.save(subGroup); if(!preventNotify) onGroupsChanged.emit(); + if(!preventSync) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncSubscriptionGroups, + SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf()) + ); + } + }; + } } - fun deleteSubscriptionGroup(id: String){ + fun deleteSubscriptionGroup(id: String, isUserInteraction: Boolean = true){ val group = getSubscriptionGroup(id); if(group != null) { _subGroups.delete(group); onGroupsChanged.emit(); + + if(isUserInteraction) { + _groupsRemoved.setAndSave(id, OffsetDateTime.now()); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncSubscriptionGroups, + SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond()))) + ); + } + }; + } } } + fun hasSubscriptionGroup(url: String): Boolean { + val groups = getSubscriptionGroups(); + for(group in groups){ + if(group.urls.contains(url)) + return true; + } + return false; + } + + + fun getSyncSubscriptionGroupsPackageString(): String{ + return Json.encodeToString( + SyncSubscriptionGroupsPackage( + getSubscriptionGroups(), + getSubscriptionGroupsRemovals() + ) + ); + } + companion object { const val TAG = "StateSubscriptionGroups"; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 8e69d487..df680fed 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -202,13 +202,13 @@ class StateSubscriptions { return _subscriptionOthers.findItem { it.isChannel(url)}; } } - fun getSubscriptionOtherOrCreate(url: String) : Subscription { + fun getSubscriptionOtherOrCreate(url: String, name: String? = null, thumbnail: String? = null) : Subscription { synchronized(_subscriptionOthers) { val sub = getSubscriptionOther(url); if(sub == null) { - val newSub = Subscription(SerializedChannel(PlatformID.NONE, url, null, null, 0, null, url, mapOf())); + val newSub = Subscription(SerializedChannel(PlatformID.NONE, name ?: url, thumbnail, null, 0, null, url, mapOf())); newSub.isOther = true; - _subscriptions.save(newSub); + _subscriptionOthers.save(newSub); return newSub; } else return sub; @@ -293,8 +293,29 @@ class StateSubscriptions { if(sub != null) { _subscriptions.delete(sub); onSubscriptionsChanged.emit(getSubscriptions(), false); - if(isUserAction) - _subscriptionsRemoved.setAndSave(sub.channel.url, OffsetDateTime.now()); + if(isUserAction) { + val removalTime = OffsetDateTime.now(); + _subscriptionsRemoved.setAndSave(sub.channel.url, removalTime); + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + StateSync.instance.broadcast( + GJSyncOpcodes.syncSubscriptions, Json.encodeToString( + SyncSubscriptionsPackage( + listOf(), + mapOf(Pair(sub.channel.url, removalTime.toEpochSecond())) + ) + ) + ); + } + catch(ex: Exception) { + Logger.w(TAG, "Failed to send subs changes to sync clients", ex); + } + } + } + + if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url)) + getSubscriptionOtherOrCreate(sub.channel.url, sub.channel.name, sub.channel.thumbnail); } return sub; } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 7c05d20d..b6bf0ca8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -66,6 +66,10 @@ class StateSync { val deviceUpdatedOrAdded: Event2 = Event2() fun start() { + if (_started) { + Logger.i(TAG, "Already started.") + return + } _started = true if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) { diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 978500e0..eff83030 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -22,6 +22,7 @@ import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StateSubscriptions import kotlinx.coroutines.CoroutineScope import java.time.OffsetDateTime @@ -138,6 +139,18 @@ abstract class SubscriptionsTaskFetchAlgorithm( for(task in tasks) { val forkTask = threadPool.submit { + if(StatePlugins.instance.isUpdating(task.client.id)){ + val isUpdatingException = ScriptCriticalException(task.client.config, "Plugin is updating"); + synchronized(failedPlugins) { + //Fail all subscription calls to plugin if it has a critical issue + if(isUpdatingException.config is SourcePluginConfig && !failedPlugins.contains(isUpdatingException.config.id)) { + Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${isUpdatingException.config.name}] due to critical exception:\n" + isUpdatingException.message); + failedPlugins.add(isUpdatingException.config.id); + } + } + return@submit SubscriptionTaskResult(task, StateCache.instance.getChannelCachePager(task.sub.channel.url), isUpdatingException); + } + if(task.fromPeek) { try { diff --git a/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt b/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt index a6cef5d4..fdd9eebf 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt @@ -11,5 +11,7 @@ class GJSyncOpcodes { val syncSubscriptions: UByte = 202.toUByte(); val syncHistory: UByte = 203.toUByte(); + val syncSubscriptionGroups: UByte = 204.toUByte(); + val syncPlaylists: UByte = 205.toUByte(); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 9f6b77f5..6c46f59d 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -6,15 +6,20 @@ import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.sync.SyncSessionData import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode import com.futo.platformplayer.sync.models.SendToDevicePackage +import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -22,7 +27,9 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.nio.ByteBuffer +import java.time.Instant import java.time.OffsetDateTime +import java.time.ZoneOffset interface IAuthorizable { val isAuthorized: Boolean @@ -158,6 +165,8 @@ class SyncSession : IAuthorizable { send(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); + send(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); + send(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); if(recentHistory.size > 0) @@ -205,6 +214,67 @@ class SyncSession : IAuthorizable { } } + GJSyncOpcodes.syncSubscriptionGroups -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + var lastSubgroupChange = OffsetDateTime.MIN; + for(group in pack.groups){ + if(group.lastChange > lastSubgroupChange) + lastSubgroupChange = group.lastChange; + + val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); + + if(existing == null) + StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); + else if(existing.lastChange < group.lastChange) { + existing.name = group.name; + existing.urls = group.urls; + existing.image = group.image; + existing.priority = group.priority; + existing.lastChange = group.lastChange; + StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); + } + } + for(removal in pack.groupRemovals) { + val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.creationTime < removalTime) + StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); + } + } + + GJSyncOpcodes.syncPlaylists -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + for(playlist in pack.playlists) { + val existing = StatePlaylists.instance.getPlaylist(playlist.id); + + if(existing == null) + StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); + else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) { + existing.dateUpdate = playlist.dateUpdate; + existing.name = playlist.name; + existing.videos = playlist.videos; + existing.dateCreation = playlist.dateCreation; + existing.datePlayed = playlist.datePlayed; + StatePlaylists.instance.createOrUpdatePlaylist(existing, false); + } + } + for(removal in pack.playlistRemovals) { + val creation = StatePlaylists.instance.getPlaylist(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.dateCreation < removalTime) + StatePlaylists.instance.removePlaylist(creation, false); + + } + } + GJSyncOpcodes.syncHistory -> { val dataBody = ByteArray(data.remaining()); data.get(dataBody); @@ -242,8 +312,7 @@ class SyncSession : IAuthorizable { if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); if(sub.creationTime > removalTime) { - val newSub = - StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); + val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); added.add(newSub); } } diff --git a/app/src/main/java/com/futo/platformplayer/sync/models/SyncPlaylistsPackage.kt b/app/src/main/java/com/futo/platformplayer/sync/models/SyncPlaylistsPackage.kt new file mode 100644 index 00000000..3d40057f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/models/SyncPlaylistsPackage.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.sync.models + +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.models.SubscriptionGroup +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime +import java.util.Dictionary + +@Serializable +class SyncPlaylistsPackage( + var playlists: List, + var playlistRemovals: Map +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/models/SyncSubscriptionGroupsPackage.kt b/app/src/main/java/com/futo/platformplayer/sync/models/SyncSubscriptionGroupsPackage.kt new file mode 100644 index 00000000..663f6a7b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/models/SyncSubscriptionGroupsPackage.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.sync.models + +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.models.SubscriptionGroup +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime +import java.util.Dictionary + +@Serializable +class SyncSubscriptionGroupsPackage( + var groups: List, + var groupRemovals: Map +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index d412a40a..3bfce0be 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -6,6 +6,8 @@ import android.widget.FrameLayout import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -53,14 +55,30 @@ class VideoListEditorView : FrameLayout { }; adapterVideos.onRemove.subscribe { v -> - synchronized(_videos) { - val index = _videos.indexOf(v); - if(index >= 0) { - _videos.removeAt(index); - onVideoRemoved.emit(v); + val executeDelete = { + synchronized(_videos) { + val index = _videos.indexOf(v); + if(index >= 0) { + _videos.removeAt(index); + onVideoRemoved.emit(v); + } + adapterVideos.notifyItemRemoved(index); } - adapterVideos.notifyItemRemoved(index); } + + if (Settings.instance.other.playlistDeleteConfirmation) { + UIDialogs.showConfirmationDialog(context, "Please confirm to delete", action = { + executeDelete() + }, cancelAction = { + + }, doNotAskAgainAction = { + Settings.instance.other.playlistDeleteConfirmation = false + Settings.instance.save() + }) + } else { + executeDelete() + } + }; adapterVideos.onClick.subscribe(onVideoClicked::emit); diff --git a/app/src/main/res/layout/dialog_automatic_backup.xml b/app/src/main/res/layout/dialog_automatic_backup.xml index 3804866b..1f570c4b 100644 --- a/app/src/main/res/layout/dialog_automatic_backup.xml +++ b/app/src/main/res/layout/dialog_automatic_backup.xml @@ -71,6 +71,16 @@ android:singleLine="true" android:inputType="textPassword" android:hint="@string/backup_password" /> + Yes No Confirm + Don\'t ask again Are you sure you want to delete this playlist? Are you sure you want to delete this subscription? Removing this source will result in some of your subscriptions not being resolved. @@ -180,6 +181,7 @@ Set a password for your daily backup Set a password used to encrypt your daily backup that is written to external storage. Backup Password + Repeat Password Restore from Automatic Backup It appears an automatic backup exists on your device, if you would like to restore, enter your backup password. Restore @@ -398,6 +400,9 @@ Enable autoplay by default Autoplay will be enabled by default whenever you watch a video Allow full screen portrait + Delete from WatchLater when watched + After you leave a video that you mostly watched, it will be removed from watch later. + Allow fullscreen portrait Switch to Audio in Background Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter Groups @@ -417,6 +422,8 @@ Payment Payment Status Bypass Rotation Prevention + Playlist Delete Confirmation + Show confirmation dialog when deleting media from a playlist Enable Polycentric Can be disabled when you are experiencing issues Allows for rotation on non-video views.\nWARNING: Not designed for it @@ -608,6 +615,7 @@ Do you want to convert channel {channelName} to a playlist? Failed to convert channel Page + Sync Video Hide Hide from Home Hide Creator from Home diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index 5809463f..9dedbca4 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 5809463f3dc2fd81fb92740ede467e271b5ca0c3 +Subproject commit 9dedbca4f27cfca2e2a146d6edb6a9bae7541d67 diff --git a/app/src/stable/assets/sources/nebula b/app/src/stable/assets/sources/nebula index ed6e7fe3..9e6dcf09 160000 --- a/app/src/stable/assets/sources/nebula +++ b/app/src/stable/assets/sources/nebula @@ -1 +1 @@ -Subproject commit ed6e7fe340f2b90c3f9ad35993c5b0bf89593c29 +Subproject commit 9e6dcf093538511eac56dc44a32b99139f1f1005 diff --git a/app/src/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index b8ceab3e..59774ac0 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit b8ceab3e572be982171ceac09be7a7ad7878b8e8 +Subproject commit 59774ac08406e29f1408cb461caa5b79c805c6e1 diff --git a/app/src/stable/assets/sources/patreon b/app/src/stable/assets/sources/patreon index 5b191993..7b66aea9 160000 --- a/app/src/stable/assets/sources/patreon +++ b/app/src/stable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 +Subproject commit 7b66aea99f08303eedea879b236c49132669d2b8 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index b94d5a50..75ca0c0f 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit b94d5a5091ae0929d82c703868616158607a4436 +Subproject commit 75ca0c0f1e31394ec4c82d5320fa9330df849f6f diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 95ae01d5..80c9b4d3 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 95ae01d5358328583fc3a3b59a2a0ca9d06301d2 +Subproject commit 80c9b4d3b48739170b40b313be930329dcc59fe4 diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index a30bb7af..7c47d7bd 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -38,6 +38,8 @@ + + @@ -67,6 +69,8 @@ + + diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index 5809463f..9dedbca4 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 5809463f3dc2fd81fb92740ede467e271b5ca0c3 +Subproject commit 9dedbca4f27cfca2e2a146d6edb6a9bae7541d67 diff --git a/app/src/unstable/assets/sources/nebula b/app/src/unstable/assets/sources/nebula index ed6e7fe3..9e6dcf09 160000 --- a/app/src/unstable/assets/sources/nebula +++ b/app/src/unstable/assets/sources/nebula @@ -1 +1 @@ -Subproject commit ed6e7fe340f2b90c3f9ad35993c5b0bf89593c29 +Subproject commit 9e6dcf093538511eac56dc44a32b99139f1f1005 diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index b8ceab3e..59774ac0 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit b8ceab3e572be982171ceac09be7a7ad7878b8e8 +Subproject commit 59774ac08406e29f1408cb461caa5b79c805c6e1 diff --git a/app/src/unstable/assets/sources/patreon b/app/src/unstable/assets/sources/patreon index 5b191993..7b66aea9 160000 --- a/app/src/unstable/assets/sources/patreon +++ b/app/src/unstable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 +Subproject commit 7b66aea99f08303eedea879b236c49132669d2b8 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index b94d5a50..75ca0c0f 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit b94d5a5091ae0929d82c703868616158607a4436 +Subproject commit 75ca0c0f1e31394ec4c82d5320fa9330df849f6f diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 95ae01d5..80c9b4d3 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 95ae01d5358328583fc3a3b59a2a0ca9d06301d2 +Subproject commit 80c9b4d3b48739170b40b313be930329dcc59fe4 diff --git a/dep/futopay b/dep/futopay index c8992e6a..c3f532c6 160000 --- a/dep/futopay +++ b/dep/futopay @@ -1 +1 @@ -Subproject commit c8992e6a0ef462d11dfaf716ebe1caf46c926611 +Subproject commit c3f532c660527ae579c1dff0d2f9f4d8ea4d3173 diff --git a/images/casting.jpg b/images/casting.jpg deleted file mode 100755 index 3a88e381..00000000 Binary files a/images/casting.jpg and /dev/null differ diff --git a/images/casting.png b/images/casting.png new file mode 100644 index 00000000..8f8a24a4 Binary files /dev/null and b/images/casting.png differ diff --git a/images/channel.jpg b/images/channel.jpg deleted file mode 100755 index 5f6d51cc..00000000 Binary files a/images/channel.jpg and /dev/null differ diff --git a/images/channel.png b/images/channel.png new file mode 100644 index 00000000..356631e9 Binary files /dev/null and b/images/channel.png differ diff --git a/images/creators.png b/images/creators.png index 71b458d9..e989eefe 100644 Binary files a/images/creators.png and b/images/creators.png differ diff --git a/images/downloads.jpg b/images/downloads.jpg deleted file mode 100755 index 584ab75b..00000000 Binary files a/images/downloads.jpg and /dev/null differ diff --git a/images/downloads.png b/images/downloads.png new file mode 100644 index 00000000..9373d54f Binary files /dev/null and b/images/downloads.png differ diff --git a/images/history.jpg b/images/history.jpg deleted file mode 100755 index aaaa91d4..00000000 Binary files a/images/history.jpg and /dev/null differ diff --git a/images/history.png b/images/history.png new file mode 100644 index 00000000..242c1df3 Binary files /dev/null and b/images/history.png differ diff --git a/images/playlist.jpg b/images/playlist.jpg deleted file mode 100755 index b853216c..00000000 Binary files a/images/playlist.jpg and /dev/null differ diff --git a/images/playlist.png b/images/playlist.png new file mode 100644 index 00000000..6d93c640 Binary files /dev/null and b/images/playlist.png differ diff --git a/images/playlists.jpg b/images/playlists.jpg deleted file mode 100755 index bff89844..00000000 Binary files a/images/playlists.jpg and /dev/null differ diff --git a/images/playlists.png b/images/playlists.png new file mode 100644 index 00000000..349f8adf Binary files /dev/null and b/images/playlists.png differ diff --git a/images/search-list.jpg b/images/search-list.jpg deleted file mode 100755 index c5c140ad..00000000 Binary files a/images/search-list.jpg and /dev/null differ diff --git a/images/search-list.png b/images/search-list.png new file mode 100644 index 00000000..3b0bca3d Binary files /dev/null and b/images/search-list.png differ diff --git a/images/search-preview.jpg b/images/search-preview.jpg deleted file mode 100755 index e14b4d5d..00000000 Binary files a/images/search-preview.jpg and /dev/null differ diff --git a/images/search-preview.png b/images/search-preview.png new file mode 100644 index 00000000..c0fd0cd7 Binary files /dev/null and b/images/search-preview.png differ diff --git a/images/search-suggestions.jpg b/images/search-suggestions.jpg deleted file mode 100755 index 7bf7f8a5..00000000 Binary files a/images/search-suggestions.jpg and /dev/null differ diff --git a/images/search-suggestions.png b/images/search-suggestions.png new file mode 100644 index 00000000..aa01fe54 Binary files /dev/null and b/images/search-suggestions.png differ diff --git a/images/settings.jpg b/images/settings.jpg deleted file mode 100755 index 12a3b8d3..00000000 Binary files a/images/settings.jpg and /dev/null differ diff --git a/images/settings.png b/images/settings.png new file mode 100644 index 00000000..16378e7f Binary files /dev/null and b/images/settings.png differ diff --git a/images/source-install.png b/images/source-install.png index 6e63a31b..514ba077 100644 Binary files a/images/source-install.png and b/images/source-install.png differ diff --git a/images/source-settings.jpg b/images/source-settings.jpg deleted file mode 100755 index 8674ce70..00000000 Binary files a/images/source-settings.jpg and /dev/null differ diff --git a/images/source-settings.png b/images/source-settings.png new file mode 100644 index 00000000..01281a58 Binary files /dev/null and b/images/source-settings.png differ diff --git a/images/source.jpg b/images/source.jpg deleted file mode 100755 index 4df7cf0f..00000000 Binary files a/images/source.jpg and /dev/null differ diff --git a/images/source.png b/images/source.png new file mode 100644 index 00000000..7188ac6b Binary files /dev/null and b/images/source.png differ diff --git a/images/sources-disabled.jpg b/images/sources-disabled.jpg deleted file mode 100755 index db84ac83..00000000 Binary files a/images/sources-disabled.jpg and /dev/null differ diff --git a/images/sources.jpg b/images/sources.jpg deleted file mode 100755 index 1ce53652..00000000 Binary files a/images/sources.jpg and /dev/null differ diff --git a/images/subscriptions-list.png b/images/subscriptions-list.png index 62c034d6..0eb18975 100644 Binary files a/images/subscriptions-list.png and b/images/subscriptions-list.png differ diff --git a/images/subscriptions-preview.png b/images/subscriptions-preview.png index 6aafa9cd..9af23834 100644 Binary files a/images/subscriptions-preview.png and b/images/subscriptions-preview.png differ diff --git a/images/video-details.jpg b/images/video-details.jpg deleted file mode 100755 index 11d8975e..00000000 Binary files a/images/video-details.jpg and /dev/null differ diff --git a/images/video-details.png b/images/video-details.png new file mode 100644 index 00000000..f2a99aa4 Binary files /dev/null and b/images/video-details.png differ diff --git a/images/video.jpg b/images/video.jpg deleted file mode 100755 index f740a590..00000000 Binary files a/images/video.jpg and /dev/null differ diff --git a/images/video.png b/images/video.png new file mode 100644 index 00000000..fb3b6842 Binary files /dev/null and b/images/video.png differ
Casting