Merge branch 'master' into 'subscription-submission-modal'

# Conflicts:
#   app/src/main/res/values/strings.xml
This commit is contained in:
Kai DeLorenzo 2024-11-18 15:05:10 +00:00
commit a69692be18
158 changed files with 1088 additions and 292 deletions

View file

@ -49,9 +49,23 @@ We encourage developers to write their own plugins. Please refer to the "Getting
## Contributing to Core
**We are currently not accepting contributions to the core.**
The core is currently licensed under the FUTO Temporary License (FTL). The licensing and ownership of contributions to the core are complex topics that we are still working on. We'll update these guidelines when we have more clarity.
### License
The core is currently licensed under the [Source First License 1.1](./LICENSE.md). All contributors have to sign FUTO Individual Contributor License Agreement before contributions can be accepted. You can read more about it at [https://cla.futo.org/](https://cla.futo.org/).
### How to Contribute
1. Fork the core repository.
2. Clone your fork.
3. Make your changes.
4. Commit and push your changes.
5. Open a pull request.
### Guidelines
- Ensure your code adheres to the existing style.
- Include documentation and unit tests (where applicable).
---

View file

@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation.
<table border="0">
<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-details.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.png" height="700" /></b></td>
</tr>
<tr>
<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">
<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/sources-disabled.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source.png" height="700" /></b></td>
</tr>
<tr>
<td>Sources (all enabled)</td>
<td>Sources (one disabled)</td>
<td>Sources</td>
</tr>
</table>
@ -38,7 +36,7 @@ Additional sources can also be installed. These sources are JavaScript sources,
<table border="0">
<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-settings.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source-settings.png" height="700" /></b></td>
</tr>
<tr>
<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">
<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-preview.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.png" height="700" /></b></td>
</tr>
<tr>
<td>Search (list)</td>
@ -71,7 +69,7 @@ Creators are able to configure their profile using NeoPass.
<table border="0">
<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>
<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">
<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>
<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">
<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/playlist.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.png" height="700" /></b></td>
</tr>
<tr>
<td>Playlists</td>
@ -142,7 +140,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
<table border="0">
<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>
<td>Downloads</td>
@ -157,7 +155,7 @@ For more information about casting please click [here](./docs/casting.md).
<table border="0">
<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>
<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.
2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository.
3. Open the terminal in Android Studio by clicking on the terminal icon on bottom left and run the following command:
```sh
git submodule update --init --recursive
```
3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator.
4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes.
@ -199,7 +203,6 @@ Create a tag on the master branch, incrementing the last version number by 1 (fo
Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update.
## Documentation
The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).

View file

@ -226,10 +226,6 @@
android:name=".activities.FCastGuideActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricWhyActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncHomeActivity"
android:screenOrientation="sensorPortrait"

View file

@ -150,7 +150,6 @@ class Settings : FragmentedStorageFileJson() {
fun import() {
val act = SettingsActivity.getActivity() ?: return;
val intent = MainActivity.getImportOptionsIntent(act);
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK;
act.startActivity(intent);
}
@ -505,6 +504,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
var autoplay: Boolean = false;
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
var deleteFromWatchLaterAuto: Boolean = true;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@ -862,10 +864,14 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
fun clearPayment() {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
}
})
}
}
}
@ -878,7 +884,10 @@ class Settings : FragmentedStorageFileJson() {
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
@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;
}
@ -919,7 +928,7 @@ class Settings : FragmentedStorageFileJson() {
var enabled: Boolean = true;
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
var broadcast: Boolean = true;
var broadcast: Boolean = false;
@FormField(R.string.connect_discovered, FieldForm.TOGGLE, R.string.connect_discovered_description, 2)
var connectDiscovered: Boolean = true;

View file

@ -350,6 +350,13 @@ class UIDialogs {
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
}
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
}
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);

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.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal
@ -879,6 +880,12 @@ class UISlideOverlays {
val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
val isLimited = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
if (it is JSClient)
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
@ -899,17 +906,18 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = "download",
call = {
showDownloadVideoOverlay(video, container, true);
},
invokeParent = false
),
if(!isLimited)
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = "download",
call = {
showDownloadVideoOverlay(video, container, true);
},
invokeParent = false
) else null,
SlideUpMenuItem(
container.context,
R.drawable.ic_share,
@ -936,7 +944,7 @@ class UISlideOverlays {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions)
+ actions).filterNotNull()
));
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
@ -1033,15 +1041,7 @@ class UISlideOverlays {
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = container.context.getString(R.string.download),
call = { showDownloadVideoOverlay(video, container, true); },
invokeParent = false
))
)
);
val playlistItems = arrayListOf<SlideUpMenuItem>();

View file

@ -1310,7 +1310,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "TAB";
sourcesIntent.putExtra("TAB", tab);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
@ -1318,7 +1318,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "VIDEO";
sourcesIntent.putExtra("VIDEO", videoUrl);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
@ -1326,14 +1326,14 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "ACTION";
sourcesIntent.putExtra("ACTION", action);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
fun getImportOptionsIntent(context: Context): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "IMPORT_OPTIONS";
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
}

View file

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

View file

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

View file

@ -22,6 +22,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
@ -34,6 +35,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
@ -52,6 +54,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
}
_buttonStart.setOnClickListener {
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
}
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);

View file

@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.futopay.PaymentConfigurations
import com.futo.futopay.PaymentManager
import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
@ -94,9 +95,8 @@ class BuyFragment : MainFragment() {
if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) {
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
}
}
}

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

View file

@ -14,6 +14,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.AddSourceOptionsActivity
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@ -57,10 +58,19 @@ class SubscriptionGroupListFragment : MainFragment() {
};
it.onDelete.subscribe { group ->
val loc = _subs.indexOf(group);
_subs.remove(group);
_list?.adapter?.notifyItemRangeRemoved(loc);
StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id);
context?.let { context ->
UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${group.name}]?", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Delete", {
StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true);
val loc = _subs.indexOf(group);
_subs.remove(group);
_list?.adapter?.notifyItemRangeRemoved(loc);
StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true);
}, UIDialogs.ActionStyle.DANGEROUS));
}
};
it.onDragDrop.subscribe {
_touchHelper?.startDrag(it);

View file

@ -27,6 +27,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
@ -364,6 +365,7 @@ class SubscriptionsFeedFragment : MainFragment() {
}
override fun reload() {
StatePlugins.instance.clearUpdating(); //Fallback in case it doesnt clear, UI should be blocked.
loadResults(true);
}

View file

@ -101,7 +101,7 @@ class VideoDetailFragment : MainFragment {
val currentRequestedOrientation = a.requestedOrientation
var currentOrientation = if (_currentOrientation == -1) currentRequestedOrientation else _currentOrientation
if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT && !Settings.instance.playback.reversePortrait)
currentOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
currentOrientation = currentRequestedOrientation
val isAutoRotate = Settings.instance.playback.isAutoRotate()
val isFs = isFullscreen
@ -347,7 +347,7 @@ class VideoDetailFragment : MainFragment {
return true
}
if (state == State.MAXIMIZED && isFullscreen && !Settings.instance.playback.fullscreenPortrait && (_currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || _currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT)) {
if (state == State.MAXIMIZED && isFullscreen && !Settings.instance.playback.fullscreenPortrait && (_currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || (Settings.instance.playback.reversePortrait && _currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT))) {
_viewDetail?.setFullscreen(false)
return true
}

View file

@ -40,6 +40,7 @@ import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@ -72,6 +73,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
@ -111,9 +113,12 @@ import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SendToDevicePackage
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanNowDiffString
@ -637,6 +642,27 @@ class VideoDetailView : ConstraintLayout {
StatePlayer.instance.onVideoChanging.subscribe(this) {
setVideoOverview(it);
};
var hadDevice = false;
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
if(hasDevice != hadDevice) {
hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
}
}
};
StateSync.instance.deviceRemoved.subscribe(this) { id ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
if(hasDevice != hadDevice) {
hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
}
}
}
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
@ -711,6 +737,7 @@ class VideoDetailView : ConstraintLayout {
};
onClose.subscribe {
checkAndRemoveWatchLater();
_lastVideoSource = null;
_lastAudioSource = null;
_lastSubtitleSource = null;
@ -819,6 +846,11 @@ class VideoDetailView : ConstraintLayout {
}
fun updateMoreButtons() {
val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
if (it is JSClient)
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
@ -838,38 +870,44 @@ class VideoDetailView : ConstraintLayout {
}
_slideUpOverlay?.hide();
} else null,
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
}
else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
}
else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
};
}
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
};
},
RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) {
video?.let {
Logger.i(TAG, "Share preventPictureInPicture = true");
preventPictureInPicture = true;
shareVideo();
};
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture();
fragment.forcePictureInPicture();
//PiPActivity.startPiP(context);
_slideUpOverlay?.hide();
},
else null,
RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) {
video?.let {
Logger.i(TAG, "Share preventPictureInPicture = true");
preventPictureInPicture = true;
shareVideo();
};
_slideUpOverlay?.hide();
},
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture();
fragment.forcePictureInPicture();
//PiPActivity.startPiP(context);
_slideUpOverlay?.hide();
}
else null,
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let {
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
@ -878,6 +916,22 @@ class VideoDetailView : ConstraintLayout {
};
_slideUpOverlay?.hide();
},
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
val devices = StateSync.instance.getSessions();
val videoToSend = video ?: return@RoundButton;
if(devices.size > 1) {
//not implemented
}
else if(devices.size == 1){
val device = devices.first();
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , {
fragment.lifecycleScope.launch(Dispatchers.IO) {
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds/1000).toInt()));
}
})
}
}} else null,
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo();
_slideUpOverlay?.hide();
@ -1025,6 +1079,8 @@ class VideoDetailView : ConstraintLayout {
StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this);
StateSync.instance.deviceUpdatedOrAdded.remove(this);
StateSync.instance.deviceRemoved.remove(this);
MediaControlReceiver.onLowerVolumeReceived.remove(this);
MediaControlReceiver.onPlayReceived.remove(this);
MediaControlReceiver.onPauseReceived.remove(this);
@ -1642,7 +1698,7 @@ class VideoDetailView : ConstraintLayout {
});
else
_player.setArtwork(null);
_player.setSource(videoSource, audioSource, _playWhenReady, false);
_player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0);
if(subtitleSource != null)
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
_player.seekTo(resumePositionMs);
@ -1792,6 +1848,8 @@ class VideoDetailView : ConstraintLayout {
fun prevVideo(withoutRemoval: Boolean = false) {
Logger.i(TAG, "prevVideo")
checkAndRemoveWatchLater();
val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next != null) {
setVideoOverview(next, true, 0, true);
@ -1800,6 +1858,8 @@ class VideoDetailView : ConstraintLayout {
fun nextVideo(forceLoop: Boolean = false, withoutRemoval: Boolean = false, bypassVideoLoop: Boolean = false): Boolean {
Logger.i(TAG, "nextVideo")
checkAndRemoveWatchLater();
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
val autoplayVideo = _autoplayVideo
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
@ -1808,7 +1868,8 @@ class VideoDetailView : ConstraintLayout {
next = autoplayVideo
}
_autoplayVideo = null
Logger.i(TAG, "Autoplay video cleared (nextVideo)")
Logger.i(TAG, "Autoplay video cleared (nextVideo)");
if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue();
if(next != null) {
@ -1820,6 +1881,20 @@ class VideoDetailView : ConstraintLayout {
return false;
}
fun checkAndRemoveWatchLater(){
val watchCurrent = video ?: videoLocal ?: _searchVideo;
if(Settings.instance.playback.deleteFromWatchLaterAuto) {
if(watchCurrent?.duration != null &&
watchCurrent.duration > 0 &&
(lastPositionMilliseconds / 1000) > watchCurrent.duration * 0.7) {
if(!watchCurrent.url.isNullOrEmpty()) {
StatePlaylists.instance.removeFromWatchLater(watchCurrent.url);
}
}
}
}
//Quality Selector data
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
val v = video ?: return;
@ -2860,6 +2935,7 @@ class VideoDetailView : ConstraintLayout {
const val TAG_OVERLAY = "overlay";
const val TAG_LIVECHAT = "livechat";
const val TAG_OPEN = "open";
const val TAG_SEND_TO_DEVICE = "send_to_device";
const val TAG_MORE = "MORE";
private val _buttonPinStore = FragmentedStorage.get<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 is IVideoSource) {
if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0)
return 0;
return (source.duration / 8).toInt() * source.bitrate!!;
return (source.duration / 8) * source.bitrate!!;
}
else return 0;
}
fun estimateSourceSize(source: IAudioSource?): Int {
fun estimateSourceSize(source: IAudioSource?): Long {
if(source == null) return 0;
if(source is IAudioSource) {
if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0)
return 0;
return (source.duration!! / 8).toInt() * source.bitrate;
return (source.duration!! / 8) * source.bitrate;
}
else return 0;
}

View file

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

View file

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

View file

@ -100,33 +100,34 @@ class ServiceRecordAggregator {
Logger.i(TAG, "$builder")*/
val currentServices: MutableList<DnsService>
ptrRecords.forEach { record ->
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
}
aRecords.forEach { aRecord ->
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
}
aaaaRecords.forEach { aaaaRecord ->
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
}
txtRecords.forEach { txtRecord ->
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
}
srvRecords.forEach { srvRecord ->
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
}
//TODO: Maybe this can be debounced?
synchronized(this._currentServices) {
ptrRecords.forEach { record ->
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
}
aRecords.forEach { aRecord ->
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
}
aaaaRecords.forEach { aaaaRecord ->
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
}
txtRecords.forEach { txtRecord ->
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
}
srvRecords.forEach { srvRecord ->
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
}
currentServices = getCurrentServices()
this._currentServices.clear()
this._currentServices.addAll(currentServices)

View file

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

View file

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

View file

@ -2,6 +2,8 @@ package com.futo.platformplayer.states
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
@ -20,9 +22,13 @@ import androidx.lifecycle.lifecycleScope
import androidx.work.*
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs.Action
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.UIDialogs.Companion.showDialog
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker
@ -419,8 +425,17 @@ class StateApp {
Logger.onLogSubmitted.subscribe {
scopeOrNull?.launch(Dispatchers.Main) {
try {
if (it != null) {
UIDialogs.toast("Uploaded $it", true);
if (!it.isNullOrEmpty()) {
(SettingsActivity.getActivity() ?: contextOrNull)?.let { c ->
val okButtonAction = Action(c.getString(R.string.ok), {}, ActionStyle.PRIMARY)
val copyButtonAction = Action(c.getString(R.string.copy), {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Log id", it)
clipboard.setPrimaryClip(clip)
}, ActionStyle.NONE)
showDialog(c, R.drawable.ic_error, "Uploaded $it", null, null, 0, copyButtonAction, okButtonAction)
}
} else {
UIDialogs.toast("Failed to upload");
}

View file

@ -11,6 +11,7 @@ import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
@ -18,7 +19,9 @@ import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
@ -61,9 +64,9 @@ class StateBackup {
StatePlaylists.instance.toMigrateCheck()
).flatten();
fun getCache(): ImportCache {
fun getCache(additionalVideos: List<SerializedPlatformVideo> = listOf()): ImportCache {
val allPlaylists = StatePlaylists.instance.getPlaylists();
val videos = allPlaylists.flatMap { it.videos }.distinctBy { it.url };
val videos = allPlaylists.flatMap { it.videos }.plus(additionalVideos).distinctBy { it.url };
val allSubscriptions = StateSubscriptions.instance.getSubscriptions();
val channels = allSubscriptions.map { it.channel };
@ -240,6 +243,23 @@ class StateBackup {
.associateBy { it.name }
.mapValues { it.value.getAllReconstructionStrings() }
.toMutableMap();
var historyVideos: List<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 pluginSettings = StatePlugins.instance.getPlugins()
.associateBy { it.config.id }
@ -249,7 +269,7 @@ class StateBackup {
.associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! };
val cache = getCache();
val cache = getCache(historyVideos ?: listOf());
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings, cache);
@ -333,19 +353,64 @@ class StateBackup {
if(doImportStores) {
for(store in export.stores) {
Logger.i(TAG, "Importing store [${store.key}]");
val relevantStore = availableStores.find { it.name == store.key };
if(relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
if(store.key == "history") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(historyStr in store.value) {
try {
val histObj = HistoryVideo.fromReconString(historyStr) { url ->
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url };
}
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
}, UIDialogs.ActionStyle.PRIMARY))
}
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if(toAwait.isEmpty())
onConclusion();
}
};
else if(store.key == "subscription_groups") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(groupStr in store.value) {
try {
val group = Json.decodeFromString<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 };
if (relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if(toAwait.isEmpty())
onConclusion();
}
};
}
}
}
}

View file

@ -59,7 +59,6 @@ class StateHistory {
return getHistoryPosition(url) > duration * 0.7;
}
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false): Long {
val pos = if(position < 0) 0 else position;
val historyVideo = index.obj;
@ -90,7 +89,7 @@ class StateHistory {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})");
StateSync.instance.broadcastJson(
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncHistory,
listOf(historyVideo)
);

View file

@ -17,10 +17,18 @@ import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
@ -45,6 +53,7 @@ class StatePlaylists {
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup())
.load();
private val _playlistRemoved = FragmentedStorage.get<StringDateMapStorage>("playlist_removed");
val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares");
@ -81,6 +90,18 @@ class StatePlaylists {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
}
}
fun getWatchLaterFromUrl(url: String): SerializedPlatformVideo?{
synchronized(_watchlistStore) {
val order = _watchlistOrderStore.getAllValues();
return _watchlistStore.getItems().firstOrNull { it.url == url };
}
}
fun removeFromWatchLater(url: String) {
val item = getWatchLaterFromUrl(url);
if(item != null){
removeFromWatchLater(item);
}
}
fun removeFromWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) {
_watchlistStore.delete(video);
@ -118,6 +139,9 @@ class StatePlaylists {
return playlistStore.findItem { it.id == id };
}
fun getPlaylistRemovals(): Map<String, Long> {
return _playlistRemoved.all();
}
fun didPlay(playlistId: String) {
val playlist = getPlaylist(playlistId);
@ -148,13 +172,15 @@ class StatePlaylists {
createOrUpdatePlaylist(newPlaylist);
return newPlaylist;
}
fun createOrUpdatePlaylist(playlist: Playlist) {
fun createOrUpdatePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) {
playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true);
if(playlist.id.isNotEmpty()) {
if (StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(playlist.id);
}
if(isUserInteraction)
broadcastSyncPlaylist(playlist);
}
}
fun addToPlaylist(id: String, video: IPlatformVideo) {
@ -163,14 +189,41 @@ class StatePlaylists {
playlist.videos.add(SerializedPlatformVideo.fromVideo(video));
playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true);
broadcastSyncPlaylist(playlist);
}
}
fun removePlaylist(playlist: Playlist) {
private fun broadcastSyncPlaylist(playlist: Playlist){
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(playlist), mapOf())
);
}
};
}
fun removePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) {
playlistStore.delete(playlist);
if(StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
}
if(isUserInteraction) {
_playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond())))
);
}
};
}
}
fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri {
@ -194,6 +247,16 @@ class StatePlaylists {
return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile);
}
fun getSyncPlaylistsPackageString(): String{
return Json.encodeToString(
SyncPlaylistsPackage(
getPlaylists(),
getPlaylistRemovals()
)
);
}
companion object {
val TAG = "StatePlaylists";
private var _instance : StatePlaylists? = null;

View file

@ -19,6 +19,7 @@ import com.futo.platformplayer.stores.PluginIconStorage
import com.futo.platformplayer.stores.PluginScriptsDirectory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@ -47,6 +48,8 @@ class StatePlugins {
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
private val _isUpdating: HashSet<String> = hashSetOf();
fun getPluginIconOrNull(id: String): ImageVariable? {
if(iconsDir.hasIcon(id))
return iconsDir.getIconBinary(id);
@ -58,6 +61,38 @@ class StatePlugins {
.load();
}
fun isUpdating(id: String): Boolean{
synchronized(_isUpdating){
return _isUpdating.contains(id);
}
}
fun setIsUpdating(id: String, value: Boolean){
synchronized(_isUpdating){
if(value && !_isUpdating.contains(id)) {
Logger.i(TAG, "PLUGIN [${id}] UPDATING");
_isUpdating.add(id);
}
if(!value && _isUpdating.contains(id)) {
Logger.i(TAG, "PLUGIN [${id}] NOT UPDATING");
_isUpdating.remove(id);
}
}
}
suspend fun whileUpdating(id: String, handle: suspend ()->Unit){
try {
setIsUpdating(id, true);
handle();
}
finally {
setIsUpdating(id, false);
}
}
fun clearUpdating(){
synchronized(_isUpdating) {
_isUpdating.clear();
}
}
suspend fun checkForUpdates(): List<Pair<SourcePluginConfig, SourcePluginConfig>> = withContext(Dispatchers.IO) {
var configs = mutableListOf<Pair<SourcePluginConfig, SourcePluginConfig>>()
@ -430,42 +465,49 @@ class StatePlugins {
fun installPluginBackground(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, onProgress: (text: String, progress: Double)->Unit, onConcluded: (ex: Throwable?)->Unit) {
scope.launch(Dispatchers.IO) {
val client = ManagedHttpClient();
try {
whileUpdating(config.id) {
withContext(Dispatchers.Main) {
onProgress.invoke("Validating script", 0.25);
onProgress.invoke("Waiting for plugins to finish", 0.1);
}
delay(500);
val tempDescriptor = SourcePluginDescriptor(config);
val plugin = JSClient(context, tempDescriptor, null, script);
plugin.validate();
withContext(Dispatchers.Main) {
onProgress.invoke("Downloading Icon", 0.5);
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
val client = ManagedHttpClient();
try {
withContext(Dispatchers.Main) {
onProgress.invoke("Saving plugin", 0.75);
onProgress.invoke("Validating script", 0.25);
}
val iconResp = client.get(absIconUrl);
if(iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
if(installEx != null)
throw installEx;
StatePlatform.instance.updateAvailableClients(context);
withContext(Dispatchers.Main) {
onProgress.invoke("Finished", 1.0)
onConcluded.invoke(null);
}
} catch (ex: Exception) {
Logger.e(TAG, ex.message ?: "null", ex);
withContext(Dispatchers.Main) {
onConcluded.invoke(ex);
val tempDescriptor = SourcePluginDescriptor(config);
val plugin = JSClient(context, tempDescriptor, null, script);
plugin.validate();
withContext(Dispatchers.Main) {
onProgress.invoke("Downloading Icon", 0.5);
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
withContext(Dispatchers.Main) {
onProgress.invoke("Saving plugin", 0.75);
}
val iconResp = client.get(absIconUrl);
if (iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
if (installEx != null)
throw installEx;
StatePlatform.instance.updateAvailableClients(context);
withContext(Dispatchers.Main) {
onProgress.invoke("Finished", 1.0)
onConcluded.invoke(null);
}
} catch (ex: Exception) {
Logger.e(TAG, ex.message ?: "null", ex);
withContext(Dispatchers.Main) {
onConcluded.invoke(ex);
}
}
}
}

View file

@ -25,13 +25,20 @@ import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StateHistory.Companion
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
@ -51,6 +58,9 @@ class StateSubscriptionGroups {
.withUnique { it.id }
.load();
private val _groupsRemoved = FragmentedStorage.get<StringDateMapStorage>("group_removed");
val onGroupsChanged = Event0();
fun getSubscriptionGroup(id: String): SubscriptionGroup? {
@ -59,19 +69,66 @@ class StateSubscriptionGroups {
fun getSubscriptionGroups(): List<SubscriptionGroup> {
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);
if(!preventNotify)
onGroupsChanged.emit();
if(!preventSync) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf())
);
}
};
}
}
fun deleteSubscriptionGroup(id: String){
fun deleteSubscriptionGroup(id: String, isUserInteraction: Boolean = true){
val group = getSubscriptionGroup(id);
if(group != null) {
_subGroups.delete(group);
onGroupsChanged.emit();
if(isUserInteraction) {
_groupsRemoved.setAndSave(id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond())))
);
}
};
}
}
}
fun hasSubscriptionGroup(url: String): Boolean {
val groups = getSubscriptionGroups();
for(group in groups){
if(group.urls.contains(url))
return true;
}
return false;
}
fun getSyncSubscriptionGroupsPackageString(): String{
return Json.encodeToString(
SyncSubscriptionGroupsPackage(
getSubscriptionGroups(),
getSubscriptionGroupsRemovals()
)
);
}
companion object {
const val TAG = "StateSubscriptionGroups";

View file

@ -202,13 +202,13 @@ class StateSubscriptions {
return _subscriptionOthers.findItem { it.isChannel(url)};
}
}
fun getSubscriptionOtherOrCreate(url: String) : Subscription {
fun getSubscriptionOtherOrCreate(url: String, name: String? = null, thumbnail: String? = null) : Subscription {
synchronized(_subscriptionOthers) {
val sub = getSubscriptionOther(url);
if(sub == null) {
val newSub = Subscription(SerializedChannel(PlatformID.NONE, url, null, null, 0, null, url, mapOf()));
val newSub = Subscription(SerializedChannel(PlatformID.NONE, name ?: url, thumbnail, null, 0, null, url, mapOf()));
newSub.isOther = true;
_subscriptions.save(newSub);
_subscriptionOthers.save(newSub);
return newSub;
}
else return sub;
@ -250,7 +250,7 @@ class StateSubscriptions {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
StateSync.instance.broadcast(
StateSync.instance.broadcastData(
GJSyncOpcodes.syncSubscriptions, Json.encodeToString(
SyncSubscriptionsPackage(
listOf(subObj),
@ -293,8 +293,29 @@ class StateSubscriptions {
if(sub != null) {
_subscriptions.delete(sub);
onSubscriptionsChanged.emit(getSubscriptions(), false);
if(isUserAction)
_subscriptionsRemoved.setAndSave(sub.channel.url, OffsetDateTime.now());
if(isUserAction) {
val removalTime = OffsetDateTime.now();
_subscriptionsRemoved.setAndSave(sub.channel.url, removalTime);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
StateSync.instance.broadcastData(
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;
}

View file

@ -66,6 +66,10 @@ class StateSync {
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun start() {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true
if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) {
@ -366,26 +370,29 @@ class StateSync {
Logger.i(TAG, "Connection authorized for ${remotePublicKey} because initiator")
}
},
onData = { s, opcode, data ->
session?.handlePacket(s, opcode, data)
onData = { s, opcode, subOpcode, data ->
session?.handlePacket(s, opcode, subOpcode, data)
})
}
inline fun <reified T> broadcastJson(opcode: UByte, data: T) {
broadcast(opcode, Json.encodeToString(data));
inline fun <reified T> broadcastJsonData(subOpcode: UByte, data: T) {
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data));
}
fun broadcast(opcode: UByte, data: String) {
broadcast(opcode, data.toByteArray(Charsets.UTF_8));
fun broadcastData(subOpcode: UByte, data: String) {
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun broadcast(opcode: UByte, data: ByteArray) {
fun broadcast(opcode: UByte, subOpcode: UByte, data: String) {
broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) {
for(session in getSessions()) {
try {
if (session.isAuthorized && session.connected) {
session.send(opcode, data);
session.send(opcode, subOpcode, data);
}
}
catch(ex: Exception) {
Logger.w(TAG, "Failed to broadcast ${opcode} to ${session.remotePublicKey}: ${ex.message}}", ex);
Logger.w(TAG, "Failed to broadcast (opcode = ${opcode}, subOpcode = ${subOpcode}) to ${session.remotePublicKey}: ${ex.message}}", ex);
}
}
}
@ -394,7 +401,7 @@ class StateSync {
val time = measureTimeMillis {
//val export = StateBackup.export();
//session.send(GJSyncOpcodes.syncExport, export.asZip());
session.send(GJSyncOpcodes.syncStateExchange, getSyncSessionDataString(session.remotePublicKey));
session.sendData(GJSyncOpcodes.syncStateExchange, getSyncSessionDataString(session.remotePublicKey));
}
Logger.i(TAG, "Generated and sent sync export in ${time}ms");
}

View file

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

View file

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

View file

@ -6,15 +6,20 @@ import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.SyncSessionData
import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode
import com.futo.platformplayer.sync.models.SendToDevicePackage
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -22,7 +27,9 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
interface IAuthorizable {
val isAuthorized: Boolean
@ -38,6 +45,7 @@ class SyncSession : IAuthorizable {
private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit
val remotePublicKey: String
override val isAuthorized get() = _authorized && _remoteAuthorized
private var _wasAuthorized = false
var connected: Boolean = false
private set(v) {
@ -87,8 +95,10 @@ class SyncSession : IAuthorizable {
}
private fun checkAuthorized() {
if (isAuthorized)
if (!_wasAuthorized && isAuthorized) {
_wasAuthorized = true
_onAuthorized.invoke(this)
}
}
fun removeSocketSession(socketSession: SyncSocketSession) {
@ -110,29 +120,34 @@ class SyncSession : IAuthorizable {
_onClose.invoke(this)
}
fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, data: ByteBuffer) {
Logger.i(TAG, "Handle packet (opcode: ${opcode}, data.length: ${data.remaining()})")
when (opcode) {
Opcode.NOTIFY_AUTHORIZED.value -> {
_remoteAuthorized = true
checkAuthorized()
}
Opcode.NOTIFY_UNAUTHORIZED.value -> {
_remoteAuthorized = false
_onUnauthorized(this)
}
//TODO: Handle any kind of packet (that is not necessarily authorized)
}
if (!isAuthorized) {
return
}
Logger.i(TAG, "Received ${opcode} (${data.remaining()} bytes)")
//TODO: Abstract this out
fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
try {
Logger.i(TAG, "Handle packet (opcode: ${opcode}, subOpcode: ${subOpcode}, data.length: ${data.remaining()})")
when (opcode) {
Opcode.NOTIFY_AUTHORIZED.value -> {
_remoteAuthorized = true
checkAuthorized()
}
Opcode.NOTIFY_UNAUTHORIZED.value -> {
_remoteAuthorized = false
_onUnauthorized(this)
}
//TODO: Handle any kind of packet (that is not necessarily authorized)
}
if (!isAuthorized) {
return
}
if (opcode != Opcode.DATA.value) {
Logger.w(TAG, "Unknown opcode received: (opcode = ${opcode}, subOpcode = ${subOpcode})}")
return
}
Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)")
//TODO: Abstract this out
when (subOpcode) {
GJSyncOpcodes.sendToDevices -> {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
val context = StateApp.instance.contextOrNull;
@ -157,11 +172,13 @@ class SyncSession : IAuthorizable {
Logger.i(TAG, "Received SyncSessionData from " + remotePublicKey);
send(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString());
sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString())
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
if(recentHistory.size > 0)
sendJson(GJSyncOpcodes.syncHistory, recentHistory);
sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
}
GJSyncOpcodes.syncExport -> {
@ -205,6 +222,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 -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
@ -242,8 +320,7 @@ class SyncSession : IAuthorizable {
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
if(sub.creationTime > removalTime) {
val newSub =
StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
added.add(newSub);
}
}
@ -269,16 +346,19 @@ class SyncSession : IAuthorizable {
}
inline fun <reified T> sendJson(opcode: UByte, data: T) {
send(opcode, Json.encodeToString<T>(data));
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data));
}
fun send(opcode: UByte, data: String) {
send(opcode, data.toByteArray(Charsets.UTF_8));
fun sendData(subOpcode: UByte, data: String) {
send(Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun send(opcode: UByte, data: ByteArray) {
fun send(opcode: UByte, subOpcode: UByte, data: String) {
send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) {
val sock = _socketSessions.firstOrNull();
if(sock != null){
sock.send(opcode, ByteBuffer.wrap(data));
sock.send(opcode, subOpcode, ByteBuffer.wrap(data));
}
else
throw IllegalStateException("Session has no active sockets");

View file

@ -2,6 +2,7 @@ package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState
@ -18,7 +19,8 @@ class SyncSocketSession {
NOTIFY_UNAUTHORIZED(3u),
STREAM_START(4u),
STREAM_DATA(5u),
STREAM_END(6u)
STREAM_END(6u),
DATA(7u)
}
private val _inputStream: LittleEndianDataInputStream
@ -41,12 +43,12 @@ class SyncSocketSession {
private val _localKeyPair: DHState
private var _localPublicKey: String
val localPublicKey: String get() = _localPublicKey
private val _onData: (session: SyncSocketSession, opcode: UByte, data: ByteBuffer) -> Unit
private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit
var authorizable: IAuthorizable? = null
val remoteAddress: String
constructor(remoteAddress: String, localKeyPair: DHState, inputStream: LittleEndianDataInputStream, outputStream: LittleEndianDataOutputStream, onClose: (session: SyncSocketSession) -> Unit, onHandshakeComplete: (session: SyncSocketSession) -> Unit, onData: (session: SyncSocketSession, opcode: UByte, data: ByteBuffer) -> Unit) {
constructor(remoteAddress: String, localKeyPair: DHState, inputStream: LittleEndianDataInputStream, outputStream: LittleEndianDataOutputStream, onClose: (session: SyncSocketSession) -> Unit, onHandshakeComplete: (session: SyncSocketSession) -> Unit, onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit) {
_inputStream = inputStream
_outputStream = outputStream
_onClose = onClose
@ -159,10 +161,11 @@ class SyncSocketSession {
}
private fun performVersionCheck() {
_outputStream.writeInt(1)
val CURRENT_VERSION = 2
_outputStream.writeInt(CURRENT_VERSION)
val version = _inputStream.readInt()
Logger.i(TAG, "performVersionCheck (version = $version)")
if (version != 1)
if (version != CURRENT_VERSION)
throw Exception("Invalid version")
}
@ -205,8 +208,9 @@ class SyncSocketSession {
throw Exception("Handshake finished without completing")
}
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
ensureNotMainThread()
fun send(opcode: UByte, data: ByteBuffer) {
if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) {
val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE
val segmentData = ByteArray(segmentSize)
@ -223,8 +227,8 @@ class SyncSocketSession {
if (sendOffset == 0) {
segmentOpcode = Opcode.STREAM_START.value
bytesToSend = segmentSize - 4 - 4 - 1
segmentPacketSize = bytesToSend + 4 + 4 + 1
bytesToSend = segmentSize - 4 - 4 - 1 - 1
segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1
} else {
bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining)
segmentOpcode = if (bytesToSend >= bytesRemaining) Opcode.STREAM_END.value else Opcode.STREAM_DATA.value
@ -236,18 +240,20 @@ class SyncSocketSession {
putInt(if (segmentOpcode == Opcode.STREAM_START.value) data.remaining() else sendOffset)
if (segmentOpcode == Opcode.STREAM_START.value) {
put(opcode.toByte())
put(subOpcode.toByte())
}
put(data.array(), data.position() + sendOffset, bytesToSend)
}
send(segmentOpcode, ByteBuffer.wrap(segmentData, 0, segmentPacketSize))
send(segmentOpcode, 0u, ByteBuffer.wrap(segmentData, 0, segmentPacketSize))
sendOffset += bytesToSend
}
} else {
synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(data.remaining() + 1)
putInt(data.remaining() + 2)
put(opcode.toByte())
put(subOpcode.toByte())
put(data.array(), data.position(), data.remaining())
}
@ -260,12 +266,15 @@ class SyncSocketSession {
}
}
fun send(opcode: UByte) {
synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(1)
_sendBuffer.asUByteArray()[4] = opcode
fun send(opcode: UByte, subOpcode: UByte = 0u) {
ensureNotMainThread()
//Logger.i(TAG, "Encrypting message (size = ${HEADER_SIZE})")
synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2)
_sendBuffer.asUByteArray()[4] = opcode
_sendBuffer.asUByteArray()[5] = subOpcode
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE)
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
@ -277,19 +286,19 @@ class SyncSocketSession {
private fun handleData(data: ByteArray, length: Int) {
if (length < HEADER_SIZE)
throw Exception("Packet must be at least 5 bytes (header size)")
throw Exception("Packet must be at least 6 bytes (header size)")
val size = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
if (size != length - 4)
throw Exception("Incomplete packet received")
val opcode = data.asUByteArray()[4]
val packetData = ByteBuffer.wrap(data, HEADER_SIZE, size - 1)
handlePacket(opcode, packetData.order(ByteOrder.LITTLE_ENDIAN))
val subOpcode = data.asUByteArray()[5]
val packetData = ByteBuffer.wrap(data, HEADER_SIZE, size - 2)
handlePacket(opcode, subOpcode, packetData.order(ByteOrder.LITTLE_ENDIAN))
}
private fun handlePacket(opcode: UByte, data: ByteBuffer) {
private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
when (opcode) {
Opcode.PING.value -> {
send(Opcode.PONG.value)
@ -302,7 +311,7 @@ class SyncSocketSession {
}
Opcode.NOTIFY_AUTHORIZED.value,
Opcode.NOTIFY_UNAUTHORIZED.value -> {
_onData.invoke(this, opcode, data)
_onData.invoke(this, opcode, subOpcode, data)
return
}
}
@ -316,8 +325,9 @@ class SyncSocketSession {
val id = data.int
val expectedSize = data.int
val op = data.get().toUByte()
val subOp = data.get().toUByte()
val syncStream = SyncStream(expectedSize, op)
val syncStream = SyncStream(expectedSize, op, subOp)
if (data.remaining() > 0) {
syncStream.add(data.array(), data.position(), data.remaining())
}
@ -362,10 +372,13 @@ class SyncSocketSession {
throw Exception("After sync stream end, the stream must be complete")
}
handlePacket(syncStream.opcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) })
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) })
}
Opcode.DATA.value -> {
_onData.invoke(this, opcode, subOpcode, data)
}
else -> {
_onData.invoke(this, opcode, data)
Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})")
}
}
}
@ -374,6 +387,6 @@ class SyncSocketSession {
private const val TAG = "SyncSocketSession"
const val MAXIMUM_PACKET_SIZE = 65535 - 16
const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16
const val HEADER_SIZE = 5
const val HEADER_SIZE = 6
}
}

View file

@ -1,6 +1,6 @@
package com.futo.platformplayer.sync.internal
class SyncStream(expectedSize: Int, val opcode: UByte) {
class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte) {
companion object {
const val MAXIMUM_SIZE = 10_000_000
}

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.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@ -53,14 +55,30 @@ class VideoListEditorView : FrameLayout {
};
adapterVideos.onRemove.subscribe { v ->
synchronized(_videos) {
val index = _videos.indexOf(v);
if(index >= 0) {
_videos.removeAt(index);
onVideoRemoved.emit(v);
val executeDelete = {
synchronized(_videos) {
val index = _videos.indexOf(v);
if(index >= 0) {
_videos.removeAt(index);
onVideoRemoved.emit(v);
}
adapterVideos.notifyItemRemoved(index);
}
adapterVideos.notifyItemRemoved(index);
}
if (Settings.instance.other.playlistDeleteConfirmation) {
UIDialogs.showConfirmationDialog(context, "Please confirm to delete", action = {
executeDelete()
}, cancelAction = {
}, doNotAskAgainAction = {
Settings.instance.other.playlistDeleteConfirmation = false
Settings.instance.save()
})
} else {
executeDelete()
}
};
adapterVideos.onClick.subscribe(onVideoClicked::emit);

View file

@ -327,8 +327,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd && (toIgnore.isEmpty() || !toIgnore.contains(it)) } };
}
fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false) {
swapSources(videoSource, audioSource,false, play, keepSubtitles);
fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false, resume: Boolean = false) {
swapSources(videoSource, audioSource,resume, play, keepSubtitles);
}
fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean {
var videoSourceUsed = videoSource;

View file

@ -22,6 +22,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />

View file

@ -22,6 +22,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />

View file

@ -22,6 +22,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />

View file

@ -10,6 +10,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"

View file

@ -14,6 +14,7 @@
android:id="@+id/button_close"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_close"
android:scaleType="fitCenter"
android:padding="10dp"
app:layout_constraintLeft_toLeftOf="parent"

View file

@ -75,6 +75,7 @@
android:id="@+id/incognito_button"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_incognito_button"
android:src="@drawable/ic_disabled_visible_purple"
android:background="@drawable/background_button_round_black"
android:scaleType="fitCenter"

View file

@ -21,6 +21,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />

View file

@ -9,6 +9,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@ -19,6 +20,7 @@
android:id="@+id/button_help"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_help"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View file

@ -9,6 +9,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@ -19,6 +20,7 @@
android:id="@+id/button_help"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_help"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View file

@ -9,6 +9,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@ -19,6 +20,7 @@
android:id="@+id/button_help"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_help"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View file

@ -9,6 +9,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@ -19,6 +20,7 @@
android:id="@+id/button_help"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_help"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View file

@ -10,6 +10,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@ -20,6 +21,7 @@
android:id="@+id/button_help"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_help"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@ -28,6 +30,7 @@
android:id="@+id/image_polycentric"
android:layout_height="80dp"
android:layout_width="80dp"
android:contentDescription="@string/cd_image_polycentric"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/roundedCorners_40dp"
app:srcCompat="@drawable/placeholder_profile"

View file

@ -10,6 +10,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"

View file

@ -24,6 +24,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />

View file

@ -10,6 +10,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"

View file

@ -10,6 +10,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"

View file

@ -14,6 +14,7 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"

View file

@ -27,6 +27,7 @@
android:id="@+id/button_cancel"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_button_close"
app:srcCompat="@drawable/ic_close_thin"
app:tint="#888888"
android:layout_marginEnd="30dp" />
@ -71,6 +72,16 @@
android:singleLine="true"
android:inputType="textPassword"
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
android:layout_width="match_parent"

View file

@ -97,6 +97,7 @@
android:id="@+id/button_scan_qr"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_scan_qr"
android:scaleType="centerCrop"
app:srcCompat="@drawable/ic_qr"
app:tint="@color/primary" />
@ -109,6 +110,7 @@
android:id="@+id/button_add"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_add"
android:scaleType="centerCrop"
app:srcCompat="@drawable/ic_add"
app:tint="@color/primary"

View file

@ -58,6 +58,7 @@
android:id="@+id/image_device"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_image_device"
app:srcCompat="@drawable/ic_chromecast"
android:scaleType="fitCenter"
app:layout_constraintLeft_toLeftOf="parent"
@ -197,6 +198,7 @@
android:id="@id/button_previous"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@string/cd_button_previous"
android:scaleType="centerCrop"
android:clickable="true"
android:padding="10dp"
@ -206,6 +208,7 @@
android:id="@+id/button_play"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@string/cd_button_play"
android:padding="20dp"
android:scaleType="fitCenter"
android:clickable="true"
@ -215,6 +218,7 @@
android:id="@+id/button_pause"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@string/cd_button_pause"
android:padding="10dp"
android:scaleType="fitCenter"
android:clickable="true"
@ -224,6 +228,7 @@
android:id="@+id/button_stop"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@string/cd_button_stop"
android:scaleType="fitCenter"
android:padding="5dp"
android:clickable="true"
@ -233,6 +238,7 @@
android:id="@id/button_next"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@string/cd_button_next"
android:clickable="true"
android:scaleType="centerCrop"
android:padding="10dp"

View file

@ -23,6 +23,7 @@
android:id="@+id/update_spinner"
android:layout_width="100dp"
android:layout_height="100dp"
android:contentDescription="@string/cd_update_spinner"
app:srcCompat="@drawable/ic_update_animated" />
<TextView

View file

@ -11,6 +11,7 @@
android:id="@+id/app_icon"
android:layout_width="35dp"
android:layout_height="35dp"
android:contentDescription="@string/cd_app_icon"
android:layout_marginStart="16dp"
android:layout_marginEnd="4dp"
android:scaleType="fitCenter"
@ -72,6 +73,7 @@
android:id="@+id/button_cast"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_cast_button"
android:paddingStart="16dp"
android:paddingEnd="12dp"
android:paddingTop="12dp"
@ -84,6 +86,7 @@
android:id="@+id/button_add"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_add"
android:paddingStart="5dp"
android:paddingEnd="12dp"
android:paddingTop="7dp"

View file

@ -62,6 +62,7 @@
android:background="@drawable/rounded_outline"
android:layout_width="35dp"
android:layout_height="35dp"
android:contentDescription="@string/cd_creator_thumbnail"
android:layout_marginStart="8dp"
android:scaleType="fitCenter"
app:layout_constraintLeft_toLeftOf="parent"
@ -103,6 +104,7 @@
android:id="@+id/button_sub_settings"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/cd_button_settings"
android:layout_marginTop="3dp"
android:layout_marginRight="10dp"
android:scaleType="fitCenter"
@ -114,6 +116,7 @@
android:id="@+id/button_subscribe"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_subscribe"
android:layout_marginEnd="4dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"

View file

@ -17,6 +17,7 @@
android:id="@+id/image_channel_thumbnail"
android:layout_width="80dp"
android:layout_height="80dp"
android:contentDescription="@string/cd_creator_thumbnail"
app:srcCompat="@drawable/ic_peertube"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"

View file

@ -48,6 +48,7 @@
android:id="@+id/button_clear_search"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_clear_search"
android:paddingStart="18dp"
android:paddingEnd="18dp"
android:layout_gravity="right|center_vertical"

View file

@ -41,6 +41,7 @@
<ImageView
android:layout_width="26dp"
android:layout_height="match_parent"
android:contentDescription="@string/cd_search_icon"
app:srcCompat="@drawable/ic_search_thin"
android:scaleType="fitCenter"
android:layout_marginStart="10dp"
@ -65,6 +66,7 @@
android:id="@+id/button_clear_search"
android:layout_width="46dp"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_clear_search"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_close_thin"
app:tint="@color/gray_ac"

View file

@ -11,6 +11,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingLeft="16dp"
android:paddingRight="8dp"
app:srcCompat="@drawable/ic_back_nav" />

View file

@ -11,6 +11,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingLeft="16dp"
android:paddingRight="8dp"
app:srcCompat="@drawable/ic_back_nav" />
@ -34,6 +35,7 @@
android:id="@+id/button_cast"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_cast_button"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="9dp"

View file

@ -10,6 +10,7 @@
android:id="@+id/app_icon"
android:layout_width="35dp"
android:layout_height="35dp"
android:contentDescription="@string/cd_app_icon"
android:layout_marginStart="16dp"
android:layout_marginEnd="4dp"
android:scaleType="fitCenter"
@ -37,6 +38,7 @@
android:id="@+id/button_cast"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_cast_button"
android:paddingStart="16dp"
android:paddingEnd="12dp"
android:paddingTop="12dp"
@ -49,6 +51,7 @@
android:id="@+id/button_search"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_search"
android:paddingStart="5dp"
android:paddingEnd="12dp"
android:paddingTop="11dp"

View file

@ -34,6 +34,7 @@
android:id="@+id/image_history"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_icon_history"
app:srcCompat="@drawable/ic_clock_white"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
@ -119,6 +120,7 @@
android:id="@+id/button_create_playlist"
android:layout_width="35dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_button_create_playlist"
app:srcCompat="@drawable/ic_add_white_16dp"
android:paddingEnd="15dp"
android:paddingStart="15dp"

View file

@ -58,6 +58,7 @@
android:id="@+id/button_share"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_share"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="5dp"

View file

@ -13,6 +13,7 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingLeft="16dp"
android:paddingRight="16dp"
app:srcCompat="@drawable/ic_back_white_24dp" />
@ -27,6 +28,7 @@
android:id="@+id/edit_search"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:hint="Search"
android:layout_weight="1"
android:inputType="text"
android:imeOptions="actionDone"
@ -37,6 +39,7 @@
android:id="@+id/button_clear_search"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_clear_search"
android:paddingStart="18dp"
android:paddingEnd="18dp"
android:layout_gravity="right|center_vertical"
@ -48,6 +51,7 @@
android:id="@+id/button_filter"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_filter"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:scaleType="fitCenter"

View file

@ -43,6 +43,7 @@
android:id="@+id/button_delete"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_delete"
android:layout_marginLeft="5dp"
android:layout_marginRight="0dp"
android:src="@drawable/ic_trash"
@ -56,6 +57,7 @@
android:id="@+id/button_settings"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_settings"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:src="@drawable/ic_settings"
@ -69,6 +71,7 @@
android:id="@+id/image_group"
android:layout_width="110dp"
android:layout_height="70dp"
android:contentDescription="@string/cd_image_group"
android:adjustViewBounds="true"
app:circularflow_defaultRadius="10dp"
android:layout_marginLeft="30dp"
@ -90,6 +93,7 @@
<ImageButton
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cd_edit_image"
android:padding="5dp"
android:clickable="false"
android:scaleType="fitCenter"
@ -121,6 +125,7 @@
<ImageButton
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_button_edit"
android:padding="2dp"
android:layout_marginStart="5dp"
android:layout_marginBottom="-5dp"

View file

@ -58,6 +58,7 @@
android:id="@+id/button_share"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_share"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="5dp"
@ -162,6 +163,7 @@
android:id="@+id/button_edit"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_edit"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="5dp"
@ -177,6 +179,7 @@
android:id="@+id/button_download"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_download"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="10dp"

View file

@ -36,7 +36,8 @@
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/creator_thumbnail"
android:layout_width="27dp"
android:layout_height="27dp" />
android:layout_height="27dp"
android:contentDescription="@string/cd_creator_thumbnail" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -128,6 +129,7 @@
android:id="@+id/image_like_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_like_icon"
app:srcCompat="@drawable/ic_thumb_up" />
<TextView
@ -144,6 +146,7 @@
android:id="@+id/image_dislike_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_dislike_icon"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
app:srcCompat="@drawable/ic_thumb_down" />
@ -285,6 +288,7 @@
android:id="@+id/button_share"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/cd_button_share"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="5dp"

View file

@ -103,6 +103,7 @@
android:id="@+id/minimize_play"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_minimize_play"
android:padding="10dp"
android:clickable="true"
android:scaleType="fitCenter"
@ -111,6 +112,7 @@
android:id="@+id/minimize_pause"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_minimize_pause"
android:padding="5dp"
android:scaleType="fitCenter"
android:clickable="true"
@ -119,6 +121,7 @@
android:id="@+id/minimize_close"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_minimize_close"
android:padding="5dp"
android:scaleType="fitCenter"
android:layout_marginStart="2dp"
@ -337,7 +340,8 @@
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/creator_thumbnail"
android:layout_width="35dp"
android:layout_height="35dp" />
android:layout_height="35dp"
android:contentDescription="@string/cd_creator_thumbnail" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"

View file

@ -59,6 +59,7 @@
android:id="@+id/donation_amount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_donation_amount"
android:gravity="center"
tools:text="$100" />
</LinearLayout>

View file

@ -100,6 +100,7 @@
android:id="@+id/image_like_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_like_icon"
app:srcCompat="@drawable/ic_thumb_up" />
<TextView
@ -116,6 +117,7 @@
android:id="@+id/image_dislike_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_dislike_icon"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
app:srcCompat="@drawable/ic_thumb_down" />
@ -134,6 +136,7 @@
android:id="@+id/button_replies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_replies"
app:pillIcon="@drawable/ic_forum"
app:pillText="55 Replies"
android:layout_marginStart="15dp" />

View file

@ -91,6 +91,7 @@
android:id="@+id/button_replies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_replies"
app:pillIcon="@drawable/ic_forum"
app:pillText="55 Replies"
android:layout_marginStart="15dp" />
@ -112,6 +113,7 @@
android:id="@+id/pill_text"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_delete"
android:textColor="@color/white"
android:textSize="13dp"
android:gravity="center_vertical"

View file

@ -56,6 +56,7 @@
android:id="@+id/button_subscribe"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_subscribe"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/text_channel_metadata"
app:layout_constraintLeft_toLeftOf="parent"
@ -65,6 +66,7 @@
android:id="@+id/platform_indicator"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/cd_platform_indicator"
android:layout_marginTop="18dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"

View file

@ -10,6 +10,7 @@
android:id="@+id/image_device"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_image_device"
app:srcCompat="@drawable/ic_chromecast"
android:scaleType="fitCenter"
app:layout_constraintLeft_toLeftOf="parent"
@ -63,6 +64,7 @@
android:id="@+id/image_loader"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_image_loader"
app:srcCompat="@drawable/ic_loader_animated"
android:layout_marginEnd="8dp"/>

View file

@ -20,6 +20,7 @@
android:id="@+id/donation_author_image"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_donation_author_image"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"

View file

@ -173,6 +173,7 @@
android:id="@+id/image_trash"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash_18dp"
android:scaleType="fitCenter"
android:paddingTop="10dp"

View file

@ -45,7 +45,8 @@
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/platform"
android:layout_width="25dp"
android:layout_height="25dp" />
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator" />
</LinearLayout>
</LinearLayout>

View file

@ -194,6 +194,7 @@
android:id="@+id/creator_thumbnail"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/cd_creator_thumbnail"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="10dp"
@ -272,6 +273,7 @@
android:id="@+id/thumbnail_platform"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube"/>
</LinearLayout>

View file

@ -306,6 +306,7 @@
android:id="@+id/thumbnail_platform"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_platform_indicator"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_margin="4dp" />

View file

@ -14,6 +14,7 @@
android:id="@+id/image_drag_drop"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_drag_drop"
app:srcCompat="@drawable/ic_dragdrop_white"
android:scaleType="fitCenter"
android:paddingTop="10dp"
@ -116,6 +117,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
android:contentDescription="@string/cd_download_indicator"
app:srcCompat="@drawable/download_for_offline" />
</FrameLayout>
</FrameLayout>
@ -174,6 +176,7 @@
android:id="@+id/image_trash"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash_18dp"
android:scaleType="fitCenter"
android:paddingTop="10dp"

View file

@ -41,6 +41,7 @@
android:id="@+id/thumbnail_platform"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_platform_indicator"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:layout_gravity="end"

View file

@ -108,6 +108,7 @@
android:id="@+id/creator_thumbnail"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/cd_creator_thumbnail"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="10dp"
@ -161,6 +162,7 @@
android:id="@+id/thumbnail_platform"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator"
android:scaleType="centerInside" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -72,6 +72,7 @@
android:id="@+id/button_trash"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash"
android:padding="10dp"
android:scaleType="fitCenter"

View file

@ -90,6 +90,7 @@
android:id="@+id/platform_indicator"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator"
android:scaleType="centerInside"
android:layout_marginEnd="8dp"
app:layout_constraintTop_toTopOf="@id/image_author_thumbnail"
@ -157,6 +158,7 @@
android:id="@+id/image_like_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_like_icon"
app:srcCompat="@drawable/ic_thumb_up" />
<TextView
@ -173,6 +175,7 @@
android:id="@+id/image_dislike_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_dislike_icon"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
app:srcCompat="@drawable/ic_thumb_down" />
@ -202,6 +205,7 @@
android:id="@+id/image_comments"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/Replies"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
app:srcCompat="@drawable/ic_forum" />

View file

@ -90,6 +90,7 @@
android:id="@+id/platform_indicator"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_platform_indicator"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube"
android:layout_marginEnd="8dp"

View file

@ -24,6 +24,7 @@
android:id="@+id/image_source"
android:layout_width="35dp"
android:layout_height="35dp"
android:contentDescription="@string/cd_platform_indicator"
app:srcCompat="@drawable/ic_peertube"
android:scaleType="fitCenter" />

View file

@ -24,6 +24,7 @@
android:id="@+id/image_source"
android:layout_width="35dp"
android:layout_height="35dp"
android:contentDescription="@string/cd_platform_indicator"
app:srcCompat="@drawable/ic_peertube"
android:scaleType="fitCenter" />

View file

@ -15,6 +15,7 @@
android:id="@+id/image_drag_drop"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_drag_drop"
app:srcCompat="@drawable/ic_dragdrop_white"
android:scaleType="fitCenter"
android:paddingTop="10dp"
@ -34,6 +35,7 @@
android:id="@+id/image_source"
android:layout_width="35dp"
android:layout_height="35dp"
android:contentDescription="@string/cd_platform_indicator"
app:srcCompat="@drawable/ic_peertube"
android:scaleType="fitCenter" />

View file

@ -14,6 +14,7 @@
android:id="@+id/creator_thumbnail"
android:layout_width="46dp"
android:layout_height="46dp"
android:contentDescription="@string/cd_creator_thumbnail"
android:layout_marginStart="20dp"/>
<LinearLayout
@ -42,7 +43,8 @@
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/platform"
android:layout_width="25dp"
android:layout_height="25dp" />
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator" />
<TextView
android:id="@+id/text_meta"
android:layout_width="wrap_content"
@ -60,6 +62,7 @@
android:id="@+id/button_settings"
android:layout_width="50dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_settings"
app:srcCompat="@drawable/ic_settings"
android:scaleType="fitCenter"
android:paddingStart="5dp"
@ -70,6 +73,7 @@
android:id="@+id/button_trash"
android:layout_width="60dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash"
android:scaleType="fitCenter"
android:paddingStart="5dp"

View file

@ -13,6 +13,7 @@
android:id="@+id/thumb"
android:layout_width="50dp"
android:layout_height="match_parent"
android:contentDescription="@string/cd_drag_drop"
android:padding="12dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
@ -25,6 +26,7 @@
android:id="@+id/image"
android:layout_width="75dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_image_group"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toRightOf="@id/thumb"
@ -77,6 +79,7 @@
android:id="@+id/button_trash"
android:layout_width="50dp"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_delete"
android:layout_marginRight="10dp"
android:padding="10dp"
android:scaleType="fitCenter"

View file

@ -17,6 +17,7 @@
android:id="@+id/image_drag_drop"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_drag_drop"
app:srcCompat="@drawable/ic_dragdrop_white"
android:scaleType="fitCenter"
android:paddingTop="10dp"

View file

@ -134,6 +134,7 @@
android:id="@+id/creator_thumbnail"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/cd_creator_thumbnail"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
app:layout_constraintLeft_toLeftOf="parent"
@ -205,6 +206,7 @@
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_download"
android:scaleType="fitXY"
app:srcCompat="@drawable/download_for_offline" />
</FrameLayout>
@ -213,6 +215,7 @@
android:id="@+id/thumbnail_platform"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube" />
</LinearLayout>
@ -230,6 +233,7 @@
android:id="@+id/button_add_to_watch_later"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/cd_button_add_to_watch_later"
android:layout_marginEnd="5dp"
android:background="@drawable/edit_text_background"
app:srcCompat="@drawable/ic_clock_white" />

View file

@ -40,6 +40,7 @@
android:id="@+id/thumbnail_platform_nested"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_margin="5dp"
@ -173,6 +174,7 @@
android:id="@+id/creator_thumbnail"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/cd_creator_thumbnail"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="10dp"
@ -242,6 +244,7 @@
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_download"
android:scaleType="fitXY"
app:srcCompat="@drawable/download_for_offline" />
</FrameLayout>
@ -250,6 +253,7 @@
android:id="@+id/thumbnail_platform"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@string/cd_platform_indicator"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube"/>
</LinearLayout>
@ -267,6 +271,7 @@
android:id="@+id/button_add_to_watch_later"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/cd_button_add_to_watch_later"
android:layout_marginEnd="5dp"
android:background="@drawable/edit_text_background"
app:srcCompat="@drawable/ic_clock_white" />

View file

@ -104,6 +104,7 @@
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cd_download_indicator"
android:scaleType="fitXY"
app:srcCompat="@drawable/download_for_offline" />
</FrameLayout>
@ -112,6 +113,7 @@
android:id="@+id/thumbnail_platform"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/cd_platform_indicator"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:layout_gravity="end"
@ -188,6 +190,7 @@
android:id="@+id/button_add_to_watch_later"
android:layout_width="wrap_content"
android:layout_height="27dp"
android:contentDescription="@string/cd_button_add_to_watch_later"
android:src="@drawable/ic_clock_white"
android:paddingTop="7dp"
android:paddingBottom="6dp"

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