Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into landscape

This commit is contained in:
Koen J 2024-11-18 12:16:05 +01:00
commit a7cbb0e93c
84 changed files with 754 additions and 198 deletions

View File

@ -1,4 +1,4 @@
# Grayjay Core License 1.0 # Source First License 1.1
## Acceptance ## Acceptance
By using the software, you agree to all of the terms and conditions below. 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 Licensors trademarks is subject to applicable law. You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensors trademarks is subject to applicable law.
## Patents ## 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 ## 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. 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.

View File

@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation.
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/video.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/video.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video-details.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/video-details.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Video</td> <td>Video</td>
@ -24,12 +24,10 @@ The FUTO media app is a player that exposes multiple video websites as sources i
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/sources.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/source.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/sources-disabled.jpg" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Sources (all enabled)</td> <td>Sources</td>
<td>Sources (one disabled)</td>
</tr> </tr>
</table> </table>
@ -38,7 +36,7 @@ Additional sources can also be installed. These sources are JavaScript sources,
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source-settings.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/source-settings.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Install a new source</td> <td>Install a new source</td>
@ -54,8 +52,8 @@ When a user enters a search term into the search bar, the query is posted to th
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/search-list.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/search-list.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-preview.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/search-preview.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Search (list)</td> <td>Search (list)</td>
@ -71,7 +69,7 @@ Creators are able to configure their profile using NeoPass.
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/channel.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/channel.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Channel</td> <td>Channel</td>
@ -112,7 +110,7 @@ The app offers a lot of settings customizing how the app looks and feels. An exa
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/settings.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/settings.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Settings</td> <td>Settings</td>
@ -125,8 +123,8 @@ Playlists allow you to make a collection of videos that you can create and custo
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/playlists.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/playlists.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlist.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/playlist.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Playlists</td> <td>Playlists</td>
@ -142,7 +140,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/downloads.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/downloads.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Downloads</td> <td>Downloads</td>
@ -157,7 +155,7 @@ For more information about casting please click [here](./docs/casting.md).
<table border="0"> <table border="0">
<tr> <tr>
<td><b style="font-size:30px"><img src="images/casting.jpg" height="700" /></b></td> <td><b style="font-size:30px"><img src="images/casting.png" height="700" /></b></td>
</tr> </tr>
<tr> <tr>
<td>Casting</td> <td>Casting</td>
@ -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. 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. 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. 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. 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. 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 ## Documentation
The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview). The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).

View File

@ -486,6 +486,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21) @FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
var autoplay: Boolean = false; 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) @FormField(R.string.comments, "group", R.string.comments_description, 6)
@ -843,11 +846,15 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2) @FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
fun clearPayment() { fun clearPayment() {
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
StatePayment.instance.clearLicenses(); StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart)); UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings(); it.reloadSettings();
} }
})
}
} }
} }
@ -855,7 +862,10 @@ class Settings : FragmentedStorageFileJson() {
var other = Other(); var other = Other();
@Serializable @Serializable
class Other { 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; var polycentricEnabled: Boolean = true;
} }

View File

@ -348,6 +348,13 @@ 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 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) { fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
val dialog = AutoUpdateDialog(context); val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog); registerDialogOpened(dialog);

View File

@ -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.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.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.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
@ -879,6 +880,12 @@ class UISlideOverlays {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); 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) { if (lastUpdated != null) {
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist", SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
@ -899,6 +906,7 @@ 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(
if(!isLimited)
SlideUpMenuItem( SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_download, R.drawable.ic_download,
@ -909,7 +917,7 @@ class UISlideOverlays {
showDownloadVideoOverlay(video, container, true); showDownloadVideoOverlay(video, container, true);
}, },
invokeParent = false invokeParent = false
), ) else null,
SlideUpMenuItem( SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_share, R.drawable.ic_share,
@ -936,7 +944,7 @@ class UISlideOverlays {
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");
})) }))
+ actions) + actions).filterNotNull()
)); ));
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", 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), "${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later", tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), 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<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();

View File

@ -50,7 +50,8 @@ class SourcePluginConfig(
var primaryClaimFieldType: Int? = null, var primaryClaimFieldType: Int? = null,
var developerSubmitUrl: String? = null, var developerSubmitUrl: String? = null,
var allowAllHttpHeaderAccess: Boolean = false, var allowAllHttpHeaderAccess: Boolean = false,
var maxDownloadParallelism: Int = 0 var maxDownloadParallelism: Int = 0,
var reduceFunctionsInLimitedVersion: Boolean = false,
) : IV8PluginConfig { ) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);

View File

@ -71,6 +71,8 @@ abstract class JSPager<T> : IPager<T> {
warnIfMainThread("JSPager.getResults"); warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager"); val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
if(items.v8Runtime.isDead || items.v8Runtime.isClosed)
throw IllegalStateException("Runtime closed");
val newResults = items.toArray() val newResults = items.toArray()
.map { convertResult(it as V8ValueObject) } .map { convertResult(it as V8ValueObject) }
.toList(); .toList();

View File

@ -22,6 +22,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonCancel: ImageButton; private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText; private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager; private lateinit var _inputMethodManager: InputMethodManager;
@ -34,6 +35,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
_buttonStop = findViewById(R.id.button_stop); _buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start); _buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password); _editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
@ -52,6 +54,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
} }
_buttonStart.setOnClickListener { _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(); val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) { if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false); UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);

View File

@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.futopay.PaymentConfigurations import com.futo.futopay.PaymentConfigurations
import com.futo.futopay.PaymentManager import com.futo.futopay.PaymentManager
import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
@ -94,9 +95,8 @@ class BuyFragment : MainFragment() {
if(currency != null && prices.containsKey(currency.id)) { if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!; val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) { 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);
} }
} }
} }

View File

@ -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.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("Cancel", {}),
UIDialogs.Action("Delete", { UIDialogs.Action("Delete", {
StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id); StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id, true);
_didDelete = true; _didDelete = true;
fragment.close(true); fragment.close(true);
}, UIDialogs.ActionStyle.DANGEROUS)) }, UIDialogs.ActionStyle.DANGEROUS))
@ -253,7 +253,7 @@ class SubscriptionGroupFragment : MainFragment() {
if(g.urls.isEmpty() && g.image == null) { if(g.urls.isEmpty() && g.image == null) {
//Obtain image //Obtain image
for(sub in it) { 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) { if(sub != null && sub.channel.thumbnail != null) {
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!); g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
g.image?.setImageView(_imageGroup); g.image?.setImageView(_imageGroup);
@ -308,8 +308,10 @@ class SubscriptionGroupFragment : MainFragment() {
if(group != null) { if(group != null) {
val urls = group.urls.toList(); val urls = group.urls.toList();
val subs = StateSubscriptions.instance.getSubscriptions().map { it.channel } val subs = urls.map {
_enabledCreators.addAll(subs.filter { urls.contains(it.url) }); (StateSubscriptions.instance.getSubscription(it) ?: StateSubscriptions.instance.getSubscriptionOther(it))?.channel
}.filterNotNull();
_enabledCreators.addAll(subs);
} }
updateMeta(); updateMeta();
filterCreators(); filterCreators();

View File

@ -14,6 +14,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.AddSourceOptionsActivity import com.futo.platformplayer.activities.AddSourceOptionsActivity
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@ -57,10 +58,19 @@ class SubscriptionGroupListFragment : MainFragment() {
}; };
it.onDelete.subscribe { group -> it.onDelete.subscribe { group ->
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); val loc = _subs.indexOf(group);
_subs.remove(group); _subs.remove(group);
_list?.adapter?.notifyItemRangeRemoved(loc); _list?.adapter?.notifyItemRangeRemoved(loc);
StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id); StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true);
}, UIDialogs.ActionStyle.DANGEROUS));
}
}; };
it.onDragDrop.subscribe { it.onDragDrop.subscribe {
_touchHelper?.startDrag(it); _touchHelper?.startDrag(it);

View File

@ -26,6 +26,7 @@ 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.StateHistory
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.stores.FragmentedStorageFileJson
@ -362,6 +363,7 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
override fun reload() { override fun reload() {
StatePlugins.instance.clearUpdating(); //Fallback in case it doesnt clear, UI should be blocked.
loadResults(true); loadResults(true);
} }

View File

@ -143,9 +143,7 @@ class VideoDetailFragment : MainFragment {
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint("SourceLockedOrientationActivity")
fun updateOrientation() { fun updateOrientation() {
val a = activity ?: return val a = activity ?: return
// only applies to small windows
val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait
val isReversePortraitAllowed = Settings.instance.playback.reversePortrait val isReversePortraitAllowed = Settings.instance.playback.reversePortrait
val rotationLock = StatePlayer.instance.rotationLock val rotationLock = StatePlayer.instance.rotationLock

View File

@ -41,6 +41,7 @@ import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
@ -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.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.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.models.JSVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager 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.StatePlugins
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.states.StateSync
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.db.types.DBHistory 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.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
@ -642,6 +647,27 @@ class VideoDetailView : ConstraintLayout {
StatePlayer.instance.onVideoChanging.subscribe(this) { StatePlayer.instance.onVideoChanging.subscribe(this) {
setVideoOverview(it); 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.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() }; MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() }; MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
@ -717,6 +743,7 @@ class VideoDetailView : ConstraintLayout {
}; };
onClose.subscribe { onClose.subscribe {
checkAndRemoveWatchLater();
_lastVideoSource = null; _lastVideoSource = null;
_lastAudioSource = null; _lastAudioSource = null;
_lastSubtitleSource = null; _lastSubtitleSource = null;
@ -825,6 +852,11 @@ class VideoDetailView : ConstraintLayout {
} }
fun updateMoreButtons() { 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) { val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let { (video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) { _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
@ -844,6 +876,7 @@ class VideoDetailView : ConstraintLayout {
} }
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
} else null, } else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) { RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) { if(!allowBackground) {
_player.switchToAudioMode(); _player.switchToAudioMode();
@ -856,12 +889,15 @@ class VideoDetailView : ConstraintLayout {
it.text.text = resources.getString(R.string.background); it.text.text = resources.getString(R.string.background);
} }
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
}, }
else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let { video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
}; };
}, }
else null,
RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) { RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) {
video?.let { video?.let {
Logger.i(TAG, "Share preventPictureInPicture = true"); Logger.i(TAG, "Share preventPictureInPicture = true");
@ -870,12 +906,14 @@ class VideoDetailView : ConstraintLayout {
}; };
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
}, },
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) { RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture(); this.startPictureInPicture();
fragment.forcePictureInPicture(); fragment.forcePictureInPicture();
//PiPActivity.startPiP(context); //PiPActivity.startPiP(context);
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
}, }
else null,
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) { RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let { video?.let {
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url; val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
@ -884,6 +922,22 @@ class VideoDetailView : ConstraintLayout {
}; };
_slideUpOverlay?.hide(); _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") { RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo(); reloadVideo();
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
@ -1031,6 +1085,8 @@ class VideoDetailView : ConstraintLayout {
StateApp.instance.preventPictureInPicture.remove(this); StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this); StatePlayer.instance.onVideoChanging.remove(this);
StateSync.instance.deviceUpdatedOrAdded.remove(this);
StateSync.instance.deviceRemoved.remove(this);
MediaControlReceiver.onLowerVolumeReceived.remove(this); MediaControlReceiver.onLowerVolumeReceived.remove(this);
MediaControlReceiver.onPlayReceived.remove(this); MediaControlReceiver.onPlayReceived.remove(this);
MediaControlReceiver.onPauseReceived.remove(this); MediaControlReceiver.onPauseReceived.remove(this);
@ -1853,6 +1909,8 @@ class VideoDetailView : ConstraintLayout {
fun prevVideo(withoutRemoval: Boolean = false) { fun prevVideo(withoutRemoval: Boolean = false) {
Logger.i(TAG, "prevVideo") Logger.i(TAG, "prevVideo")
checkAndRemoveWatchLater();
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, true, 0, true); setVideoOverview(next, true, 0, true);
@ -1861,6 +1919,8 @@ class VideoDetailView : ConstraintLayout {
fun nextVideo(forceLoop: Boolean = false, withoutRemoval: Boolean = false, bypassVideoLoop: Boolean = false): Boolean { fun nextVideo(forceLoop: Boolean = false, withoutRemoval: Boolean = false, bypassVideoLoop: Boolean = false): Boolean {
Logger.i(TAG, "nextVideo") Logger.i(TAG, "nextVideo")
checkAndRemoveWatchLater();
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop); var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
val autoplayVideo = _autoplayVideo val autoplayVideo = _autoplayVideo
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) { if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
@ -1869,7 +1929,8 @@ class VideoDetailView : ConstraintLayout {
next = autoplayVideo next = autoplayVideo
} }
_autoplayVideo = null _autoplayVideo = null
Logger.i(TAG, "Autoplay video cleared (nextVideo)") Logger.i(TAG, "Autoplay video cleared (nextVideo)");
if(next == null && forceLoop) if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue(); next = StatePlayer.instance.restartQueue();
if(next != null) { if(next != null) {
@ -1881,6 +1942,20 @@ class VideoDetailView : ConstraintLayout {
return false; 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 //Quality Selector data
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) { private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
val v = video ?: return; val v = video ?: return;
@ -2952,6 +3027,7 @@ class VideoDetailView : ConstraintLayout {
const val TAG_OVERLAY = "overlay"; const val TAG_OVERLAY = "overlay";
const val TAG_LIVECHAT = "livechat"; const val TAG_LIVECHAT = "livechat";
const val TAG_OPEN = "open"; const val TAG_OPEN = "open";
const val TAG_SEND_TO_DEVICE = "send_to_device";
const val TAG_MORE = "MORE"; const val TAG_MORE = "MORE";
private val _buttonPinStore = FragmentedStorage.get<StringArrayStorage>("videoPinnedButtons"); private val _buttonPinStore = FragmentedStorage.get<StringArrayStorage>("videoPinnedButtons");

View File

@ -191,21 +191,21 @@ class VideoHelper {
} }
fun estimateSourceSize(source: IVideoSource?): Int { fun estimateSourceSize(source: IVideoSource?): Long {
if(source == null) return 0; if(source == null) return 0;
if(source is IVideoSource) { if(source is IVideoSource) {
if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0) if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0)
return 0; return 0;
return (source.duration / 8).toInt() * source.bitrate!!; return (source.duration / 8) * source.bitrate!!;
} }
else return 0; else return 0;
} }
fun estimateSourceSize(source: IAudioSource?): Int { fun estimateSourceSize(source: IAudioSource?): Long {
if(source == null) return 0; if(source == null) return 0;
if(source is IAudioSource) { if(source is IAudioSource) {
if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0) if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0)
return 0; return 0;
return (source.duration!! / 8).toInt() * source.bitrate; return (source.duration!! / 8) * source.bitrate;
} }
else return 0; else return 0;
} }

View File

@ -46,7 +46,10 @@ class MDNSListener {
} }
fun start() { fun start() {
if (_started) throw Exception("Already running.") if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true _started = true
_scope = CoroutineScope(Dispatchers.IO); _scope = CoroutineScope(Dispatchers.IO);

View File

@ -37,7 +37,10 @@ class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (L
} }
fun start() { fun start() {
if (_started) throw Exception("Already running.") if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true _started = true
val listener = MDNSListener() val listener = MDNSListener()

View File

@ -100,7 +100,6 @@ class ServiceRecordAggregator {
Logger.i(TAG, "$builder")*/ Logger.i(TAG, "$builder")*/
val currentServices: MutableList<DnsService> val currentServices: MutableList<DnsService>
synchronized(this._currentServices) {
ptrRecords.forEach { record -> ptrRecords.forEach { record ->
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() } val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName) val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
@ -127,6 +126,8 @@ class ServiceRecordAggregator {
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second) _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
} }
//TODO: Maybe this can be debounced?
synchronized(this._currentServices) {
currentServices = getCurrentServices() currentServices = getCurrentServices()
this._currentServices.clear() this._currentServices.clear()
this._currentServices.addAll(currentServices) this._currentServices.addAll(currentServices)

View File

@ -1,5 +1,7 @@
package com.futo.platformplayer.models package com.futo.platformplayer.models
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
@ -10,6 +12,11 @@ open class SubscriptionGroup {
var urls: MutableList<String> = mutableListOf(); var urls: MutableList<String> = mutableListOf();
var priority: Int = 99; 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) { constructor(name: String) {
this.name = name; this.name = name;
} }
@ -19,6 +26,8 @@ open class SubscriptionGroup {
this.image = parent.image; this.image = parent.image;
this.urls = parent.urls; this.urls = parent.urls;
this.priority = parent.priority; this.priority = parent.priority;
this.lastChange = parent.lastChange;
this.creationTime = parent.creationTime;
} }
class Selectable(parent: SubscriptionGroup, isSelected: Boolean = false): SubscriptionGroup(parent) { class Selectable(parent: SubscriptionGroup, isSelected: Boolean = false): SubscriptionGroup(parent) {

View File

@ -12,6 +12,8 @@ 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 com.futo.platformplayer.views.behavior.NonScrollingTextView
import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class PlatformLinkMovementMethod : LinkMovementMethod { class PlatformLinkMovementMethod : LinkMovementMethod {
@ -23,6 +25,7 @@ class PlatformLinkMovementMethod : LinkMovementMethod {
override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean {
val action = event.action; val action = event.action;
Logger.i(TAG, "onTouchEvent (action = $action)")
if (action == MotionEvent.ACTION_UP) { if (action == MotionEvent.ACTION_UP) {
val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX; val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX;
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY; val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY;

View File

@ -2,6 +2,8 @@ package com.futo.platformplayer.states
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@ -20,9 +22,13 @@ import androidx.lifecycle.lifecycleScope
import androidx.work.* import androidx.work.*
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.R 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.CaptchaActivity
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.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
@ -419,8 +425,17 @@ class StateApp {
Logger.onLogSubmitted.subscribe { Logger.onLogSubmitted.subscribe {
scopeOrNull?.launch(Dispatchers.Main) { scopeOrNull?.launch(Dispatchers.Main) {
try { try {
if (it != null) { if (!it.isNullOrEmpty()) {
UIDialogs.toast("Uploaded $it", true); (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 { } else {
UIDialogs.toast("Failed to upload"); UIDialogs.toast("Failed to upload");
} }

View File

@ -11,6 +11,7 @@ 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.channels.SerializedChannel
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.copyTo import com.futo.platformplayer.copyTo
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider 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.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.HistoryVideo
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.SubscriptionGroup
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
@ -61,9 +64,9 @@ class StateBackup {
StatePlaylists.instance.toMigrateCheck() StatePlaylists.instance.toMigrateCheck()
).flatten(); ).flatten();
fun getCache(): ImportCache { fun getCache(additionalVideos: List<SerializedPlatformVideo> = listOf()): ImportCache {
val allPlaylists = StatePlaylists.instance.getPlaylists(); 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 allSubscriptions = StateSubscriptions.instance.getSubscriptions();
val channels = allSubscriptions.map { it.channel }; val channels = allSubscriptions.map { it.channel };
@ -240,6 +243,23 @@ class StateBackup {
.associateBy { it.name } .associateBy { it.name }
.mapValues { it.value.getAllReconstructionStrings() } .mapValues { it.value.getAllReconstructionStrings() }
.toMutableMap(); .toMutableMap();
var historyVideos: List<SerializedPlatformVideo>? = 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 settings = Settings.instance.encode();
val pluginSettings = StatePlugins.instance.getPlugins() val pluginSettings = StatePlugins.instance.getPlugins()
.associateBy { it.config.id } .associateBy { it.config.id }
@ -249,7 +269,7 @@ class StateBackup {
.associateBy { it.config.id } .associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! }; .mapValues { it.value.config.sourceUrl!! };
val cache = getCache(); val cache = getCache(historyVideos ?: listOf());
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings, cache); val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings, cache);
@ -333,6 +353,50 @@ class StateBackup {
if(doImportStores) { if(doImportStores) {
for(store in export.stores) { for(store in export.stores) {
Logger.i(TAG, "Importing store [${store.key}]"); Logger.i(TAG, "Importing store [${store.key}]");
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))
}
}
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<SubscriptionGroup>(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 }; val relevantStore = availableStores.find { it.name == store.key };
if (relevantStore == null) { if (relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import"); Logger.w(TAG, "Unknown store [${store.key}] import");
@ -351,6 +415,7 @@ class StateBackup {
} }
} }
} }
}
if (doImportPlugins) { if (doImportPlugins) {
Logger.i(TAG, "Importing plugins"); Logger.i(TAG, "Importing plugins");

View File

@ -59,7 +59,6 @@ class StateHistory {
return getHistoryPosition(url) > duration * 0.7; 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 { 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 pos = if(position < 0) 0 else position;
val historyVideo = index.obj; val historyVideo = index.obj;

View File

@ -17,10 +17,18 @@ 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.ImportCache
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
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.StringDateMapStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.stores.v2.ReconstructStore 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.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
@ -45,6 +53,7 @@ class StatePlaylists {
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists") val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup()) .withRestore(PlaylistBackup())
.load(); .load();
private val _playlistRemoved = FragmentedStorage.get<StringDateMapStorage>("playlist_removed");
val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares");
@ -81,6 +90,18 @@ class StatePlaylists {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); 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) { fun removeFromWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
_watchlistStore.delete(video); _watchlistStore.delete(video);
@ -118,6 +139,9 @@ class StatePlaylists {
return playlistStore.findItem { it.id == id }; return playlistStore.findItem { it.id == id };
} }
fun getPlaylistRemovals(): Map<String, Long> {
return _playlistRemoved.all();
}
fun didPlay(playlistId: String) { fun didPlay(playlistId: String) {
val playlist = getPlaylist(playlistId); val playlist = getPlaylist(playlistId);
@ -148,13 +172,15 @@ class StatePlaylists {
createOrUpdatePlaylist(newPlaylist); createOrUpdatePlaylist(newPlaylist);
return newPlaylist; return newPlaylist;
} }
fun createOrUpdatePlaylist(playlist: Playlist) { fun createOrUpdatePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) {
playlist.dateUpdate = OffsetDateTime.now(); playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true); playlistStore.saveAsync(playlist, true);
if(playlist.id.isNotEmpty()) { if(playlist.id.isNotEmpty()) {
if (StateDownloads.instance.isPlaylistCached(playlist.id)) { if (StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(playlist.id); StateDownloads.instance.checkForOutdatedPlaylistVideos(playlist.id);
} }
if(isUserInteraction)
broadcastSyncPlaylist(playlist);
} }
} }
fun addToPlaylist(id: String, video: IPlatformVideo) { fun addToPlaylist(id: String, video: IPlatformVideo) {
@ -163,14 +189,41 @@ class StatePlaylists {
playlist.videos.add(SerializedPlatformVideo.fromVideo(video)); playlist.videos.add(SerializedPlatformVideo.fromVideo(video));
playlist.dateUpdate = OffsetDateTime.now(); playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true); 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); playlistStore.delete(playlist);
if(StateDownloads.instance.isPlaylistCached(playlist.id)) { if(StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.deleteCachedPlaylist(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 { fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri {
@ -194,6 +247,16 @@ class StatePlaylists {
return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile); return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile);
} }
fun getSyncPlaylistsPackageString(): String{
return Json.encodeToString(
SyncPlaylistsPackage(
getPlaylists(),
getPlaylistRemovals()
)
);
}
companion object { companion object {
val TAG = "StatePlaylists"; val TAG = "StatePlaylists";
private var _instance : StatePlaylists? = null; private var _instance : StatePlaylists? = null;

View File

@ -19,6 +19,7 @@ import com.futo.platformplayer.stores.PluginIconStorage
import com.futo.platformplayer.stores.PluginScriptsDirectory import com.futo.platformplayer.stores.PluginScriptsDirectory
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -47,6 +48,8 @@ class StatePlugins {
private var _updatesAvailableMap: HashSet<String> = hashSetOf(); private var _updatesAvailableMap: HashSet<String> = hashSetOf();
private val _isUpdating: HashSet<String> = hashSetOf();
fun getPluginIconOrNull(id: String): ImageVariable? { fun getPluginIconOrNull(id: String): ImageVariable? {
if(iconsDir.hasIcon(id)) if(iconsDir.hasIcon(id))
return iconsDir.getIconBinary(id); return iconsDir.getIconBinary(id);
@ -58,6 +61,38 @@ class StatePlugins {
.load(); .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<Pair<SourcePluginConfig, SourcePluginConfig>> = withContext(Dispatchers.IO) { suspend fun checkForUpdates(): List<Pair<SourcePluginConfig, SourcePluginConfig>> = withContext(Dispatchers.IO) {
var configs = mutableListOf<Pair<SourcePluginConfig, SourcePluginConfig>>() var configs = mutableListOf<Pair<SourcePluginConfig, SourcePluginConfig>>()
@ -430,6 +465,12 @@ class StatePlugins {
fun installPluginBackground(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, onProgress: (text: String, progress: Double)->Unit, onConcluded: (ex: Throwable?)->Unit) { fun installPluginBackground(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, onProgress: (text: String, progress: Double)->Unit, onConcluded: (ex: Throwable?)->Unit) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
whileUpdating(config.id) {
withContext(Dispatchers.Main) {
onProgress.invoke("Waiting for plugins to finish", 0.1);
}
delay(500);
val client = ManagedHttpClient(); val client = ManagedHttpClient();
try { try {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -470,6 +511,7 @@ class StatePlugins {
} }
} }
} }
}
fun getPlugin(id: String): SourcePluginDescriptor? { fun getPlugin(id: String): SourcePluginDescriptor? {
if(id == StateDeveloper.DEV_ID) if(id == StateDeveloper.DEV_ID)

View File

@ -25,13 +25,20 @@ 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
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StateHistory.Companion
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms 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.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
@ -51,6 +58,9 @@ class StateSubscriptionGroups {
.withUnique { it.id } .withUnique { it.id }
.load(); .load();
private val _groupsRemoved = FragmentedStorage.get<StringDateMapStorage>("group_removed");
val onGroupsChanged = Event0(); val onGroupsChanged = Event0();
fun getSubscriptionGroup(id: String): SubscriptionGroup? { fun getSubscriptionGroup(id: String): SubscriptionGroup? {
@ -59,17 +69,64 @@ class StateSubscriptionGroups {
fun getSubscriptionGroups(): List<SubscriptionGroup> { fun getSubscriptionGroups(): List<SubscriptionGroup> {
return _subGroups.getItems(); return _subGroups.getItems();
} }
fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false) { fun getSubscriptionGroupsRemovals(): Map<String, Long> {
return _groupsRemoved.all();
}
fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false, preventSync: Boolean = false) {
subGroup.lastChange = OffsetDateTime.now();
_subGroups.save(subGroup); _subGroups.save(subGroup);
if(!preventNotify) if(!preventNotify)
onGroupsChanged.emit(); 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); val group = getSubscriptionGroup(id);
if(group != null) { if(group != null) {
_subGroups.delete(group); _subGroups.delete(group);
onGroupsChanged.emit(); 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()
)
);
} }

View File

@ -202,13 +202,13 @@ class StateSubscriptions {
return _subscriptionOthers.findItem { it.isChannel(url)}; return _subscriptionOthers.findItem { it.isChannel(url)};
} }
} }
fun getSubscriptionOtherOrCreate(url: String) : Subscription { fun getSubscriptionOtherOrCreate(url: String, name: String? = null, thumbnail: String? = null) : Subscription {
synchronized(_subscriptionOthers) { synchronized(_subscriptionOthers) {
val sub = getSubscriptionOther(url); val sub = getSubscriptionOther(url);
if(sub == null) { 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; newSub.isOther = true;
_subscriptions.save(newSub); _subscriptionOthers.save(newSub);
return newSub; return newSub;
} }
else return sub; else return sub;
@ -293,8 +293,29 @@ class StateSubscriptions {
if(sub != null) { if(sub != null) {
_subscriptions.delete(sub); _subscriptions.delete(sub);
onSubscriptionsChanged.emit(getSubscriptions(), false); onSubscriptionsChanged.emit(getSubscriptions(), false);
if(isUserAction) if(isUserAction) {
_subscriptionsRemoved.setAndSave(sub.channel.url, OffsetDateTime.now()); 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; return sub;
} }

View File

@ -66,6 +66,10 @@ class StateSync {
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2() val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun start() { fun start() {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true _started = true
if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) { if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) {

View File

@ -22,6 +22,7 @@ import com.futo.platformplayer.models.Subscription
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.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
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.time.OffsetDateTime
@ -138,6 +139,18 @@ abstract class SubscriptionsTaskFetchAlgorithm(
for(task in tasks) { for(task in tasks) {
val forkTask = threadPool.submit<SubscriptionTaskResult> { val forkTask = threadPool.submit<SubscriptionTaskResult> {
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) { if(task.fromPeek) {
try { try {

View File

@ -11,5 +11,7 @@ class GJSyncOpcodes {
val syncSubscriptions: UByte = 202.toUByte(); val syncSubscriptions: UByte = 202.toUByte();
val syncHistory: UByte = 203.toUByte(); val syncHistory: UByte = 203.toUByte();
val syncSubscriptionGroups: UByte = 204.toUByte();
val syncPlaylists: UByte = 205.toUByte();
} }
} }

View File

@ -6,15 +6,20 @@ import com.futo.platformplayer.api.media.Serializer
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.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
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.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer 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.StateSubscriptions
import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.SyncSessionData import com.futo.platformplayer.sync.SyncSessionData
import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode
import com.futo.platformplayer.sync.models.SendToDevicePackage 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 com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -22,7 +27,9 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset
interface IAuthorizable { interface IAuthorizable {
val isAuthorized: Boolean val isAuthorized: Boolean
@ -158,6 +165,8 @@ class SyncSession : IAuthorizable {
send(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); 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); val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
if(recentHistory.size > 0) 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<SyncSubscriptionGroupsPackage>(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<SyncPlaylistsPackage>(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 -> { GJSyncOpcodes.syncHistory -> {
val dataBody = ByteArray(data.remaining()); val dataBody = ByteArray(data.remaining());
data.get(dataBody); data.get(dataBody);
@ -242,8 +312,7 @@ class SyncSession : IAuthorizable {
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
if(sub.creationTime > removalTime) { if(sub.creationTime > removalTime) {
val newSub = val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
added.add(newSub); added.add(newSub);
} }
} }

View File

@ -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<Playlist>,
var playlistRemovals: Map<String, Long>
)

View File

@ -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<SubscriptionGroup>,
var groupRemovals: Map<String, Long>
)

View File

@ -6,6 +6,8 @@ import android.widget.FrameLayout
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
@ -53,6 +55,7 @@ class VideoListEditorView : FrameLayout {
}; };
adapterVideos.onRemove.subscribe { v -> adapterVideos.onRemove.subscribe { v ->
val executeDelete = {
synchronized(_videos) { synchronized(_videos) {
val index = _videos.indexOf(v); val index = _videos.indexOf(v);
if(index >= 0) { if(index >= 0) {
@ -61,6 +64,21 @@ class VideoListEditorView : FrameLayout {
} }
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); adapterVideos.onClick.subscribe(onVideoClicked::emit);

View File

@ -71,6 +71,16 @@
android:singleLine="true" android:singleLine="true"
android:inputType="textPassword" android:inputType="textPassword"
android:hint="@string/backup_password" /> android:hint="@string/backup_password" />
<EditText
android:id="@+id/edit_password2"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textPassword"
android:hint="@string/repeat_password" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -82,6 +82,7 @@
<string name="yes">Yes</string> <string name="yes">Yes</string>
<string name="no">No</string> <string name="no">No</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="do_not_ask_again">Don\'t ask again</string>
<string name="confirm_delete_playlist">Are you sure you want to delete this playlist?</string> <string name="confirm_delete_playlist">Are you sure you want to delete this playlist?</string>
<string name="confirm_delete_subscription">Are you sure you want to delete this subscription?</string> <string name="confirm_delete_subscription">Are you sure you want to delete this subscription?</string>
<string name="confirm_remove_source">Removing this source will result in some of your subscriptions not being resolved.</string> <string name="confirm_remove_source">Removing this source will result in some of your subscriptions not being resolved.</string>
@ -180,6 +181,7 @@
<string name="set_a_password_for_your_daily_backup">Set a password for your daily backup</string> <string name="set_a_password_for_your_daily_backup">Set a password for your daily backup</string>
<string name="set_a_password_used_to_encrypt_your_daily_backup_that_is_written_to_external_storage">Set a password used to encrypt your daily backup that is written to external storage.</string> <string name="set_a_password_used_to_encrypt_your_daily_backup_that_is_written_to_external_storage">Set a password used to encrypt your daily backup that is written to external storage.</string>
<string name="backup_password">Backup Password</string> <string name="backup_password">Backup Password</string>
<string name="repeat_password">Repeat Password</string>
<string name="restore_from_automatic_backup">Restore from Automatic Backup</string> <string name="restore_from_automatic_backup">Restore from Automatic Backup</string>
<string name="it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password">It appears an automatic backup exists on your device, if you would like to restore, enter your backup password.</string> <string name="it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password">It appears an automatic backup exists on your device, if you would like to restore, enter your backup password.</string>
<string name="restore">Restore</string> <string name="restore">Restore</string>
@ -398,6 +400,9 @@
<string name="autoplay">Enable autoplay by default</string> <string name="autoplay">Enable autoplay by default</string>
<string name="autoplay_description">Autoplay will be enabled by default whenever you watch a video</string> <string name="autoplay_description">Autoplay will be enabled by default whenever you watch a video</string>
<string name="allow_full_screen_portrait">Allow full screen portrait</string> <string name="allow_full_screen_portrait">Allow full screen portrait</string>
<string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string>
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
<string name="allow_full_screen_portrait">Allow fullscreen portrait</string>
<string name="background_switch_audio">Switch to Audio in Background</string> <string name="background_switch_audio">Switch to Audio in Background</string>
<string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string> <string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>
<string name="subscription_group_menu">Groups</string> <string name="subscription_group_menu">Groups</string>
@ -417,6 +422,8 @@
<string name="payment">Payment</string> <string name="payment">Payment</string>
<string name="payment_status">Payment Status</string> <string name="payment_status">Payment Status</string>
<string name="bypass_rotation_prevention">Bypass Rotation Prevention</string> <string name="bypass_rotation_prevention">Bypass Rotation Prevention</string>
<string name="playlist_delete_confirmation">Playlist Delete Confirmation</string>
<string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string>
<string name="enable_polycentric">Enable Polycentric</string> <string name="enable_polycentric">Enable Polycentric</string>
<string name="can_be_disabled_when_you_are_experiencing_issues">Can be disabled when you are experiencing issues</string> <string name="can_be_disabled_when_you_are_experiencing_issues">Can be disabled when you are experiencing issues</string>
<string name="bypass_rotation_prevention_description">Allows for rotation on non-video views.\nWARNING: Not designed for it</string> <string name="bypass_rotation_prevention_description">Allows for rotation on non-video views.\nWARNING: Not designed for it</string>
@ -608,6 +615,7 @@
<string name="do_you_want_to_convert_channel_channelname_to_a_playlist">Do you want to convert channel {channelName} to a playlist?</string> <string name="do_you_want_to_convert_channel_channelname_to_a_playlist">Do you want to convert channel {channelName} to a playlist?</string>
<string name="failed_to_convert_channel">Failed to convert channel</string> <string name="failed_to_convert_channel">Failed to convert channel</string>
<string name="page">Page</string> <string name="page">Page</string>
<string name="send_to_device">Sync Video</string>
<string name="hide">Hide</string> <string name="hide">Hide</string>
<string name="hide_from_home">Hide from Home</string> <string name="hide_from_home">Hide from Home</string>
<string name="hide_creator_from_home">Hide Creator from Home</string> <string name="hide_creator_from_home">Hide Creator from Home</string>

@ -1 +1 @@
Subproject commit 5809463f3dc2fd81fb92740ede467e271b5ca0c3 Subproject commit 9dedbca4f27cfca2e2a146d6edb6a9bae7541d67

@ -1 +1 @@
Subproject commit ed6e7fe340f2b90c3f9ad35993c5b0bf89593c29 Subproject commit 9e6dcf093538511eac56dc44a32b99139f1f1005

@ -1 +1 @@
Subproject commit b8ceab3e572be982171ceac09be7a7ad7878b8e8 Subproject commit 59774ac08406e29f1408cb461caa5b79c805c6e1

@ -1 +1 @@
Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 Subproject commit 7b66aea99f08303eedea879b236c49132669d2b8

@ -1 +1 @@
Subproject commit b94d5a5091ae0929d82c703868616158607a4436 Subproject commit 75ca0c0f1e31394ec4c82d5320fa9330df849f6f

@ -1 +1 @@
Subproject commit 95ae01d5358328583fc3a3b59a2a0ca9d06301d2 Subproject commit 80c9b4d3b48739170b40b313be930329dcc59fe4

View File

@ -38,6 +38,8 @@
<data android:host="bitchute.com" /> <data android:host="bitchute.com" />
<data android:host="www.bitchute.com" /> <data android:host="www.bitchute.com" />
<data android:host="old.bitchute.com" /> <data android:host="old.bitchute.com" />
<data android:host="open.spotify.com" />
<data android:host="music.youtube.com" />
<data android:pathPrefix="/" /> <data android:pathPrefix="/" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
@ -67,6 +69,8 @@
<data android:host="bitchute.com" /> <data android:host="bitchute.com" />
<data android:host="www.bitchute.com" /> <data android:host="www.bitchute.com" />
<data android:host="old.bitchute.com" /> <data android:host="old.bitchute.com" />
<data android:host="open.spotify.com" />
<data android:host="music.youtube.com" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>

@ -1 +1 @@
Subproject commit 5809463f3dc2fd81fb92740ede467e271b5ca0c3 Subproject commit 9dedbca4f27cfca2e2a146d6edb6a9bae7541d67

@ -1 +1 @@
Subproject commit ed6e7fe340f2b90c3f9ad35993c5b0bf89593c29 Subproject commit 9e6dcf093538511eac56dc44a32b99139f1f1005

@ -1 +1 @@
Subproject commit b8ceab3e572be982171ceac09be7a7ad7878b8e8 Subproject commit 59774ac08406e29f1408cb461caa5b79c805c6e1

@ -1 +1 @@
Subproject commit 5b1919934d20f8c53de9959b04bdb66e0c6af3e9 Subproject commit 7b66aea99f08303eedea879b236c49132669d2b8

@ -1 +1 @@
Subproject commit b94d5a5091ae0929d82c703868616158607a4436 Subproject commit 75ca0c0f1e31394ec4c82d5320fa9330df849f6f

@ -1 +1 @@
Subproject commit 95ae01d5358328583fc3a3b59a2a0ca9d06301d2 Subproject commit 80c9b4d3b48739170b40b313be930329dcc59fe4

@ -1 +1 @@
Subproject commit c8992e6a0ef462d11dfaf716ebe1caf46c926611 Subproject commit c3f532c660527ae579c1dff0d2f9f4d8ea4d3173

Binary file not shown.

Before

Width:  |  Height:  |  Size: 789 KiB

BIN
images/casting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
images/channel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
images/downloads.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

BIN
images/history.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

BIN
images/playlist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

BIN
images/playlists.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

BIN
images/search-list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

BIN
images/search-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

BIN
images/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

BIN
images/source-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

BIN
images/source.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/video-details.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
images/video.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB