mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 453030d561 | |||
| e080702a52 | |||
| 3909343adc | |||
| dc76934d0e | |||
| 6cf47d592a | |||
| 1507c70729 | |||
| d6a23ac0de | |||
| 17df396672 | |||
| 0c5ba0cd39 | |||
| 183aeb18a0 | |||
| 8d08e19cd2 | |||
| a882d04d26 | |||
| c4d06c1ba2 | |||
| 4dfcd47901 | |||
| 4c0c1abb4b | |||
| 6f44071186 | |||
| 29910a2698 | |||
| b5da0d4462 | |||
| 99fb9b3462 | |||
| 5f0a89d13b | |||
| f311561e6f | |||
| 2fc944ddd9 | |||
| a2970b86ee | |||
| ac9a51f105 | |||
| 90dca2537a | |||
| 4df227147c | |||
| 1fb55dca0a | |||
| 3d7b347e49 | |||
| 769ec9f59a | |||
| dee310de3d | |||
| 0af4bad906 | |||
| 4731673ba3 | |||
| 8745221cbd | |||
| 742d95440e | |||
| 180b320cd7 | |||
| cc8dffc485 | |||
| a64fd2cf35 | |||
| 4aceb364d9 | |||
| 76d9bac0ec | |||
| 2b8dc41d0d | |||
| 33430c538c | |||
| 03e9cb398b | |||
| 2aef2ebec1 | |||
| 5e5fffbf97 | |||
| 51ac604e31 | |||
| 4e49b5bc63 | |||
| 658cbc5e00 | |||
| 2ceb4c5644 | |||
| 2738954af7 | |||
| db5aaf0b84 | |||
| e1abb7f8ae | |||
| 3310ac6008 | |||
| 09879c83e9 | |||
| 7aa8b6bc14 | |||
| cac8a8fde4 | |||
| 01cb544dfd | |||
| b9239b6177 | |||
| 96ca3f62a2 | |||
| 73ad783881 | |||
| 3bfcf65535 | |||
| 8b3b27a2a8 | |||
| 82f214f155 | |||
| 4ee127fe13 | |||
| 1e4aefb7d5 | |||
| 2a825a9f83 | |||
| 6695774037 | |||
| a10bc8c7de | |||
| c1e6e401cc | |||
| 98b6213886 | |||
| b6671c653c | |||
| 55d042bee3 | |||
| 80034ad131 | |||
| 30c41044da | |||
| e369676808 | |||
| 2fa9e65bee | |||
| cf96bd1ec0 | |||
| 1f5a069877 | |||
| adc5013ea4 | |||
| 515c5e00e9 | |||
| ba9f843368 | |||
| 0653f88c49 | |||
| 4ce9f64808 | |||
| 4fa0229ccb | |||
| 42dd8d6152 | |||
| 0a839b4814 | |||
| 586db317dd | |||
| ae36a24ad1 | |||
| 9a435f8859 | |||
| 81162c5df2 | |||
| c7c3ddfc96 | |||
| 830d3a9022 | |||
| a1c2d19daf | |||
| bd87a47551 | |||
| 76103a2a8c | |||
| f63f9dd6db |
@@ -26,7 +26,7 @@ body:
|
||||
label: Reproduction steps
|
||||
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
||||
placeholder: |
|
||||
0. Play a Youtube video
|
||||
0. Play a YouTube video
|
||||
1. Press on Download button
|
||||
2. Select quality 1440p
|
||||
3. Grayjay crashes when attempting to download
|
||||
@@ -83,7 +83,7 @@ body:
|
||||
- "Spotify"
|
||||
- "TedTalks"
|
||||
- "Twitch"
|
||||
- "Youtube"
|
||||
- "YouTube"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -106,3 +106,9 @@
|
||||
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
||||
path = app/src/stable/assets/sources/crunchyroll
|
||||
url = ../plugins/crunchyroll.git
|
||||
[submodule "app/src/stable/assets/sources/mixcloud"]
|
||||
path = app/src/stable/assets/sources/mixcloud
|
||||
url = ../plugins/mixcloud.git
|
||||
[submodule "app/src/unstable/assets/sources/mixcloud"]
|
||||
path = app/src/unstable/assets/sources/mixcloud
|
||||
url = ../plugins/mixcloud.git
|
||||
|
||||
+3
-2
@@ -154,9 +154,10 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.dagger:dagger:2.48'
|
||||
//implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'androidx.test:monitor:1.7.2'
|
||||
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
|
||||
@@ -251,6 +251,9 @@ class PlatformVideo extends PlatformContent {
|
||||
this.duration = obj.duration ?? -1; //Long
|
||||
this.viewCount = obj.viewCount ?? -1; //Long
|
||||
|
||||
this.playbackTime = obj.playbackTime ?? -1;
|
||||
this.playbackDate = obj.playbackDate ?? undefined;
|
||||
|
||||
this.isLive = obj.isLive ?? false; //Boolean
|
||||
this.isShort = !!obj.isShort ?? false;
|
||||
}
|
||||
@@ -464,14 +467,20 @@ class AudioUrlWidevineSource extends AudioUrlSource {
|
||||
this.getLicenseRequestExecutor = () => {
|
||||
return {
|
||||
executeRequest: (url, _headers, _method, license_request_data) => {
|
||||
return http.POST(
|
||||
const response = http.POST(
|
||||
url,
|
||||
license_request_data,
|
||||
{ Authorization: `Bearer ${obj.bearerToken}` },
|
||||
false,
|
||||
true
|
||||
).body
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.body) {
|
||||
throw new ScriptException("Unable to acquire license key");
|
||||
}
|
||||
|
||||
return response.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,6 +794,7 @@ let plugin = {
|
||||
//To override by plugin
|
||||
const source = {
|
||||
getHome() { return new ContentPager([], false, {}); },
|
||||
getShorts() { return new VideoPager([], false, {}); },
|
||||
|
||||
enable(config){ },
|
||||
disable() {},
|
||||
|
||||
@@ -603,6 +603,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else -> 2.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
|
||||
var shortsPregenerate: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
|
||||
@@ -424,7 +424,7 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showCastingDialog(context: Context) {
|
||||
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null) {
|
||||
val dialog = ConnectedCastingDialog(context);
|
||||
@@ -432,6 +432,7 @@ class UIDialogs {
|
||||
dialog.setOwnerActivity(context)
|
||||
}
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
} else {
|
||||
@@ -444,21 +445,24 @@ class UIDialogs {
|
||||
if (c is Activity) {
|
||||
dialog.setOwnerActivity(c);
|
||||
}
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
||||
fun showCastingTutorialDialog(context: Context) {
|
||||
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val dialog = CastingHelpDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showCastingAddDialog(context: Context) {
|
||||
fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val dialog = CastingAddDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
@@ -129,115 +129,163 @@ class UISlideOverlays {
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
|
||||
val menu = SlideUpMenuOverlay(
|
||||
container.context,
|
||||
container,
|
||||
"Subscription Settings",
|
||||
null,
|
||||
true,
|
||||
listOf()
|
||||
);
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()) else null,
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if(it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if(it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(
|
||||
listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications =
|
||||
menu?.selectOption(null, "notifications", true, true)
|
||||
?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()
|
||||
) else null,
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups =
|
||||
ArrayList<SubscriptionGroup>(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? =
|
||||
null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if (it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup =
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroup(
|
||||
it.id
|
||||
)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if (it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
|
||||
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(
|
||||
actualGroup
|
||||
);
|
||||
groups.addAll(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()
|
||||
),
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive =
|
||||
menu?.selectOption(null, "fetchLive", true, true)
|
||||
?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams =
|
||||
menu?.selectOption(null, "fetchStreams", true, true)
|
||||
?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts =
|
||||
menu?.selectOption(null, "fetchPosts", true, true)
|
||||
?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
|
||||
SlideUpMenuGroup(container.context, "Actions",
|
||||
"Various things you can do with this subscription",
|
||||
@@ -245,61 +293,82 @@ class UISlideOverlays {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
|
||||
showCreateSubscriptionGroup(container, subscription.channel);
|
||||
}, false)*/
|
||||
).filterNotNull());
|
||||
).filterNotNull()
|
||||
);
|
||||
|
||||
menu.setItems(items);
|
||||
menu.setItems(items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
if (subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if (subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if (subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if (subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if (subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
|
||||
if(subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
|
||||
if (subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Enable 'Background Update' in settings for notifications to work"
|
||||
);
|
||||
|
||||
if(mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications", null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(mainContext, SettingsActivity::class.java);
|
||||
intent.putExtra("query", mainContext.getString(R.string.background_update));
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
return@subscribe;
|
||||
}
|
||||
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(container.context, "Android notifications are disabled");
|
||||
if(mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
if (mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(
|
||||
mainContext,
|
||||
R.drawable.ic_settings,
|
||||
"Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications",
|
||||
null,
|
||||
0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(
|
||||
mainContext,
|
||||
SettingsActivity::class.java
|
||||
);
|
||||
intent.putExtra(
|
||||
"query",
|
||||
mainContext.getString(R.string.background_update)
|
||||
);
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
return@subscribe;
|
||||
} else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Android notifications are disabled"
|
||||
);
|
||||
if (mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
menu.setOk("Save");
|
||||
|
||||
menu.show();
|
||||
menu.show();
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show subscription overlay.", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -74,6 +75,7 @@ class LoginActivity : AppCompatActivity() {
|
||||
finish();
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
@@ -86,6 +88,19 @@ class LoginActivity : AppCompatActivity() {
|
||||
//TODO: Find most reliable way to wait for page js to finish
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
|
||||
if(loginWarnings.size > 0) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { it.url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
@@ -169,6 +170,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
|
||||
lateinit var _fragWatchlist: WatchLaterFragment;
|
||||
lateinit var _fragHistory: HistoryFragment;
|
||||
lateinit var _fragShorts: ShortsFragment;
|
||||
lateinit var _fragSourceDetail: SourceDetailFragment;
|
||||
lateinit var _fragDownloads: DownloadsFragment;
|
||||
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
|
||||
@@ -338,6 +340,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragWebDetail = WebDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
_fragShorts = ShortsFragment.newInstance();
|
||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||
_fragDownloads = DownloadsFragment();
|
||||
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
|
||||
@@ -1253,6 +1256,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
WebDetailFragment::class -> _fragWebDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
ShortsFragment::class -> _fragShorts as T;
|
||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||
DownloadsFragment::class -> _fragDownloads as T;
|
||||
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
|
||||
@@ -36,6 +37,11 @@ interface IPlatformClient {
|
||||
*/
|
||||
fun getHome(): IPager<IPlatformContent>
|
||||
|
||||
/**
|
||||
* Gets the shorts feed
|
||||
*/
|
||||
fun getShorts(): IPager<IPlatformVideo>
|
||||
|
||||
//Search
|
||||
/**
|
||||
* Gets search suggestion for the provided query string
|
||||
@@ -176,6 +182,10 @@ interface IPlatformClient {
|
||||
* Retrieves the subscriptions of the currently logged in user
|
||||
*/
|
||||
fun getUserSubscriptions(): Array<String>;
|
||||
/**
|
||||
* Retrieves the history of the currently logged in user
|
||||
*/
|
||||
fun getUserHistory(): IPager<IPlatformContent>;
|
||||
|
||||
|
||||
fun isClaimTypeSupported(claimType: Int): Boolean;
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -26,12 +27,17 @@ class LiveChatManager {
|
||||
private val _emojiCache: EmojiCache = EmojiCache();
|
||||
private val _pager: IPager<IPlatformLiveEvent>?;
|
||||
|
||||
private var _position: Long = 0;
|
||||
private var _eventsPosition: Long = 0;
|
||||
|
||||
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
||||
|
||||
private var _startCounter = 0;
|
||||
|
||||
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
||||
|
||||
val isVOD get() = _pager is JSVODEventPager;
|
||||
|
||||
var viewCount: Long = 0
|
||||
private set;
|
||||
|
||||
@@ -39,8 +45,24 @@ class LiveChatManager {
|
||||
_scope = scope;
|
||||
_pager = pager;
|
||||
viewCount = initialViewCount;
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
handleEvents(pager.getResults());
|
||||
if(pager is JSVODEventPager)
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
else
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
|
||||
if(pager is JSVODEventPager) {
|
||||
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
|
||||
//TODO: Remove this once dripfeed is done properly
|
||||
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = _eventsPosition + 1500;
|
||||
}
|
||||
else
|
||||
handleEvents(pager.getResults());
|
||||
}
|
||||
|
||||
fun start() {
|
||||
@@ -52,6 +74,10 @@ class LiveChatManager {
|
||||
_startCounter++;
|
||||
}
|
||||
|
||||
fun setVideoPosition(ms: Long) {
|
||||
_position = ms;
|
||||
}
|
||||
|
||||
fun getHistory(): List<IPlatformLiveEvent> {
|
||||
synchronized(_history) {
|
||||
return _history.toList();
|
||||
@@ -85,13 +111,34 @@ class LiveChatManager {
|
||||
try {
|
||||
while(_startCounter == counter) {
|
||||
var nextInterval = 1000L;
|
||||
if(_pager is JSVODEventPager && _eventsPosition > _position) {
|
||||
delay(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if(_pager == null || !_pager.hasMorePages())
|
||||
return@launch;
|
||||
_pager.nextPage();
|
||||
val newEvents = _pager.getResults();
|
||||
val newEvents = if(_pager is JSVODEventPager) {
|
||||
val requestPosition = _position;
|
||||
_pager.nextPage(requestPosition.toInt());
|
||||
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
replayResults;
|
||||
}
|
||||
else {
|
||||
_pager.nextPage();
|
||||
_pager.getResults();
|
||||
}
|
||||
if(_pager is JSLiveEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
else if(_pager is JSVODEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
|
||||
if(newEvents.size > 0)
|
||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||
|
||||
@@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
|
||||
val hasGetContentChapters: Boolean = false,
|
||||
val hasPeekChannelContents: Boolean = false,
|
||||
val hasGetChannelPlaylists: Boolean = false,
|
||||
val hasGetContentRecommendations: Boolean = false
|
||||
val hasGetContentRecommendations: Boolean = false,
|
||||
val hasGetUserHistory: Boolean = false
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IPlatformLiveEvent {
|
||||
val type : LiveEventType;
|
||||
var time: Long;
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -18,12 +18,15 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
val colorName: String?;
|
||||
val badges: List<String>;
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) {
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
|
||||
this.name = name;
|
||||
this.message = message;
|
||||
this.thumbnail = thumbnail;
|
||||
this.colorName = colorName;
|
||||
this.badges = badges ?: listOf();
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -39,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
obj.getOrThrow(config, "name", contextName),
|
||||
obj.getOrThrow(config, "thumbnail", contextName, true),
|
||||
obj.getOrThrow(config, "message", contextName),
|
||||
colorName, badges);
|
||||
colorName, badges,
|
||||
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
|
||||
var expire: Int = 6000;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
||||
this.name = name;
|
||||
|
||||
@@ -10,6 +10,8 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
||||
|
||||
val emojis: HashMap<String, String>;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(emojis: HashMap<String, String>) {
|
||||
this.emojis = emojis;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class LiveEventRaid: IPlatformLiveEvent {
|
||||
val targetUrl: String;
|
||||
val isOutgoing: Boolean;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
||||
this.targetName = name;
|
||||
this.targetUrl = url;
|
||||
|
||||
@@ -10,6 +10,8 @@ class LiveEventViewCount: IPlatformLiveEvent {
|
||||
|
||||
val viewCount: Int;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(viewCount: Int) {
|
||||
this.viewCount = viewCount;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* A search result representing a video (overview data)
|
||||
@@ -12,6 +13,9 @@ interface IPlatformVideo : IPlatformContent {
|
||||
val duration: Long;
|
||||
val viewCount: Long;
|
||||
|
||||
val playbackTime: Long;
|
||||
val playbackDate: OffsetDateTime?;
|
||||
|
||||
val isLive : Boolean;
|
||||
|
||||
val isShort: Boolean;
|
||||
|
||||
+6
-3
@@ -3,11 +3,10 @@ package com.futo.platformplayer.api.media.models.video
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnail
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
@@ -18,7 +17,7 @@ open class SerializedPlatformVideo(
|
||||
override val contentType: ContentType = ContentType.MEDIA,
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails,
|
||||
override val thumbnails: Thumbnails = Thumbnails(),
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
@JsonNames("datetime", "dateTime")
|
||||
@@ -33,6 +32,10 @@ open class SerializedPlatformVideo(
|
||||
|
||||
override val isLive: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
|
||||
+4
-1
@@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
@@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails(
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
||||
@@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -124,6 +126,7 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||
val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true
|
||||
|
||||
fun getSubscriptionRateLimit(): Int? {
|
||||
val pluginRateLimit = config.subscriptionRateLimit;
|
||||
@@ -269,7 +272,8 @@ open class JSClient : IPlatformClient {
|
||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
|
||||
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -328,6 +332,13 @@ open class JSClient : IPlatformClient {
|
||||
plugin.executeTyped("source.getHome()"));
|
||||
}
|
||||
|
||||
@JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform")
|
||||
override fun getShorts(): IPager<IPlatformVideo> = isBusyWith("getShorts") {
|
||||
ensureEnabled()
|
||||
return@isBusyWith JSVideoPager(config, this,
|
||||
plugin.executeTyped("source.getShorts()"))
|
||||
}
|
||||
|
||||
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
||||
@JSDocsParameter("query", "Query to complete suggestions for")
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
||||
@@ -702,6 +713,13 @@ open class JSClient : IPlatformClient {
|
||||
.toTypedArray();
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
|
||||
override fun getUserHistory(): IPager<IPlatformContent> {
|
||||
ensureEnabled();
|
||||
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
try {
|
||||
plugin.start();
|
||||
|
||||
+28
-3
@@ -1,6 +1,10 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Dictionary
|
||||
|
||||
@Serializable
|
||||
class SourcePluginAuthConfig(
|
||||
val loginUrl: String,
|
||||
val completionUrl: String? = null,
|
||||
@@ -11,5 +15,26 @@ class SourcePluginAuthConfig(
|
||||
val userAgent: String? = null,
|
||||
val loginButton: String? = null,
|
||||
val domainHeadersToFind: Map<String, List<String>>? = null,
|
||||
val loginWarning: String? = null
|
||||
) { }
|
||||
val loginWarning: String? = null,
|
||||
val loginWarnings: List<Warning>? = null
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
class Warning(
|
||||
val url: String,
|
||||
val text: String?,
|
||||
val details: String? = null,
|
||||
val once: Boolean? = true
|
||||
) {
|
||||
@Contextual
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
return _regex ?: url.let {
|
||||
val reg = Regex(it);
|
||||
_regex = reg;
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ class SourcePluginConfig(
|
||||
var subscriptionRateLimit: Int? = null,
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var enableInShorts: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null,
|
||||
var developerSubmitUrl: String? = null,
|
||||
|
||||
+20
-2
@@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -103,12 +109,22 @@ class SourcePluginDescriptor {
|
||||
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
|
||||
var enableHome: Boolean? = null;
|
||||
|
||||
|
||||
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
|
||||
var enableSearch: Boolean? = null;
|
||||
|
||||
@FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3)
|
||||
var enableShorts: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
|
||||
@FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
|
||||
var sync = Sync();
|
||||
@Serializable
|
||||
class Sync {
|
||||
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
|
||||
var enableHistorySync: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
|
||||
var rateLimit = RateLimit();
|
||||
@Serializable
|
||||
class RateLimit {
|
||||
@@ -143,6 +159,8 @@ class SourcePluginDescriptor {
|
||||
tabEnabled.enableHome = config.enableInHome
|
||||
if(tabEnabled.enableSearch == null)
|
||||
tabEnabled.enableSearch = config.enableInSearch
|
||||
if(tabEnabled.enableShorts == null)
|
||||
tabEnabled.enableShorts = config.enableInShorts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -21,6 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
@@ -85,12 +86,12 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced
|
||||
}
|
||||
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
@@ -60,7 +61,7 @@ class JSComment : IPlatformComment {
|
||||
if(!_hasGetReplies)
|
||||
return null;
|
||||
|
||||
val obj = _comment!!.invoke<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
|
||||
return JSCommentPager(_config!!, plugin, obj);
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ abstract class JSPager<T> : IPager<T> {
|
||||
protected var pager: V8ValueObject;
|
||||
|
||||
private var _lastResults: List<T>? = null;
|
||||
private var _resultChanged: Boolean = true;
|
||||
private var _hasMorePages: Boolean = false;
|
||||
protected var _resultChanged: Boolean = true;
|
||||
protected var _hasMorePages: Boolean = false;
|
||||
//private var _morePagesWasFalse: Boolean = false;
|
||||
|
||||
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
||||
@@ -41,7 +41,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
}
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _hasMorePages;
|
||||
return _hasMorePages && !pager.isClosed;
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
|
||||
+3
-2
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
@@ -68,12 +69,12 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
return null;
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getSourcePlugin
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -38,7 +39,7 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
throw IllegalStateException("This subtitle doesn't support getSubtitles..");
|
||||
|
||||
return _obj.getSourcePlugin()?.busy {
|
||||
val v8String = _obj.invoke<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
val v8String = _obj.invokeV8<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
return@busy v8String.value;
|
||||
} ?: "";
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVODEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
||||
override var nextRequest: Int;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") {
|
||||
warnIfMainThread("VODEventPager.nextPage");
|
||||
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") {
|
||||
pager.invokeV8<V8Value>("nextPage", ms);
|
||||
};
|
||||
if(newPager is V8ValueObject)
|
||||
pager = newPager;
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
}
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
override fun nextPage() = nextPage(0);
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent {
|
||||
return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
@@ -17,6 +21,10 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
final override val duration: Long;
|
||||
final override val viewCount: Long;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
final override val isLive: Boolean;
|
||||
final override val isShort: Boolean;
|
||||
|
||||
@@ -29,5 +37,11 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
viewCount = _content.getOrThrow(config, "viewCount", contextName);
|
||||
isLive = _content.getOrThrow(config, "isLive", contextName);
|
||||
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
|
||||
playbackTime = _content.getOrDefault<Long>(config, "playbackTime", contextName, -1)?.toLong() ?: -1;
|
||||
val playbackDateInt = _content.getOrDefault<Int>(config, "playbackDate", contextName, null)?.toLong();
|
||||
if(playbackDateInt == null || playbackDateInt == 0.toLong())
|
||||
playbackDate = null;
|
||||
else
|
||||
playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -7,6 +7,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
@@ -24,13 +25,17 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private val _plugin: JSClient;
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
private val _hasGetPlaybackTracker: Boolean;
|
||||
private val _hasGetVODEvents: Boolean;
|
||||
|
||||
//Details
|
||||
override val description : String;
|
||||
@@ -46,7 +51,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
|
||||
override val subtitles: List<ISubtitleSource>;
|
||||
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||
val contextName = "VideoDetails";
|
||||
_plugin = plugin;
|
||||
@@ -71,6 +75,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
_hasGetVODEvents = _content.has("getVODEvents");
|
||||
}
|
||||
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||
@@ -86,7 +91,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private fun getPlaybackTrackerJS(): IPlaybackTracker? {
|
||||
return _plugin.busy {
|
||||
V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") {
|
||||
val tracker = _content.invoke<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
val tracker = _content.invokeV8<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
?: return@catchScriptErrors null;
|
||||
if(tracker is V8ValueObject)
|
||||
return@catchScriptErrors JSPlaybackTracker(_plugin, tracker);
|
||||
@@ -111,7 +116,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
return _plugin.busy {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return@busy JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
}
|
||||
@@ -130,11 +135,22 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
|
||||
private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
|
||||
return _plugin.busy {
|
||||
val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invokeV8<V8Value>("getComments", arrayOf<Any>());
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return@busy null;
|
||||
|
||||
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
}
|
||||
|
||||
fun hasVODEvents(): Boolean{
|
||||
return _hasGetVODEvents;
|
||||
}
|
||||
fun getVODEvents(url: String): IPager<IPlatformLiveEvent>? = _plugin.busy {
|
||||
if(!_hasGetVODEvents)
|
||||
return@busy null;
|
||||
|
||||
return@busy JSVODEventPager(_plugin.config, _plugin,
|
||||
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
||||
}
|
||||
}
|
||||
+13
@@ -17,6 +17,7 @@ import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -57,12 +58,24 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
private var _pregenerate: V8Deferred<String?>? = null;
|
||||
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||
_pregenerate = generateAsync(scope);
|
||||
return _pregenerate;
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
|
||||
val pregenerated = _pregenerate;
|
||||
if(pregenerated != null) {
|
||||
Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio");
|
||||
return pregenerated;
|
||||
}
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
var result: V8Deferred<V8ValueString>? = null;
|
||||
|
||||
+12
@@ -18,6 +18,7 @@ import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -65,11 +66,22 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
private var _pregenerate: V8Deferred<String?>? = null;
|
||||
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||
_pregenerate = generateAsync(scope);
|
||||
return _pregenerate;
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
val pregenerated = _pregenerate;
|
||||
if(pregenerated != null) {
|
||||
Logger.w("JSDashManifestRawSource", "Returning pre-generated video");
|
||||
return pregenerated;
|
||||
}
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
|
||||
+5
-2
@@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
@@ -19,7 +18,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
@@ -53,6 +52,10 @@ class LocalVideoDetails: IPlatformVideoDetails {
|
||||
override val isLive: Boolean = false;
|
||||
override val isShort: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
constructor(file: File) {
|
||||
id = PlatformID("Local", file.path, "LOCAL")
|
||||
name = file.name;
|
||||
|
||||
+5
-7
@@ -7,12 +7,12 @@ import java.util.stream.IntStream
|
||||
* A Content MultiPager that returns results based on a specified distribution
|
||||
* TODO: Merge all basic distribution pagers
|
||||
*/
|
||||
class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
|
||||
|
||||
private val dist : HashMap<IPager<IPlatformContent>, Float>;
|
||||
private val distConsumed : HashMap<IPager<IPlatformContent>, Float>;
|
||||
private val dist : HashMap<IPager<T>, Float>;
|
||||
private val distConsumed : HashMap<IPager<T>, Float>;
|
||||
|
||||
constructor(pagers : Map<IPager<IPlatformContent>, Float>) : super(pagers.keys.toMutableList()) {
|
||||
constructor(pagers : Map<IPager<T>, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) {
|
||||
val distTotal = pagers.values.sum();
|
||||
dist = HashMap();
|
||||
|
||||
@@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
|
||||
override fun selectItemIndex(options: Array<SelectionOption<T>>): Int {
|
||||
if(options.size == 0)
|
||||
return -1;
|
||||
var bestIndex = 0;
|
||||
@@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
distConsumed[options[bestIndex].pager.getPager()] = bestConsumed;
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -62,6 +62,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
private val MAX_LAUNCH_RETRIES = 3
|
||||
private var _lastLaunchTime_ms = 0L
|
||||
private var _retryJob: Job? = null
|
||||
private var _autoLaunchEnabled = true
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
@@ -305,6 +306,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoLaunchEnabled = true
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
@@ -546,6 +548,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
if (appId == "CC1AD845") {
|
||||
sessionIsRunning = true;
|
||||
_autoLaunchEnabled = false
|
||||
|
||||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
@@ -558,7 +561,6 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
_transportId = transportId;
|
||||
|
||||
requestMediaStatus();
|
||||
playVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,21 +570,22 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
setTime(0.0)
|
||||
_transportId = null
|
||||
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
if (_autoLaunchEnabled) {
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
}
|
||||
} else {
|
||||
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
|
||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
@@ -599,6 +602,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
} else {
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
_autoLaunchEnabled = false
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
@@ -636,10 +640,16 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
stopVideo();
|
||||
}
|
||||
}
|
||||
|
||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
||||
playVideo()
|
||||
}
|
||||
} else if (type == "CLOSE") {
|
||||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stop();
|
||||
stopCasting();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
@@ -676,6 +686,10 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_contentId = null
|
||||
_contentType = null
|
||||
_streamType = null
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
|
||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
@@ -438,130 +439,108 @@ class StateCasting {
|
||||
_castId.incrementAndGet()
|
||||
}
|
||||
|
||||
fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean {
|
||||
val ad = activeDevice ?: return false;
|
||||
if (ad.connectionState != CastConnectionState.CONNECTED) {
|
||||
return false;
|
||||
}
|
||||
suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val ad = activeDevice ?: return@withContext false;
|
||||
if (ad.connectionState != CastConnectionState.CONNECTED) {
|
||||
return@withContext false;
|
||||
}
|
||||
|
||||
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
|
||||
val castId = _castId.incrementAndGet()
|
||||
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
|
||||
val castId = _castId.incrementAndGet()
|
||||
|
||||
var sourceCount = 0;
|
||||
if (videoSource != null) sourceCount++;
|
||||
if (audioSource != null) sourceCount++;
|
||||
if (subtitleSource != null) sourceCount++;
|
||||
var sourceCount = 0;
|
||||
if (videoSource != null) sourceCount++;
|
||||
if (audioSource != null) sourceCount++;
|
||||
if (subtitleSource != null) sourceCount++;
|
||||
|
||||
if (sourceCount < 1) {
|
||||
throw Exception("At least one source should be specified.");
|
||||
}
|
||||
if (sourceCount < 1) {
|
||||
throw Exception("At least one source should be specified.");
|
||||
}
|
||||
|
||||
if (sourceCount > 1) {
|
||||
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as local HLS");
|
||||
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
if (sourceCount > 1) {
|
||||
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as local HLS");
|
||||
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as local DASH");
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as local DASH");
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
}
|
||||
} else {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
|
||||
if (isRawDash) {
|
||||
Logger.i(TAG, "Casting as raw DASH");
|
||||
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
|
||||
if (isRawDash) {
|
||||
Logger.i(TAG, "Casting as raw DASH");
|
||||
|
||||
try {
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e);
|
||||
}
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} else {
|
||||
if (ad is FCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else {
|
||||
if (ad is FCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
}
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if(audioSource is IHLSManifestAudioSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if (videoSource is LocalVideoSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(video, videoSource, resumePosition, speed);
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e);
|
||||
}
|
||||
}
|
||||
} else if (audioSource is JSDashManifestRawAudioSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource audio");
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
|
||||
).filterNotNull().joinToString(", ");
|
||||
throw UnsupportedCastException(str);
|
||||
}
|
||||
}
|
||||
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
return true;
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if(audioSource is IHLSManifestAudioSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if (videoSource is LocalVideoSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(video, videoSource, resumePosition, speed);
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} else if (audioSource is JSDashManifestRawAudioSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource audio");
|
||||
castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} else {
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
|
||||
).filterNotNull().joinToString(", ");
|
||||
throw UnsupportedCastException(str);
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext true;
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeVideo(): Boolean {
|
||||
@@ -773,7 +752,7 @@ class StateCasting {
|
||||
|
||||
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
|
||||
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
@@ -1136,9 +1115,14 @@ class StateCasting {
|
||||
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
|
||||
private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean {
|
||||
val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true
|
||||
return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier
|
||||
}
|
||||
|
||||
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
|
||||
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
@@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
|
||||
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
|
||||
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject> {
|
||||
val remotes = mutableListOf<V8RemoteObject>();
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject?> {
|
||||
val remotes = mutableListOf<V8RemoteObject?>();
|
||||
for(obj in objs)
|
||||
remotes.add(createRemoteObject(obj)!!);
|
||||
remotes.add(createRemoteObject(obj));
|
||||
return remotes;
|
||||
}
|
||||
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {
|
||||
|
||||
@@ -106,7 +106,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
};
|
||||
|
||||
_buttonTutorial.setOnClickListener {
|
||||
UIDialogs.showCastingTutorialDialog(context)
|
||||
UIDialogs.showCastingTutorialDialog(context, ownerActivity)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
private fun performDismiss(shouldShowCastingDialog: Boolean = true) {
|
||||
if (shouldShowCastingDialog) {
|
||||
UIDialogs.showCastingDialog(context);
|
||||
UIDialogs.showCastingDialog(context, ownerActivity);
|
||||
}
|
||||
|
||||
dismiss();
|
||||
|
||||
@@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
||||
dismiss()
|
||||
UIDialogs.showCastingAddDialog(context)
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonAdd.setOnClickListener {
|
||||
UIDialogs.showCastingAddDialog(context);
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -139,9 +139,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
|
||||
@@ -303,9 +303,10 @@ class VideoDownload {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
|
||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, resolvedPlaylistUrl))
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -351,9 +352,10 @@ class VideoDownload {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, resolvedPlaylistUrl))
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -717,7 +719,7 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
|
||||
var written = 0;
|
||||
var written: Long = 0;
|
||||
var indexCounter = 0;
|
||||
onProgress(foundCues.count().toLong(), 0, 0);
|
||||
for(cue in foundCues) {
|
||||
@@ -742,7 +744,7 @@ class VideoDownload {
|
||||
|
||||
indexCounter++;
|
||||
}
|
||||
sourceLength = written.toLong();
|
||||
sourceLength = written;
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished");
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
|
||||
|
||||
override val isShort: Boolean get() = videoSerialized.isShort;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
//TODO: Offline subtitles
|
||||
override val subtitles: List<ISubtitleSource> = listOf();
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class V8RemoteObject {
|
||||
}
|
||||
|
||||
|
||||
fun List<V8RemoteObject>.serialize() : String {
|
||||
fun List<V8RemoteObject?>.serialize() : String {
|
||||
return _gson.toJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
@@ -61,7 +62,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
private var _query: String? = null
|
||||
private var _searchView: SearchView? = null
|
||||
|
||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||
val onContentClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
val onUrlClicked = Event1<String>();
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
@@ -208,10 +209,13 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
_searchView = searchView
|
||||
updateSearchViewVisibility()
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
||||
_adapterResults = PreviewContentListAdapter(lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
this.onContentClicked.subscribe { content, num ->
|
||||
val results = ArrayList(_results)
|
||||
this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results))
|
||||
}
|
||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
|
||||
_recyclerResults = view.findViewById(R.id.recycler_videos)
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(
|
||||
view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
|
||||
lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
|
||||
).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit)
|
||||
this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit)
|
||||
|
||||
+4
-1
@@ -15,6 +15,7 @@ import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -375,6 +376,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
fun newInstance() = MenuBottomBarFragment().apply { }
|
||||
|
||||
@UnstableApi
|
||||
//Add configurable buttons here
|
||||
var buttonDefinitions = listOf(
|
||||
ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, {
|
||||
@@ -390,13 +392,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate<ShortsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
|
||||
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
|
||||
val intent = Intent(c, SettingsActivity::class.java);
|
||||
|
||||
+8
@@ -211,6 +211,14 @@ class ChannelFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.onShortClicked.subscribe { v, _, pagerPair ->
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<ShortsFragment>(Triple(v, pagerPair!!.first, pagerPair.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.onAddToClicked.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if (content is IPlatformVideo) _slideUpOverlay =
|
||||
|
||||
+15
-3
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
@@ -19,6 +20,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
@@ -34,6 +36,9 @@ import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.withTimestamp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -59,7 +64,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
|
||||
_exoPlayer = player;
|
||||
|
||||
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
||||
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
|
||||
attachAdapterEvents(this);
|
||||
}
|
||||
}
|
||||
@@ -246,8 +251,15 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
}
|
||||
|
||||
//TODO: Is this still necessary?
|
||||
if(viewHolder.childViewHolder is ContentPreviewViewHolder)
|
||||
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
|
||||
if(viewHolder.childViewHolder is ContentPreviewViewHolder) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "playPreview failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopVideo() {
|
||||
|
||||
@@ -0,0 +1,883 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
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.platforms.js.SourcePluginConfig
|
||||
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.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.fragment.mainactivity.special.CommentsModalBottomSheet
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import com.futo.platformplayer.views.buttons.ShortsButton
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle
|
||||
import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.video.FutoShortPlayer
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
import com.futo.polycentric.core.Opinion
|
||||
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||
import com.google.android.material.button.MaterialButton
|
||||
//import com.google.android.material.button.MaterialButton
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import userpackage.Protocol
|
||||
|
||||
@UnstableApi
|
||||
class ShortView : FrameLayout {
|
||||
private lateinit var fragment: MainFragment
|
||||
private val player: FutoShortPlayer
|
||||
|
||||
private val channelInfo: LinearLayout
|
||||
private val creatorThumbnail: CreatorThumbnail
|
||||
private val channelName: TextView
|
||||
private val videoTitle: TextView
|
||||
private val videoSubtitle: TextView
|
||||
private val platformIndicator: PlatformIndicator
|
||||
|
||||
//TODO: Replace with non-material button
|
||||
private val backButton: MaterialButton
|
||||
private val backButtonContainer: ConstraintLayout
|
||||
|
||||
private val likeButton: ShortsButton
|
||||
//private val likeCount: TextView
|
||||
private val dislikeButton: ShortsButton
|
||||
//private val dislikeCount: TextView
|
||||
|
||||
private val commentsButton: ShortsButton
|
||||
private val shareButton: ShortsButton
|
||||
private val refreshButton: ShortsButton
|
||||
private val qualityButton: ShortsButton
|
||||
|
||||
private val playPauseOverlay: FrameLayout
|
||||
private val playPauseIcon: ImageView
|
||||
|
||||
private val overlayLoading: FrameLayout
|
||||
private val overlayLoadingSpinner: ImageView
|
||||
private lateinit var overlayQualityContainer: FrameLayout
|
||||
|
||||
private var overlayQualitySelector: SlideUpMenuOverlay? = null
|
||||
|
||||
private var video: IPlatformVideo? = null
|
||||
set(value) {
|
||||
field = value
|
||||
onVideoUpdated.emit(value)
|
||||
}
|
||||
private var videoDetails: IPlatformVideoDetails? = null
|
||||
|
||||
private var playWhenReady = false
|
||||
|
||||
private var _lastVideoSource: IVideoSource? = null
|
||||
private var _lastAudioSource: IAudioSource? = null
|
||||
private var _lastSubtitleSource: ISubtitleSource? = null
|
||||
|
||||
private var loadVideoTask: TaskHandler<String, IPlatformVideoDetails>? = null
|
||||
private var loadLikesTask: TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>? =
|
||||
null
|
||||
|
||||
val onResetTriggered = Event0()
|
||||
private val onPlayingToggled = Event1<Boolean>()
|
||||
private val onLikesLoaded = Event3<RatingLikeDislikes, Boolean, Boolean>()
|
||||
private val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>()
|
||||
private val onVideoUpdated = Event1<IPlatformVideo?>()
|
||||
|
||||
//TODO: Replace with non-material UI? Only true dependency on Material left
|
||||
private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet()
|
||||
|
||||
var likes: Long = 0
|
||||
set(value) {
|
||||
field = value
|
||||
likeButton.withPrimaryText(value.toString());
|
||||
//likeCount.text = value.toString()
|
||||
}
|
||||
|
||||
var dislikes: Long = 0
|
||||
set(value) {
|
||||
field = value
|
||||
dislikeButton.withPrimaryText(value.toString());
|
||||
//dislikeCount.text = value.toString()
|
||||
}
|
||||
|
||||
constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) {
|
||||
this.overlayQualityContainer = overlayQualityContainer
|
||||
|
||||
layoutParams = LayoutParams(
|
||||
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
this.fragment = fragment
|
||||
bottomSheet.mainFragment = fragment
|
||||
}
|
||||
|
||||
// Required constructor for XML inflation
|
||||
constructor(context: Context) : this(context, null, null)
|
||||
|
||||
// Required constructor for XML inflation with attributes
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null)
|
||||
|
||||
// Required constructor for XML inflation with attributes and style
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super(
|
||||
context, attrs, defStyleAttr ?: 0
|
||||
) {
|
||||
// Inflate the layout once here
|
||||
inflate(context, R.layout.view_short, this)
|
||||
|
||||
// Initialize all val properties using findViewById
|
||||
player = findViewById(R.id.short_player)
|
||||
channelInfo = findViewById(R.id.channel_info)
|
||||
creatorThumbnail = findViewById(R.id.creator_thumbnail)
|
||||
channelName = findViewById(R.id.channel_name)
|
||||
videoTitle = findViewById(R.id.video_title)
|
||||
videoSubtitle = findViewById(R.id.video_subtitle)
|
||||
platformIndicator = findViewById(R.id.short_platform_indicator)
|
||||
backButton = findViewById(R.id.back_button)
|
||||
backButtonContainer = findViewById(R.id.back_button_container)
|
||||
likeButton = findViewById(R.id.like_button)
|
||||
//likeCount = findViewById(R.id.like_count)
|
||||
dislikeButton = findViewById(R.id.dislike_button)
|
||||
//dislikeCount = findViewById(R.id.dislike_count)
|
||||
commentsButton = findViewById(R.id.comments_button)
|
||||
shareButton = findViewById(R.id.share_button)
|
||||
refreshButton = findViewById(R.id.refresh_button)
|
||||
qualityButton = findViewById(R.id.quality_button)
|
||||
playPauseOverlay = findViewById(R.id.play_pause_overlay)
|
||||
playPauseIcon = findViewById(R.id.play_pause_icon)
|
||||
overlayLoading = findViewById(R.id.short_view_loading_overlay)
|
||||
overlayLoadingSpinner = findViewById(R.id.short_view_loader)
|
||||
|
||||
player.setOnClickListener {
|
||||
if (player.activelyPlaying) {
|
||||
player.pause()
|
||||
onPlayingToggled.emit(false)
|
||||
} else {
|
||||
player.play()
|
||||
onPlayingToggled.emit(true)
|
||||
}
|
||||
}
|
||||
|
||||
onPlayingToggled.subscribe { playing ->
|
||||
if (playing) {
|
||||
playPauseIcon.setImageResource(R.drawable.ic_play)
|
||||
playPauseIcon.contentDescription = context.getString(R.string.play)
|
||||
} else {
|
||||
playPauseIcon.setImageResource(R.drawable.ic_pause)
|
||||
playPauseIcon.contentDescription = context.getString(R.string.pause)
|
||||
}
|
||||
showPlayPauseIcon()
|
||||
}
|
||||
|
||||
onVideoUpdated.subscribe {
|
||||
Logger.i(TAG, "Shorts videoUpdated [${it?.name}] (isDetail: ${it is IPlatformVideoDetails}, thumbnail: ${it?.author?.thumbnail})");
|
||||
videoTitle.text = it?.name
|
||||
videoSubtitle.text = if(it is IPlatformVideoDetails) it?.description; else "";
|
||||
platformIndicator.setPlatformFromClientID(it?.id?.pluginId)
|
||||
creatorThumbnail.setThumbnail(it?.author?.thumbnail, true)
|
||||
channelName.text = it?.author?.name
|
||||
}
|
||||
|
||||
backButton.setOnClickListener {
|
||||
fragment.closeSegment()
|
||||
}
|
||||
|
||||
channelInfo.setOnClickListener {
|
||||
fragment.navigate<ChannelFragment>(video?.author)
|
||||
}
|
||||
|
||||
videoTitle.setOnClickListener {
|
||||
if (!bottomSheet.isAdded) {
|
||||
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
commentsButton.onClick.subscribe {
|
||||
if (!bottomSheet.isAdded) {
|
||||
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
shareButton.onClick.subscribe {
|
||||
val url = video?.shareUrl ?: video?.url
|
||||
fragment.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, url)
|
||||
type = "text/plain"
|
||||
}, null))
|
||||
}
|
||||
|
||||
refreshButton.onClick.subscribe {
|
||||
onResetTriggered.emit()
|
||||
}
|
||||
|
||||
refreshButton.setOnLongClickListener {
|
||||
UIDialogs.toast(context, "Reload all platform shorts pagers")
|
||||
false
|
||||
}
|
||||
|
||||
qualityButton.onClick.subscribe {
|
||||
showVideoSettings()
|
||||
}
|
||||
|
||||
likeButton.onClick.subscribe {
|
||||
val checked = likeButton.iconId == R.drawable.ic_thumb_up_s // !likeButton.isChecked
|
||||
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
|
||||
if (checked) {
|
||||
likes++
|
||||
} else {
|
||||
likes--
|
||||
}
|
||||
|
||||
if(checked)
|
||||
likeButton.withIcon(R.drawable.ic_thumb_up_s_filled) //.isChecked = checked
|
||||
else
|
||||
likeButton.withIcon(R.drawable.ic_thumb_up_s)
|
||||
|
||||
if (dislikeButton.iconId == R.drawable.ic_thumb_down_s_filled && checked) {
|
||||
//dislikeButton.isChecked = false
|
||||
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
|
||||
dislikes--
|
||||
}
|
||||
|
||||
onLikeDislikeUpdated.emit(
|
||||
OnLikeDislikeUpdatedArgs(
|
||||
it, likes, checked, dislikes, !checked
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dislikeButton.onClick.subscribe {
|
||||
val checked = dislikeButton.iconId == R.drawable.ic_thumb_down_s //!dislikeButton.isChecked
|
||||
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
|
||||
if (checked) {
|
||||
dislikes++
|
||||
} else {
|
||||
dislikes--
|
||||
}
|
||||
|
||||
//dislikeButton.isChecked = checked
|
||||
if(checked)
|
||||
dislikeButton.withIcon(R.drawable.ic_thumb_down_s_filled) //.isChecked = checked
|
||||
else
|
||||
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
|
||||
|
||||
if (likeButton.iconId == R.drawable.ic_thumb_up_s_filled && checked) {
|
||||
//likeButton.isChecked = false
|
||||
likeButton.withIcon(R.drawable.ic_thumb_up_s);
|
||||
likes--
|
||||
}
|
||||
|
||||
onLikeDislikeUpdated.emit(
|
||||
OnLikeDislikeUpdatedArgs(
|
||||
it, likes, !checked, dislikes, checked
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onLikesLoaded.subscribe(tag) { rating, liked, disliked ->
|
||||
likes = rating.likes
|
||||
dislikes = rating.dislikes
|
||||
//likeButton.isChecked = liked
|
||||
//dislikeButton.isChecked = disliked
|
||||
|
||||
dislikeButton.visibility = VISIBLE
|
||||
likeButton.visibility = VISIBLE
|
||||
}
|
||||
|
||||
player.onPlaybackStateChanged.subscribe {
|
||||
val videoSource = _lastVideoSource
|
||||
|
||||
if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
|
||||
val videoTracks =
|
||||
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||
val audioTracks =
|
||||
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO }
|
||||
|
||||
val videoTrackFormats = mutableListOf<Format>()
|
||||
val audioTrackFormats = mutableListOf<Format>()
|
||||
|
||||
if (videoTracks != null) {
|
||||
for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i))
|
||||
}
|
||||
if (audioTracks != null) {
|
||||
for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i))
|
||||
}
|
||||
|
||||
updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height }
|
||||
.sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate }
|
||||
.sortedBy { it.bitrate })
|
||||
} else {
|
||||
updateQualitySourcesOverlay(videoDetails, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPlayPauseIcon() {
|
||||
val overlay = playPauseOverlay
|
||||
|
||||
overlay.alpha = 0f
|
||||
overlay.scaleX = 0f
|
||||
overlay.scaleY = 0f
|
||||
overlay.visibility = VISIBLE
|
||||
|
||||
overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400)
|
||||
.setInterpolator(OvershootInterpolator(1.2f)).start()
|
||||
|
||||
overlay.postDelayed({
|
||||
hidePlayPauseIcon()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
private fun hidePlayPauseIcon() {
|
||||
val overlay = playPauseOverlay
|
||||
|
||||
overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300)
|
||||
.setInterpolator(AccelerateInterpolator()).withEndAction {
|
||||
overlay.visibility = GONE
|
||||
}.start()
|
||||
}
|
||||
|
||||
// TODO merge this with the updateQualitySourcesOverlay for the normal video player
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||
Logger.i(TAG, "updateQualitySourcesOverlay")
|
||||
|
||||
val video: IPlatformVideoDetails?
|
||||
val localVideoSources: List<LocalVideoSource>?
|
||||
val localAudioSource: List<LocalAudioSource>?
|
||||
val localSubtitleSources: List<LocalSubtitleSource>?
|
||||
|
||||
val videoSources: List<IVideoSource>?
|
||||
val audioSources: List<IAudioSource>?
|
||||
|
||||
if (videoDetails is VideoLocal) {
|
||||
video = videoLocal?.videoSerialized
|
||||
localVideoSources = videoDetails.videoSource.toList()
|
||||
localAudioSource = videoDetails.audioSource.toList()
|
||||
localSubtitleSources = videoDetails.subtitlesSources.toList()
|
||||
videoSources = null
|
||||
audioSources = null
|
||||
} else {
|
||||
video = videoDetails
|
||||
videoSources = video?.video?.videoSources?.toList()
|
||||
audioSources =
|
||||
if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
||||
else null
|
||||
if (videoLocal != null) {
|
||||
localVideoSources = videoLocal.videoSource.toList()
|
||||
localAudioSource = videoLocal.audioSource.toList()
|
||||
localSubtitleSources = videoLocal.subtitlesSources.toList()
|
||||
} else {
|
||||
localVideoSources = null
|
||||
localAudioSource = null
|
||||
localSubtitleSources = null
|
||||
}
|
||||
}
|
||||
|
||||
val doDedup = Settings.instance.playback.simplifySources
|
||||
|
||||
val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct()
|
||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct()
|
||||
?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf()
|
||||
val bestAudioContainer =
|
||||
audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container }
|
||||
val bestAudioSources =
|
||||
if (doDedup) audioSources?.filter { it.container == bestAudioContainer }
|
||||
?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource })
|
||||
?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf()
|
||||
|
||||
val canSetSpeed = true
|
||||
val currentPlaybackRate = player.getPlaybackRate()
|
||||
overlayQualitySelector =
|
||||
SlideUpMenuOverlay(
|
||||
this.context, overlayQualityContainer, context.getString(
|
||||
R.string.quality
|
||||
), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString())
|
||||
onClick.subscribe { v ->
|
||||
|
||||
player.setPlaybackRate(v.toFloat())
|
||||
setSelected(v)
|
||||
|
||||
}
|
||||
} else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.stream_video), "video", (listOf(
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) })
|
||||
) + (liveStreamVideoFormats.map {
|
||||
SlideUpMenuItem(
|
||||
this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType
|
||||
?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) })
|
||||
}))
|
||||
)
|
||||
else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.video), "video", *bestVideoSources.map {
|
||||
val estSize = VideoHelper.estimateSourceSize(it)
|
||||
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map {
|
||||
val estSize = VideoHelper.estimateSourceSize(it)
|
||||
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup(
|
||||
this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
|
||||
}.toList().toTypedArray()
|
||||
)
|
||||
else null
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSelectVideoTrack(videoSource: IVideoSource) {
|
||||
Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)")
|
||||
if (_lastVideoSource == videoSource) return
|
||||
|
||||
_lastVideoSource = videoSource
|
||||
|
||||
playVideo(player.position)
|
||||
}
|
||||
|
||||
private fun handleSelectAudioTrack(audioSource: IAudioSource) {
|
||||
Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)")
|
||||
if (_lastAudioSource == audioSource) return
|
||||
|
||||
_lastAudioSource = audioSource
|
||||
|
||||
playVideo(player.position)
|
||||
}
|
||||
|
||||
private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) {
|
||||
Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)")
|
||||
var toSet: ISubtitleSource? = subtitleSource
|
||||
if (_lastSubtitleSource == subtitleSource) toSet = null
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
player.swapSubtitles(toSet)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
_lastSubtitleSource = toSet
|
||||
}
|
||||
|
||||
private fun showVideoSettings() {
|
||||
Logger.i(TAG, "showVideoSettings")
|
||||
|
||||
overlayQualitySelector?.selectOption("video", _lastVideoSource)
|
||||
overlayQualitySelector?.selectOption("audio", _lastAudioSource)
|
||||
overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource)
|
||||
|
||||
if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) {
|
||||
val videoTracks =
|
||||
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||
|
||||
var selectedQuality: Format? = null
|
||||
|
||||
if (videoTracks != null) {
|
||||
for (i in 0 until videoTracks.mediaTrackGroup.length) {
|
||||
if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) {
|
||||
selectedQuality = videoTracks.mediaTrackGroup.getFormat(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var videoMenuGroup: SlideUpMenuGroup? = null
|
||||
for (view in overlayQualitySelector!!.groupItems) {
|
||||
if (view is SlideUpMenuGroup && view.groupTag == "video") {
|
||||
videoMenuGroup = view
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedQuality != null) {
|
||||
videoMenuGroup?.getItem("auto")?.setSubText("")
|
||||
overlayQualitySelector?.selectOption("video", selectedQuality)
|
||||
} else {
|
||||
videoMenuGroup?.getItem("auto")
|
||||
?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}")
|
||||
overlayQualitySelector?.selectOption("video", "auto")
|
||||
}
|
||||
}
|
||||
|
||||
val currentPlaybackRate = player.getPlaybackRate()
|
||||
overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }
|
||||
?.let {
|
||||
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
|
||||
}
|
||||
|
||||
overlayQualitySelector?.show()
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) {
|
||||
this.fragment = fragment
|
||||
this.bottomSheet.mainFragment = fragment
|
||||
this.overlayQualityContainer = overlayQualityContainer
|
||||
}
|
||||
|
||||
fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) {
|
||||
if (this.video?.url == video.url) {
|
||||
return
|
||||
}
|
||||
this.video = video
|
||||
|
||||
refreshButton.visibility = if (isChannelShortsMode) {
|
||||
GONE
|
||||
} else {
|
||||
GONE //TODO: Revert?
|
||||
}
|
||||
backButtonContainer.visibility = if (isChannelShortsMode) {
|
||||
VISIBLE
|
||||
} else {
|
||||
GONE
|
||||
}
|
||||
|
||||
loadVideo(video.url)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun changeVideo(videoDetails: IPlatformVideoDetails) {
|
||||
if (video?.url == videoDetails.url) {
|
||||
return
|
||||
}
|
||||
|
||||
this.video = videoDetails
|
||||
this.videoDetails = videoDetails
|
||||
}
|
||||
|
||||
fun play() {
|
||||
loadLikes(this.video!!)
|
||||
player.clear()
|
||||
player.attach()
|
||||
player.clear()
|
||||
playVideo()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
playWhenReady = false
|
||||
|
||||
player.clear()
|
||||
player.detach()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
loadVideoTask?.cancel()
|
||||
loadLikesTask?.cancel()
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||
overlayLoading.visibility = VISIBLE
|
||||
} else {
|
||||
overlayLoading.visibility = GONE
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLikes(video: IPlatformVideo) {
|
||||
likeButton.visibility = GONE
|
||||
dislikeButton.visibility = GONE
|
||||
|
||||
loadLikesTask?.cancel()
|
||||
loadLikesTask =
|
||||
TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>(
|
||||
StateApp.instance.scopeGetter, {
|
||||
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||
val extraBytesRef =
|
||||
video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null, arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
)
|
||||
.build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
), extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
)
|
||||
|
||||
Pair(ref, queryReferencesResponse)
|
||||
}).success { (ref, queryReferencesResponse) ->
|
||||
val likes = queryReferencesResponse.countsList[0]
|
||||
val dislikes = queryReferencesResponse.countsList[1]
|
||||
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())
|
||||
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())
|
||||
onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked)
|
||||
onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like)
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike)
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral)
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill")
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions()
|
||||
Logger.i(TAG, "Finished backfill")
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref, args.hasLiked, args.hasDisliked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
loadLikesTask?.run(video)
|
||||
}
|
||||
|
||||
private fun loadVideo(url: String) {
|
||||
loadVideoTask?.cancel()
|
||||
videoDetails = null
|
||||
_lastVideoSource = null
|
||||
_lastAudioSource = null
|
||||
_lastSubtitleSource = null
|
||||
|
||||
setLoading(true)
|
||||
|
||||
Logger.i(TAG, "Shorts loadVideo [${url}]");
|
||||
val timeLoadVideoStart = System.currentTimeMillis();
|
||||
loadVideoTask = TaskHandler<String, IPlatformVideoDetails>(
|
||||
StateApp.instance.scopeGetter, {
|
||||
val result = StatePlatform.instance.getContentDetails(it).await()
|
||||
if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}")
|
||||
return@TaskHandler result
|
||||
}).success { result ->
|
||||
val timeLoadVideo = System.currentTimeMillis() - timeLoadVideoStart;
|
||||
Logger.i(TAG, "Shorts loadVideo [${url}] took ${timeLoadVideo}ms");
|
||||
videoDetails = result
|
||||
video = result
|
||||
|
||||
if(Settings.instance.playback.shortsPregenerate)
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if(result != null) {
|
||||
val prefVid = VideoHelper.selectBestVideoSource(result.video, Settings.instance.playback.getCurrentPreferredQualityPixelCount(), PREFERED_VIDEO_CONTAINERS);
|
||||
val prefAud = VideoHelper.selectBestAudioSource(result.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context));
|
||||
|
||||
if(prefVid != null && prefVid is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Shorts pregenerating video (${result.name})");
|
||||
prefVid.pregenerateAsync(fragment.lifecycleScope);
|
||||
}
|
||||
if(prefAud != null && prefAud is JSDashManifestRawAudioSource) {
|
||||
Logger.i(TAG, "Shorts pregenerating audio (${result.name})");
|
||||
prefAud.pregenerateAsync(fragment.lifecycleScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bottomSheet.video = result
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (playWhenReady) playVideo()
|
||||
}.exception<NoPlatformClientException> {
|
||||
Logger.w(TAG, "exception<NoPlatformClientException>", it)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ScriptLoginRequiredException> { e ->
|
||||
Logger.w(TAG, "exception<ScriptLoginRequiredException>", e)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", {
|
||||
val id = e.config.let { if (it is SourcePluginConfig) it.id else null }
|
||||
val didLogin =
|
||||
if (id == null) false else StatePlugins.instance.loginPlugin(context, id) {
|
||||
loadVideo(url)
|
||||
}
|
||||
if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login")
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ContentNotAvailableYetException> {
|
||||
Logger.w(TAG, "exception<ContentNotAvailableYetException>", it)
|
||||
UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { }
|
||||
}.exception<ScriptImplementationException> {
|
||||
Logger.w(TAG, "exception<ScriptImplementationException>", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, fragment)
|
||||
}.exception<ScriptAgeException> {
|
||||
Logger.w(TAG, "exception<ScriptAgeException>", it)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ScriptUnavailableException> {
|
||||
Logger.w(TAG, "exception<ScriptUnavailableException>", it)
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}.exception<ScriptException> {
|
||||
Logger.w(TAG, "exception<ScriptException>", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, fragment)
|
||||
}.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load video.", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, fragment)
|
||||
}
|
||||
|
||||
loadVideoTask?.run(url)
|
||||
}
|
||||
|
||||
private fun playVideo(resumePositionMs: Long = 0) {
|
||||
val videoDetails = this@ShortView.videoDetails
|
||||
|
||||
if (videoDetails === null) {
|
||||
playWhenReady = true
|
||||
return
|
||||
}
|
||||
|
||||
updateQualitySourcesOverlay(videoDetails, null)
|
||||
|
||||
try {
|
||||
val videoSource = _lastVideoSource
|
||||
?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount())
|
||||
val audioSource = _lastAudioSource
|
||||
?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context))
|
||||
val subtitleSource = _lastSubtitleSource
|
||||
?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null)
|
||||
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
|
||||
|
||||
if (videoSource == null && audioSource == null) {
|
||||
UIDialogs.showDialog(
|
||||
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
StatePlatform.instance.clearContentDetailCache(videoDetails.url)
|
||||
return
|
||||
}
|
||||
|
||||
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||
/*
|
||||
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
||||
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
player.setArtwork(resource.toDrawable(resources))
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
player.setArtwork(null)
|
||||
}
|
||||
})
|
||||
else player.setArtwork(null)
|
||||
*/
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0)
|
||||
if (subtitleSource != null) player.swapSubtitles(subtitleSource)
|
||||
player.seekTo(resumePositionMs)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "playVideo failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
_lastVideoSource = videoSource
|
||||
_lastAudioSource = audioSource
|
||||
_lastSubtitleSource = subtitleSource
|
||||
} catch (ex: UnsupportedCastException) {
|
||||
Logger.e(TAG, "Failed to load cast media", ex)
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex)
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to load media", ex)
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "VideoDetailView"
|
||||
}
|
||||
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.SoundEffectConstants
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@UnstableApi
|
||||
class ShortsFragment : MainFragment() {
|
||||
override val isMainView: Boolean = true
|
||||
override val isTab: Boolean = true
|
||||
override val hasBottomBar: Boolean get() = true
|
||||
|
||||
private var loadPagerTask: TaskHandler<ShortsFragment, IPager<IPlatformVideo>>? = null
|
||||
private var nextPageTask: TaskHandler<ShortsFragment, List<IPlatformVideo>>? = null
|
||||
|
||||
//TODO: Reduce number of pagers (1, or at most 2)
|
||||
private var mainShortsPager: IPager<IPlatformVideo>? = null
|
||||
private val mainShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||
|
||||
// the pager to call next on
|
||||
private var currentShortsPager: IPager<IPlatformVideo>? = null
|
||||
|
||||
// the shorts array bound to the ViewPager2 adapter
|
||||
private val currentShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||
|
||||
private var channelShortsPager: IPager<IPlatformVideo>? = null
|
||||
private val channelShorts: MutableList<IPlatformVideo> = mutableListOf()
|
||||
val isChannelShortsMode: Boolean
|
||||
get() = channelShortsPager != null
|
||||
|
||||
private var viewPager: ViewPager2? = null
|
||||
private lateinit var zeroState: LinearLayout
|
||||
private lateinit var sourcesButton: BigButton
|
||||
private lateinit var overlayLoading: FrameLayout
|
||||
private lateinit var overlayLoadingSpinner: ImageView
|
||||
private lateinit var overlayQualityContainer: FrameLayout
|
||||
private var customViewAdapter: CustomViewAdapter? = null
|
||||
|
||||
// we just completely reset the data structure so we want to tell the adapter that
|
||||
//TODO: Move most of this logic to ShortsView
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
(activity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
||||
super.onShownWithView(parameter, isBack)
|
||||
|
||||
if (parameter is Triple<*, *, *>) {
|
||||
setLoading(false)
|
||||
channelShorts.clear()
|
||||
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
|
||||
channelShorts.addAll(parameter.third as ArrayList<IPlatformVideo>)
|
||||
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
|
||||
channelShortsPager = parameter.second as IPager<IPlatformVideo>
|
||||
|
||||
currentShorts.clear()
|
||||
currentShorts.addAll(channelShorts)
|
||||
currentShortsPager = channelShortsPager
|
||||
|
||||
viewPager?.adapter?.notifyDataSetChanged()
|
||||
|
||||
viewPager?.post {
|
||||
viewPager?.currentItem = channelShorts.indexOfFirst {
|
||||
return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id
|
||||
}
|
||||
}
|
||||
} else if (isChannelShortsMode) {
|
||||
channelShortsPager = null
|
||||
channelShorts.clear()
|
||||
currentShorts.clear()
|
||||
|
||||
if (loadPagerTask == null) {
|
||||
currentShorts.addAll(mainShorts)
|
||||
currentShortsPager = mainShortsPager
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
viewPager?.adapter?.notifyDataSetChanged()
|
||||
viewPager?.currentItem = 0
|
||||
}
|
||||
|
||||
updateZeroState()
|
||||
}
|
||||
|
||||
override fun onCreateMainView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_shorts, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewPager = view.findViewById(R.id.view_pager)
|
||||
zeroState = view.findViewById(R.id.zero_state)
|
||||
sourcesButton = view.findViewById(R.id.sources_button)
|
||||
overlayLoading = view.findViewById(R.id.short_view_loading_overlay)
|
||||
overlayLoadingSpinner = view.findViewById(R.id.short_view_loader)
|
||||
overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview)
|
||||
|
||||
sourcesButton.onClick.subscribe {
|
||||
navigate<SourcesFragment>()
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
Logger.i(TAG, "Creating adapter")
|
||||
val customViewAdapter =
|
||||
CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) {
|
||||
if (!currentShortsPager!!.hasMorePages()) {
|
||||
return@CustomViewAdapter
|
||||
}
|
||||
nextPage()
|
||||
}
|
||||
customViewAdapter.onResetTriggered.subscribe {
|
||||
setLoading(true)
|
||||
loadPager()
|
||||
|
||||
loadPagerTask!!.success {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
val viewPager = viewPager!!
|
||||
viewPager.adapter = customViewAdapter
|
||||
|
||||
this.customViewAdapter = customViewAdapter
|
||||
|
||||
if (loadPagerTask == null) {// && currentShorts.isEmpty()) {
|
||||
loadPager()
|
||||
|
||||
loadPagerTask!!.success {
|
||||
setLoading(false)
|
||||
updateZeroState()
|
||||
}
|
||||
} else {
|
||||
setLoading(false)
|
||||
updateZeroState()
|
||||
}
|
||||
|
||||
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
fun play(adapter: CustomViewAdapter, position: Int) {
|
||||
val recycler = (viewPager.getChildAt(0) as RecyclerView)
|
||||
val viewHolder =
|
||||
recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder?
|
||||
|
||||
if (viewHolder == null) {
|
||||
adapter.needToPlay = position
|
||||
} else {
|
||||
val focusedView = viewHolder.shortView
|
||||
focusedView.play()
|
||||
adapter.previousShownView = focusedView
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
val adapter = (viewPager.adapter as CustomViewAdapter)
|
||||
if (adapter.previousShownView == null) {
|
||||
// play if this page selection didn't trigger by a swipe from another page
|
||||
play(adapter, position)
|
||||
} else {
|
||||
adapter.previousShownView?.stop()
|
||||
adapter.previousShownView = null
|
||||
adapter.newPosition = position
|
||||
}
|
||||
}
|
||||
|
||||
// wait for the state to idle to prevent UI lag
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
super.onPageScrollStateChanged(state)
|
||||
if (state == ViewPager2.SCROLL_STATE_IDLE) {
|
||||
val adapter = (viewPager.adapter as CustomViewAdapter)
|
||||
val position = adapter.newPosition ?: return
|
||||
adapter.newPosition = null
|
||||
|
||||
play(adapter, position)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateZeroState() {
|
||||
if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) {
|
||||
zeroState.visibility = View.VISIBLE
|
||||
} else {
|
||||
zeroState.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextPage() {
|
||||
Logger.i(TAG, "ShortsFragment nextPage");
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val time = measureTimeMillis {
|
||||
currentShortsPager!!.nextPage();
|
||||
}
|
||||
val newVideos = currentShortsPager!!.getResults();
|
||||
val prevCount = customViewAdapter!!.itemCount
|
||||
Logger.i(TAG, "Shorts nextPage took ${time}ms, ${prevCount}-${prevCount + newVideos.size}, hasMore: ${currentShortsPager?.hasMorePages()}");
|
||||
currentShorts.addAll(newVideos)
|
||||
if (isChannelShortsMode) {
|
||||
channelShorts.addAll(newVideos)
|
||||
} else {
|
||||
mainShorts.addAll(newVideos)
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
|
||||
}
|
||||
nextPageTask = null
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Shorts Failed to call nextPage", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we just completely reset the data structure so we want to tell the adapter that
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun loadPager() {
|
||||
loadPagerTask?.cancel()
|
||||
|
||||
Logger.i(TAG, "Shorts loadPage");
|
||||
var loadPageStart = System.currentTimeMillis();
|
||||
val loadPagerTask =
|
||||
TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, {
|
||||
val pager = StatePlatform.instance.getShorts();
|
||||
|
||||
return@TaskHandler pager
|
||||
}).success { pager ->
|
||||
val timeLoadPage = System.currentTimeMillis() - loadPageStart;
|
||||
Logger.i(TAG, "Shorts loadPage took ${timeLoadPage}ms");
|
||||
mainShorts.clear()
|
||||
mainShorts.addAll(pager.getResults())
|
||||
mainShortsPager = pager
|
||||
|
||||
if (!isChannelShortsMode) {
|
||||
currentShorts.clear()
|
||||
currentShorts.addAll(mainShorts)
|
||||
currentShortsPager = pager
|
||||
|
||||
// if the view pager exists go back to the beginning
|
||||
viewPager?.adapter?.notifyDataSetChanged()
|
||||
viewPager?.currentItem = 0
|
||||
}
|
||||
|
||||
loadPagerTask = null
|
||||
}.exception<Throwable> { err ->
|
||||
val message = "Unable to load shorts $err"
|
||||
Logger.w(TAG, message, err)
|
||||
if (context != null) {
|
||||
UIDialogs.showDialog(
|
||||
requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action(
|
||||
"Close", { }, UIDialogs.ActionStyle.PRIMARY
|
||||
)
|
||||
)
|
||||
}
|
||||
return@exception
|
||||
}
|
||||
|
||||
this.loadPagerTask = loadPagerTask
|
||||
|
||||
loadPagerTask.run(this)
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||
overlayLoading.visibility = View.VISIBLE
|
||||
} else {
|
||||
overlayLoading.visibility = View.GONE
|
||||
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
customViewAdapter?.previousShownView?.pause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
loadPagerTask?.cancel()
|
||||
loadPagerTask = null
|
||||
nextPageTask?.cancel()
|
||||
nextPageTask = null
|
||||
customViewAdapter?.previousShownView?.stop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ShortsFragment"
|
||||
|
||||
fun newInstance() = ShortsFragment()
|
||||
}
|
||||
|
||||
class CustomViewAdapter(
|
||||
private val videos: MutableList<IPlatformVideo>,
|
||||
private val inflater: LayoutInflater,
|
||||
private val fragment: MainFragment,
|
||||
private val overlayQualityContainer: FrameLayout,
|
||||
private val isChannelShortsMode: () -> Boolean,
|
||||
private val onNearEnd: () -> Unit,
|
||||
) : RecyclerView.Adapter<CustomViewHolder>() {
|
||||
val onResetTriggered = Event0()
|
||||
var previousShownView: ShortView? = null
|
||||
var newPosition: Int? = null
|
||||
var needToPlay: Int? = null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
|
||||
val shortView = ShortView(inflater, fragment, overlayQualityContainer)
|
||||
shortView.onResetTriggered.subscribe {
|
||||
onResetTriggered.emit()
|
||||
}
|
||||
return CustomViewHolder(shortView)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
|
||||
Logger.i(TAG, "Shorts change (position: ${position}): ${videos[position].name} (${videos[position].id.value})")
|
||||
holder.shortView.changeVideo(videos[position], isChannelShortsMode())
|
||||
|
||||
if (position == itemCount - 1) {
|
||||
onNearEnd()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: CustomViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
holder.shortView.cancel()
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(holder: CustomViewHolder) {
|
||||
super.onViewAttachedToWindow(holder)
|
||||
|
||||
if (holder.absoluteAdapterPosition == needToPlay) {
|
||||
holder.shortView.play()
|
||||
needToPlay = null
|
||||
previousShownView = holder.shortView
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = videos.size
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView)
|
||||
}
|
||||
+5
@@ -152,6 +152,11 @@ class SourceDetailFragment : MainFragment() {
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
if(!source.capabilities.hasGetUserHistory) {
|
||||
val field = _settingsAppForm.findField("sync");
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
_settingsAppForm.onChanged.clear();
|
||||
_settingsAppForm.onChanged.subscribe { field, value ->
|
||||
_settingsAppChanged = true;
|
||||
|
||||
+2
-2
@@ -25,10 +25,10 @@ data class SuggestionsFragmentData(val query: String, val searchType: SearchType
|
||||
|
||||
class SuggestionsFragment : MainFragment {
|
||||
override val isMainView : Boolean = true;
|
||||
override val hasBottomBar: Boolean = false;
|
||||
override val hasBottomBar: Boolean = true;
|
||||
override val isHistory: Boolean = false;
|
||||
|
||||
private var _recyclerSuggestions: RecyclerView? = null;
|
||||
private var _recyclerSuggestions: RecyclerView? = null;
|
||||
private var _llmSuggestions: LinearLayoutManager? = null;
|
||||
private var _radioGroupView: RadioGroupView? = null;
|
||||
private val _suggestions: ArrayList<String> = ArrayList();
|
||||
|
||||
+4
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.views.pills.WidePillButton
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@@ -152,6 +153,9 @@ class TutorialFragment : MainFragment() {
|
||||
override val viewCount: Long = -1
|
||||
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
|
||||
override val isShort: Boolean = false;
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
|
||||
return EmptyPager()
|
||||
}
|
||||
|
||||
+8
-10
@@ -337,13 +337,6 @@ class VideoDetailFragment() : MainFragment() {
|
||||
closeVideoDetails();
|
||||
};
|
||||
it.onMaximize.subscribe { maximizeVideoDetail(it) };
|
||||
it.onPlayChanged.subscribe {
|
||||
if(isInPictureInPicture) {
|
||||
val params = _viewDetail?.getPictureInPictureParams();
|
||||
if (params != null)
|
||||
activity?.setPictureInPictureParams(params);
|
||||
}
|
||||
};
|
||||
it.onEnterPictureInPicture.subscribe {
|
||||
Logger.i(TAG, "onEnterPictureInPicture")
|
||||
isInPictureInPicture = true;
|
||||
@@ -446,9 +439,14 @@ class VideoDetailFragment() : MainFragment() {
|
||||
val viewDetail = _viewDetail;
|
||||
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
|
||||
|
||||
if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
|
||||
_leavingPiP = false;
|
||||
if (viewDetail === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (viewDetail.shouldEnterPictureInPicture) {
|
||||
_leavingPiP = false
|
||||
}
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
|
||||
val params = _viewDetail?.getPictureInPictureParams();
|
||||
if(params != null) {
|
||||
Logger.i(TAG, "enterPictureInPictureMode")
|
||||
@@ -457,7 +455,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
viewDetail?.restoreBrightness()
|
||||
viewDetail.restoreBrightness()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+263
-69
@@ -4,6 +4,7 @@ import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
@@ -15,6 +16,7 @@ import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.text.Spanned
|
||||
import android.util.AttributeSet
|
||||
@@ -49,6 +51,7 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
@@ -79,7 +82,9 @@ 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.JSVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
@@ -244,7 +249,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _buttonPins: RoundButtonGroup;
|
||||
//private val _buttonMore: RoundButton;
|
||||
|
||||
var preventPictureInPicture: Boolean = false;
|
||||
var preventPictureInPicture: Boolean = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
}
|
||||
|
||||
private val _addCommentView: AddCommentView;
|
||||
private var _tabIndex: Int? = null;
|
||||
@@ -313,11 +324,24 @@ class VideoDetailView : ConstraintLayout {
|
||||
val onClose = Event0();
|
||||
val onFullscreenChanged = Event1<Boolean>();
|
||||
val onEnterPictureInPicture = Event0();
|
||||
val onPlayChanged = Event1<Boolean>();
|
||||
val onVideoChanged = Event2<Int, Int>()
|
||||
|
||||
var allowBackground : Boolean = false
|
||||
private set;
|
||||
var allowBackground: Boolean = false
|
||||
private set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
}
|
||||
|
||||
val shouldEnterPictureInPicture: Boolean
|
||||
get() = !preventPictureInPicture &&
|
||||
!StateCasting.instance.isCasting &&
|
||||
Settings.instance.playback.isBackgroundPictureInPicture() &&
|
||||
!allowBackground &&
|
||||
isPlaying
|
||||
|
||||
val onShouldEnterPictureInPictureChanged = Event0();
|
||||
|
||||
val onTouchCancel = Event0();
|
||||
private var _lastPositionSaveTime: Long = -1;
|
||||
@@ -430,6 +454,29 @@ class VideoDetailView : ConstraintLayout {
|
||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||
};
|
||||
|
||||
_container_content_liveChat.onUrlClick.subscribe { uri ->
|
||||
val c = context
|
||||
if (c is MainActivity) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
if (!c.handleUrl(uri.toString())) {
|
||||
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
context.startActivity(this)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to handle live chat URL")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
context.startActivity(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_monetization.onSupportTap.subscribe {
|
||||
_container_content_support.setPolycentricProfile(_polycentricProfile);
|
||||
switchContentView(_container_content_support);
|
||||
@@ -454,11 +501,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_player.attachPlayer();
|
||||
|
||||
_container_content_liveChat.onRaidNow.subscribe {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||
};
|
||||
|
||||
StateApp.instance.preventPictureInPicture.subscribe(this) {
|
||||
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
|
||||
preventPictureInPicture = true;
|
||||
@@ -619,8 +661,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
};
|
||||
|
||||
onShouldEnterPictureInPictureChanged.subscribe {
|
||||
val params = getPictureInPictureParams()
|
||||
fragment.activity?.setPictureInPictureParams(params)
|
||||
}
|
||||
|
||||
if (!isInEditMode) {
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState ->
|
||||
if (_onPauseCalled) {
|
||||
return@subscribe;
|
||||
}
|
||||
@@ -632,7 +679,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
setCastEnabled(true);
|
||||
}
|
||||
CastConnectionState.DISCONNECTED -> {
|
||||
loadCurrentVideo(lastPositionMilliseconds);
|
||||
loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying);
|
||||
updatePillButtonVisibilities();
|
||||
setCastEnabled(false);
|
||||
|
||||
@@ -716,7 +763,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
};
|
||||
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
||||
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
||||
_player.switchToAudioMode();
|
||||
_player.switchToAudioMode(video);
|
||||
allowBackground = true;
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
try {
|
||||
@@ -935,6 +982,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
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) {
|
||||
@@ -963,7 +1011,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
} else null,
|
||||
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
|
||||
if (!allowBackground) {
|
||||
_player.switchToAudioMode();
|
||||
_player.switchToAudioMode(video);
|
||||
allowBackground = true;
|
||||
it.text.text = resources.getString(R.string.background_revert);
|
||||
} else {
|
||||
@@ -1110,6 +1158,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
|
||||
if(!allowBackground) {
|
||||
_player.switchToVideoMode();
|
||||
allowBackground = false;
|
||||
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
|
||||
}
|
||||
}
|
||||
@@ -1133,12 +1182,18 @@ class VideoDetailView : ConstraintLayout {
|
||||
when (Settings.instance.playback.backgroundPlay) {
|
||||
0 -> handlePause();
|
||||
1 -> {
|
||||
if(!(video?.isLive ?: false))
|
||||
_player.switchToAudioMode();
|
||||
if(!(video?.isLive ?: false)) {
|
||||
_player.switchToAudioMode(video);
|
||||
allowBackground = true;
|
||||
}
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_player.isFullScreen) {
|
||||
restoreBrightness()
|
||||
}
|
||||
}
|
||||
fun onStop() {
|
||||
Logger.i(TAG, "onStop");
|
||||
@@ -1152,6 +1207,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_taskLoadVideo.cancel();
|
||||
handleStop();
|
||||
_didStop = true;
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
Logger.i(TAG, "_didStop set to true");
|
||||
|
||||
StatePlayer.instance.rotationLock = false;
|
||||
@@ -1740,12 +1796,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_liveChat?.stop();
|
||||
_liveChat = null;
|
||||
var gotLive = false;
|
||||
if (video.isLive && video.live != null) {
|
||||
loadLiveChat(video);
|
||||
gotLive = true;
|
||||
}
|
||||
if (video.isLive && video.live == null && !video.video.videoSources.any())
|
||||
if (video.isLive && video.live == null && !video.video.videoSources.any()) {
|
||||
startLiveTry(video);
|
||||
|
||||
gotLive = true;
|
||||
}
|
||||
if(!gotLive && video is JSVideoDetails && video.hasVODEvents()) {
|
||||
Logger.i(TAG, "Loading VOD chat");
|
||||
loadVODChat(video);
|
||||
}
|
||||
|
||||
_player.updateNextPrevious();
|
||||
updateMoreButtons();
|
||||
@@ -1769,6 +1832,43 @@ class VideoDetailView : ConstraintLayout {
|
||||
_taskLoadRecommendations.run(videoDetail.url)
|
||||
}
|
||||
}
|
||||
fun loadVODChat(video: IPlatformVideoDetails) {
|
||||
_liveChat?.stop();
|
||||
_container_content_liveChat.cancel();
|
||||
_liveChat = null;
|
||||
if(video !is JSVideoDetails)
|
||||
return;
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
var livePager: IPager<IPlatformLiveEvent>?;
|
||||
try {
|
||||
//TODO: Create video.getLiveEvents shortcut/optimalization
|
||||
livePager = video.getVODEvents(video.url);
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to obtain VODEvents pager", ex);
|
||||
livePager = null;
|
||||
}
|
||||
val liveChat = livePager?.let {
|
||||
val liveChatManager = LiveChatManager(fragment.lifecycleScope, livePager, video.viewCount);
|
||||
liveChatManager.start();
|
||||
return@let liveChatManager;
|
||||
}
|
||||
_liveChat = liveChat;
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
_container_content_liveChat.load(fragment.lifecycleScope, liveChat, null, if(liveChat != null) video.viewCount else null);
|
||||
switchContentView(_container_content_liveChat);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to switch content view to vod chat.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to load vod chat", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
fun loadLiveChat(video: IPlatformVideoDetails) {
|
||||
_liveChat?.stop();
|
||||
_container_content_liveChat.cancel();
|
||||
@@ -1843,7 +1943,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
//Source Loads
|
||||
private fun loadCurrentVideo(resumePositionMs: Long = 0) {
|
||||
private fun loadCurrentVideo(resumePositionMs: Long = 0, playWhenReady: Boolean = true) {
|
||||
_didStop = false;
|
||||
|
||||
val video = (videoLocal ?: video) ?: return;
|
||||
@@ -1864,26 +1964,52 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (!isCasting) {
|
||||
setCastEnabled(false);
|
||||
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if (videoSource == null && !thumbnail.isNullOrBlank())
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
_player.setArtwork(null);
|
||||
}
|
||||
});
|
||||
else
|
||||
_player.setArtwork(null);
|
||||
_player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0);
|
||||
if(subtitleSource != null)
|
||||
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
|
||||
_player.seekTo(resumePositionMs);
|
||||
val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let {
|
||||
if (it is JSClient)
|
||||
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
|
||||
else false;
|
||||
} ?: false;
|
||||
|
||||
if (isLimitedVersion && _player.isAudioMode) {
|
||||
_player.switchToVideoMode()
|
||||
allowBackground = false;
|
||||
} else {
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank())
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
_player.setArtwork(null);
|
||||
}
|
||||
});
|
||||
else
|
||||
_player.setArtwork(null);
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
_player.setSource(videoSource, audioSource, _playWhenReady && playWhenReady, false, resume = resumePositionMs > 0);
|
||||
if(subtitleSource != null)
|
||||
_player.swapSubtitles(subtitleSource);
|
||||
_player.seekTo(resumePositionMs);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "loadCurrentVideo failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble());
|
||||
else {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble());
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "loadCurrentVideo failed (casting)", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_lastVideoSource = videoSource;
|
||||
_lastAudioSource = audioSource;
|
||||
@@ -1898,19 +2024,46 @@ class VideoDetailView : ConstraintLayout {
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex);
|
||||
}
|
||||
}
|
||||
private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
||||
private suspend fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
||||
Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)")
|
||||
castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)
|
||||
}
|
||||
|
||||
val castSucceeded = StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
|
||||
_cast.setLoading(it)
|
||||
}, onLoadingEstimate = {
|
||||
_cast.setLoading(it)
|
||||
})
|
||||
private suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
||||
try {
|
||||
val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin()
|
||||
else if (audioSource is JSSource) audioSource.getUnderlyingPlugin()
|
||||
else null
|
||||
|
||||
if (castSucceeded) {
|
||||
_cast.setVideoDetails(video, resumePositionMs / 1000);
|
||||
setCastEnabled(true);
|
||||
} else throw IllegalStateException("Disconnected cast during loading");
|
||||
val startId = plugin?.getUnderlyingPlugin()?.runtimeId
|
||||
try {
|
||||
val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
|
||||
_cast.setLoading(it)
|
||||
}, onLoadingEstimate = {
|
||||
_cast.setLoading(it)
|
||||
})
|
||||
|
||||
if (castingSucceeded) {
|
||||
withContext(Dispatchers.Main) {
|
||||
_cast.setVideoDetails(video, resumePositionMs / 1000);
|
||||
setCastEnabled(true);
|
||||
}
|
||||
}
|
||||
} catch (e: ScriptReloadRequiredException) {
|
||||
Log.i(TAG, "Reload required exception", e)
|
||||
if (plugin == null)
|
||||
throw e
|
||||
|
||||
if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId)
|
||||
throw e
|
||||
|
||||
StatePlatform.instance.handleReloadRequired(e, {
|
||||
fetchVideo()
|
||||
});
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "loadCurrentVideoCast", e)
|
||||
}
|
||||
}
|
||||
|
||||
//Events
|
||||
@@ -1950,6 +2103,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height },
|
||||
audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate });
|
||||
}
|
||||
|
||||
_layoutPlayerContainer.post {
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
}
|
||||
|
||||
private var _didTriggerDatasourceErrorCount = 0;
|
||||
@@ -2410,7 +2567,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
isPlaying = playing;
|
||||
onPlayChanged.emit(playing);
|
||||
updateTracker(lastPositionMilliseconds, playing, true);
|
||||
}
|
||||
|
||||
@@ -2421,11 +2577,17 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(_lastVideoSource == videoSource)
|
||||
return;
|
||||
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||
StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
||||
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
|
||||
_player.hideControls(false); //TODO: Disable player?
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||
castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
||||
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
|
||||
_player.hideControls(false); //TODO: Disable player?
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "handleSelectVideoTrack failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
_lastVideoSource = videoSource;
|
||||
}
|
||||
@@ -2436,11 +2598,17 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(_lastAudioSource == audioSource)
|
||||
return;
|
||||
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||
StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
||||
else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
|
||||
_player.hideControls(false); //TODO: Disable player?
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||
castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed)
|
||||
else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
|
||||
_player.hideControls(false); //TODO: Disable player?
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "handleSelectAudioTrack failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
_lastAudioSource = audioSource;
|
||||
}
|
||||
@@ -2452,12 +2620,18 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(_lastSubtitleSource == subtitleSource)
|
||||
toSet = null;
|
||||
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||
StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
||||
else
|
||||
_player.swapSubtitles(fragment.lifecycleScope, toSet);
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
|
||||
castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
|
||||
else {
|
||||
_player.swapSubtitles(toSet);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
|
||||
}
|
||||
}
|
||||
_lastSubtitleSource = toSet;
|
||||
}
|
||||
|
||||
@@ -2544,6 +2718,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
setProgressBarOverlayed(false);
|
||||
}
|
||||
onFullscreenChanged.emit(fullscreen);
|
||||
_layoutPlayerContainer.post {
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCastEnabled(isCasting: Boolean) {
|
||||
@@ -2571,6 +2748,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (changed) {
|
||||
stopAllGestures();
|
||||
}
|
||||
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
|
||||
fun isLandscapeVideo(): Boolean? {
|
||||
@@ -2801,6 +2980,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_overlayContainer.removeAllViews();
|
||||
_overlay_quality_selector?.hide();
|
||||
_container_content.visibility = GONE
|
||||
|
||||
_player.fillHeight(false)
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
||||
@@ -2809,6 +2989,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
Logger.i(TAG, "handleLeavePictureInPicture")
|
||||
|
||||
if(!_player.isFullScreen) {
|
||||
_container_content.visibility = VISIBLE
|
||||
_player.fitHeight();
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
||||
} else {
|
||||
@@ -2824,29 +3005,40 @@ class VideoDetailView : ConstraintLayout {
|
||||
videoSourceHeight = 9;
|
||||
}
|
||||
val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight;
|
||||
val r = _player.getVideoRect()
|
||||
if(aspectRatio > 2.38) {
|
||||
videoSourceWidth = 16;
|
||||
videoSourceHeight = 9;
|
||||
|
||||
// shrink the left and right equally to get the rect to be 16 by 9 aspect ratio
|
||||
// we don't want a picture in picture mode that's more squashed than 16 by 9
|
||||
val targetWidth = r.height() * 16 / 9
|
||||
val shrinkAmount = (r.width() - targetWidth) / 2
|
||||
r.left += shrinkAmount
|
||||
r.right -= shrinkAmount
|
||||
}
|
||||
else if(aspectRatio < 0.43) {
|
||||
videoSourceHeight = 16;
|
||||
videoSourceWidth = 9;
|
||||
}
|
||||
|
||||
val r = Rect();
|
||||
_player.getGlobalVisibleRect(r);
|
||||
r.right = r.right - _player.paddingEnd;
|
||||
val playpauseAction = if(_player.playing)
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5));
|
||||
else
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
||||
|
||||
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
|
||||
return PictureInPictureParams.Builder()
|
||||
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
||||
.setSourceRectHint(r)
|
||||
.setActions(listOf(toBackgroundAction, playpauseAction))
|
||||
.build();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
params.setAutoEnterEnabled(shouldEnterPictureInPicture)
|
||||
}
|
||||
|
||||
return params.build()
|
||||
}
|
||||
|
||||
//Other
|
||||
@@ -2864,6 +3056,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun setLastPositionMilliseconds(positionMilliseconds: Long, updateHistory: Boolean) {
|
||||
lastPositionMilliseconds = positionMilliseconds;
|
||||
|
||||
_liveChat?.setVideoPosition(lastPositionMilliseconds);
|
||||
|
||||
val v = video ?: return;
|
||||
val currentTime = System.currentTimeMillis();
|
||||
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
|
||||
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.special
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.Spanned
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.FrameLayout.GONE
|
||||
import android.widget.FrameLayout.VISIBLE
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fixHtmlLinks
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.views.MonetizationView
|
||||
import com.futo.platformplayer.views.comments.AddCommentView
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||
import com.futo.platformplayer.views.overlays.SupportOverlay
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.segments.CommentsList
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.Models
|
||||
import com.futo.polycentric.core.PolycentricProfile
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
|
||||
|
||||
class CommentsModalBottomSheet : BottomSheetDialogFragment() {
|
||||
var mainFragment: MainFragment? = null
|
||||
|
||||
private lateinit var containerContent: FrameLayout
|
||||
private lateinit var containerContentMain: LinearLayout
|
||||
private lateinit var containerContentReplies: RepliesOverlay
|
||||
private lateinit var containerContentDescription: DescriptionOverlay
|
||||
private lateinit var containerContentSupport: SupportOverlay
|
||||
|
||||
private lateinit var title: TextView
|
||||
private lateinit var subTitle: TextView
|
||||
private lateinit var channelName: TextView
|
||||
private lateinit var channelMeta: TextView
|
||||
private lateinit var creatorThumbnail: CreatorThumbnail
|
||||
private lateinit var channelButton: LinearLayout
|
||||
private lateinit var monetization: MonetizationView
|
||||
private lateinit var platform: PlatformIndicator
|
||||
private lateinit var textLikes: TextView
|
||||
private lateinit var textDislikes: TextView
|
||||
private lateinit var layoutRating: LinearLayout
|
||||
private lateinit var imageDislikeIcon: ImageView
|
||||
private lateinit var imageLikeIcon: ImageView
|
||||
|
||||
private lateinit var description: TextView
|
||||
private lateinit var descriptionContainer: LinearLayout
|
||||
private lateinit var descriptionViewMore: TextView
|
||||
|
||||
private lateinit var commentsList: CommentsList
|
||||
private lateinit var addCommentView: AddCommentView
|
||||
|
||||
private var polycentricProfile: PolycentricProfile? = null
|
||||
|
||||
private lateinit var buttonPolycentric: Button
|
||||
private lateinit var buttonPlatform: Button
|
||||
|
||||
private var tabIndex: Int? = null
|
||||
|
||||
private var contentOverlayView: View? = null
|
||||
|
||||
lateinit var video: IPlatformVideoDetails
|
||||
|
||||
private lateinit var behavior: BottomSheetBehavior<FrameLayout>
|
||||
|
||||
private val _taskLoadPolycentricProfile =
|
||||
TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(
|
||||
ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(
|
||||
savedInstanceState: Bundle?,
|
||||
): Dialog {
|
||||
val bottomSheetDialog =
|
||||
BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme)
|
||||
bottomSheetDialog.setContentView(R.layout.modal_comments)
|
||||
|
||||
behavior = bottomSheetDialog.behavior
|
||||
|
||||
// TODO figure out how to not need all of these non null assertions
|
||||
containerContent = bottomSheetDialog.findViewById(R.id.content_container)!!
|
||||
containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!!
|
||||
containerContentReplies =
|
||||
bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!!
|
||||
containerContentDescription =
|
||||
bottomSheetDialog.findViewById(R.id.videodetail_container_description)!!
|
||||
containerContentSupport =
|
||||
bottomSheetDialog.findViewById(R.id.videodetail_container_support)!!
|
||||
|
||||
title = bottomSheetDialog.findViewById(R.id.videodetail_title)!!
|
||||
subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!!
|
||||
channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!!
|
||||
channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!!
|
||||
creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!!
|
||||
channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!!
|
||||
monetization = bottomSheetDialog.findViewById(R.id.monetization)!!
|
||||
platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!!
|
||||
layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!!
|
||||
textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!!
|
||||
textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!!
|
||||
imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!!
|
||||
imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!!
|
||||
|
||||
description = bottomSheetDialog.findViewById(R.id.videodetail_description)!!
|
||||
descriptionContainer =
|
||||
bottomSheetDialog.findViewById(R.id.videodetail_description_container)!!
|
||||
descriptionViewMore =
|
||||
bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!!
|
||||
|
||||
addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!!
|
||||
commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!!
|
||||
buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!!
|
||||
buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!!
|
||||
|
||||
commentsList.onAuthorClick.subscribe { c ->
|
||||
if (c !is PolycentricPlatformComment) {
|
||||
return@subscribe
|
||||
}
|
||||
val id = c.author.id.value
|
||||
|
||||
Logger.i(TAG, "onAuthorClick: $id")
|
||||
if (id != null && id.startsWith("polycentric://")) {
|
||||
val navUrl = "https://harbor.social/" + id.substring("polycentric://".length)
|
||||
mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri()))
|
||||
}
|
||||
}
|
||||
commentsList.onRepliesClick.subscribe { c ->
|
||||
val replyCount = c.replyCount ?: 0
|
||||
var metadata = ""
|
||||
if (replyCount > 0) {
|
||||
metadata += "$replyCount " + requireContext().getString(R.string.replies)
|
||||
}
|
||||
|
||||
if (c is PolycentricPlatformComment) {
|
||||
var parentComment: PolycentricPlatformComment = c
|
||||
containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, {
|
||||
val newComment = parentComment.cloneWithUpdatedReplyCount(
|
||||
(parentComment.replyCount ?: 0) + 1
|
||||
)
|
||||
commentsList.replaceComment(parentComment, newComment)
|
||||
parentComment = newComment
|
||||
})
|
||||
} else {
|
||||
containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) })
|
||||
}
|
||||
animateOpenOverlayView(containerContentReplies)
|
||||
}
|
||||
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
buttonPolycentric.setOnClickListener {
|
||||
setTabIndex(0)
|
||||
StateMeta.instance.setLastCommentSection(0)
|
||||
}
|
||||
} else {
|
||||
buttonPolycentric.visibility = GONE
|
||||
}
|
||||
|
||||
buttonPlatform.setOnClickListener {
|
||||
setTabIndex(1)
|
||||
StateMeta.instance.setLastCommentSection(1)
|
||||
}
|
||||
|
||||
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||
addCommentView.setContext(video.url, ref)
|
||||
|
||||
if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) {
|
||||
setTabIndex(2, true)
|
||||
} else {
|
||||
when (Settings.instance.comments.defaultCommentSection) {
|
||||
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true)
|
||||
1 -> setTabIndex(1, true)
|
||||
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
|
||||
}
|
||||
}
|
||||
|
||||
containerContentDescription.onClose.subscribe { animateCloseOverlayView() }
|
||||
containerContentReplies.onClose.subscribe { animateCloseOverlayView() }
|
||||
|
||||
descriptionViewMore.setOnClickListener {
|
||||
animateOpenOverlayView(containerContentDescription)
|
||||
}
|
||||
|
||||
updateDescriptionUI(video.description.fixHtmlLinks())
|
||||
|
||||
val dp5 =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics)
|
||||
val dp2 =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics)
|
||||
|
||||
//UI
|
||||
title.text = video.name
|
||||
channelName.text = video.author.name
|
||||
if (video.author.subscribers != null) {
|
||||
channelMeta.text = if ((video.author.subscribers
|
||||
?: 0) > 0
|
||||
) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else ""
|
||||
(channelName.layoutParams as MarginLayoutParams).setMargins(
|
||||
0, (dp5 * -1).toInt(), 0, 0
|
||||
)
|
||||
} else {
|
||||
channelMeta.text = ""
|
||||
(channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0)
|
||||
}
|
||||
|
||||
video.author.let {
|
||||
if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl)
|
||||
else monetization.setPlatformMembership(null, null)
|
||||
}
|
||||
|
||||
val subTitleSegments: ArrayList<String> = ArrayList()
|
||||
if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(
|
||||
R.string.watching_now) else requireContext().getString(R.string.views)}")
|
||||
if (video.datetime != null) {
|
||||
val diff = video.datetime?.getNowDiffSeconds() ?: 0
|
||||
val ago = video.datetime?.toHumanNowDiffString(true)
|
||||
if (diff >= 0) subTitleSegments.add("$ago ago")
|
||||
else subTitleSegments.add("available in $ago")
|
||||
}
|
||||
|
||||
platform.setPlatformFromClientID(video.id.pluginId)
|
||||
subTitle.text = subTitleSegments.joinToString(" • ")
|
||||
creatorThumbnail.setThumbnail(video.author.thumbnail, false)
|
||||
|
||||
setPolycentricProfile(null, animate = false)
|
||||
_taskLoadPolycentricProfile.run(video.author.id)
|
||||
|
||||
when (video.rating) {
|
||||
is RatingLikeDislikes -> {
|
||||
val r = video.rating as RatingLikeDislikes
|
||||
layoutRating.visibility = VISIBLE
|
||||
|
||||
textLikes.visibility = VISIBLE
|
||||
imageLikeIcon.visibility = VISIBLE
|
||||
textLikes.text = r.likes.toHumanNumber()
|
||||
|
||||
imageDislikeIcon.visibility = VISIBLE
|
||||
textDislikes.visibility = VISIBLE
|
||||
textDislikes.text = r.dislikes.toHumanNumber()
|
||||
}
|
||||
|
||||
is RatingLikes -> {
|
||||
val r = video.rating as RatingLikes
|
||||
layoutRating.visibility = VISIBLE
|
||||
|
||||
textLikes.visibility = VISIBLE
|
||||
imageLikeIcon.visibility = VISIBLE
|
||||
textLikes.text = r.likes.toHumanNumber()
|
||||
|
||||
imageDislikeIcon.visibility = GONE
|
||||
textDislikes.visibility = GONE
|
||||
}
|
||||
|
||||
else -> {
|
||||
layoutRating.visibility = GONE
|
||||
}
|
||||
}
|
||||
|
||||
monetization.onSupportTap.subscribe {
|
||||
containerContentSupport.setPolycentricProfile(polycentricProfile)
|
||||
animateOpenOverlayView(containerContentSupport)
|
||||
}
|
||||
|
||||
monetization.onStoreTap.subscribe {
|
||||
polycentricProfile?.systemState?.store?.let {
|
||||
try {
|
||||
val uri = it.toUri()
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = uri
|
||||
requireContext().startActivity(intent)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to open URI: '${it}'.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
monetization.onUrlTap.subscribe {
|
||||
mainFragment!!.navigate<BrowserFragment>(it)
|
||||
}
|
||||
|
||||
addCommentView.onCommentAdded.subscribe {
|
||||
commentsList.addComment(it)
|
||||
}
|
||||
|
||||
channelButton.setOnClickListener {
|
||||
mainFragment!!.navigate<ChannelFragment>(video.author)
|
||||
}
|
||||
|
||||
return bottomSheetDialog
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
animateCloseOverlayView()
|
||||
}
|
||||
|
||||
private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
|
||||
polycentricProfile = profile
|
||||
|
||||
val dp35 = 35.dp(requireContext().resources)
|
||||
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)
|
||||
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
|
||||
|
||||
if (avatar != null) {
|
||||
creatorThumbnail.setThumbnail(avatar, animate)
|
||||
} else {
|
||||
creatorThumbnail.setThumbnail(video.author.thumbnail, animate)
|
||||
creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto())
|
||||
}
|
||||
|
||||
val username = profile?.systemState?.username
|
||||
if (username != null) {
|
||||
channelName.text = username
|
||||
}
|
||||
|
||||
monetization.setPolycentricProfile(profile)
|
||||
}
|
||||
|
||||
private fun setTabIndex(index: Int?, forceReload: Boolean = false) {
|
||||
Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})")
|
||||
val changed = tabIndex != index || forceReload
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
|
||||
tabIndex = index
|
||||
buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null))
|
||||
buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null))
|
||||
|
||||
when (index) {
|
||||
null -> {
|
||||
addCommentView.visibility = GONE
|
||||
commentsList.clear()
|
||||
}
|
||||
|
||||
0 -> {
|
||||
addCommentView.visibility = VISIBLE
|
||||
fetchPolycentricComments()
|
||||
}
|
||||
|
||||
1 -> {
|
||||
addCommentView.visibility = GONE
|
||||
fetchComments()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchComments() {
|
||||
Logger.i(TAG, "fetchComments")
|
||||
video.let {
|
||||
commentsList.load(true) { StatePlatform.instance.getComments(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchPolycentricComments() {
|
||||
Logger.i(TAG, "fetchPolycentricComments")
|
||||
val video = video
|
||||
val idValue = video.id.value
|
||||
if (video.url.isEmpty()) {
|
||||
Logger.w(TAG, "Failed to fetch polycentric comments because url was null")
|
||||
commentsList.clear()
|
||||
return
|
||||
}
|
||||
|
||||
val ref = Models.referenceFromBuffer(video.url.toByteArray())
|
||||
val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||
commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); }
|
||||
}
|
||||
|
||||
private fun updateDescriptionUI(text: Spanned) {
|
||||
containerContentDescription.load(text)
|
||||
description.text = text
|
||||
|
||||
if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE
|
||||
else descriptionContainer.visibility = GONE
|
||||
}
|
||||
|
||||
private fun animateOpenOverlayView(view: View) {
|
||||
if (contentOverlayView != null) {
|
||||
Logger.e(TAG, "Content overlay already open")
|
||||
return
|
||||
}
|
||||
|
||||
behavior.isDraggable = false
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
|
||||
val animHeight = containerContentMain.height
|
||||
|
||||
view.translationY = animHeight.toFloat()
|
||||
view.visibility = VISIBLE
|
||||
|
||||
view.animate().setDuration(300).translationY(0f).withEndAction {
|
||||
contentOverlayView = view
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun animateCloseOverlayView() {
|
||||
val curView = contentOverlayView
|
||||
if (curView == null) {
|
||||
Logger.e(TAG, "No content overlay open")
|
||||
return
|
||||
}
|
||||
|
||||
behavior.isDraggable = true
|
||||
|
||||
val animHeight = contentOverlayView!!.height
|
||||
|
||||
curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction {
|
||||
curView.visibility = GONE
|
||||
contentOverlayView = null
|
||||
}.start()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "ModalBottomSheet"
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,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.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -91,7 +93,11 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
||||
try {
|
||||
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to start activity.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class DownloadService : Service() {
|
||||
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
|
||||
private var _notificationManager: NotificationManager? = null;
|
||||
private var _notificationChannel: NotificationChannel? = null;
|
||||
private var _isForeground = false
|
||||
|
||||
private val _client = ManagedHttpClient(OkHttpClient.Builder()
|
||||
//.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081)))
|
||||
@@ -66,6 +67,7 @@ class DownloadService : Service() {
|
||||
|
||||
if(!FragmentedStorage.isInitialized) {
|
||||
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
|
||||
stopSelf()
|
||||
closeDownloadSession();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
@@ -116,6 +118,22 @@ class DownloadService : Service() {
|
||||
override fun onCreate() {
|
||||
Logger.i(TAG, "onCreate");
|
||||
super.onCreate()
|
||||
|
||||
setupNotificationRequirements()
|
||||
|
||||
val bootstrapNotif = NotificationCompat.Builder(this, DOWNLOAD_NOTIF_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_download)
|
||||
.setContentTitle("Preparing downloads...")
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
else
|
||||
startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif)
|
||||
|
||||
_isForeground = true
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
@@ -246,15 +264,14 @@ class DownloadService : Service() {
|
||||
}
|
||||
|
||||
private fun notifyDownload(download: VideoDownload?) {
|
||||
val channel = _notificationChannel ?: return;
|
||||
|
||||
val channelId = DOWNLOAD_NOTIF_CHANNEL_ID
|
||||
val bringUpIntent = Intent(this, MainActivity::class.java);
|
||||
bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
|
||||
bringUpIntent.action = "TAB";
|
||||
bringUpIntent.putExtra("TAB", "Downloads");
|
||||
|
||||
var builder = if(download != null)
|
||||
NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG)
|
||||
val builder = if(download != null)
|
||||
NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ic_download)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
@@ -262,16 +279,16 @@ class DownloadService : Service() {
|
||||
.setContentTitle("${download.state}: ${download.name}")
|
||||
.setContentText(download.getDownloadInfo())
|
||||
.setProgress(100, (download.progress * 100).toInt(), download.progress == 0.0)
|
||||
.setChannelId(channel.id)
|
||||
.setChannelId(channelId)
|
||||
else
|
||||
NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG)
|
||||
NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ic_download)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE))
|
||||
.setContentTitle("Preparing for download...")
|
||||
.setContentText("Initializing download process...")
|
||||
.setChannelId(channel.id)
|
||||
.setChannelId(channelId)
|
||||
|
||||
val notif = builder.build();
|
||||
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
|
||||
|
||||
@@ -636,6 +636,20 @@ class StateApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val enabledPlugins = StatePlatform.instance.getEnabledClients();
|
||||
for(plugin in enabledPlugins) {
|
||||
try {
|
||||
if(plugin is JSClient) {
|
||||
if(plugin.descriptor.appSettings.sync.enableHistorySync == true)
|
||||
StateHistory.instance.syncRemoteHistory(plugin);
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to update remote history for ${plugin.name}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun mainAppStartedWithExternalFiles(context: Context) {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.states.StatePlaylists.Companion
|
||||
import com.futo.platformplayer.states.StateApp.Companion
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringDateMapStorage
|
||||
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||
@@ -19,7 +22,6 @@ import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentMap
|
||||
import kotlin.math.min
|
||||
|
||||
class StateHistory {
|
||||
//Legacy
|
||||
@@ -31,6 +33,8 @@ class StateHistory {
|
||||
})
|
||||
.load();
|
||||
|
||||
private val _remoteHistoryDatesStore = FragmentedStorage.get<StringDateMapStorage>("remoteHistoryDates");
|
||||
|
||||
private val historyIndex: ConcurrentMap<Any, DBHistory.Index> = ConcurrentHashMap();
|
||||
val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor())
|
||||
.withIndex({ it.url }, historyIndex, false, true)
|
||||
@@ -186,8 +190,95 @@ class StateHistory {
|
||||
val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 };
|
||||
for(item in toDelete)
|
||||
_historyDBStore.delete(item);
|
||||
_remoteHistoryDatesStore.map = HashMap<String, Long>();
|
||||
_remoteHistoryDatesStore.save();
|
||||
}
|
||||
|
||||
fun syncRemoteHistory(plugin: JSClient) {
|
||||
if (plugin.capabilities.hasGetUserHistory &&
|
||||
plugin.isLoggedIn) {
|
||||
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
|
||||
|
||||
val hist = StatePlatform.instance.getUserHistory(plugin.id);
|
||||
|
||||
syncRemoteHistory(plugin.id, hist, 100, 3);
|
||||
}
|
||||
}
|
||||
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int) {
|
||||
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
|
||||
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
|
||||
val maxPageCount = if(maxPages <= 0) 3 else maxPages;
|
||||
var exceededDate = false;
|
||||
try {
|
||||
val toSync = mutableListOf<IPlatformVideo>();
|
||||
var pageCount = 0;
|
||||
var videoCount = 0;
|
||||
var isFirst = true;
|
||||
var oldestPlayback = OffsetDateTime.MAX;
|
||||
var newestPlayback = OffsetDateTime.MIN;
|
||||
do {
|
||||
if (!isFirst) videos.nextPage();
|
||||
val newVideos = videos.getResults();
|
||||
|
||||
var foundVideos = false;
|
||||
var toSyncAddedCount = 0;
|
||||
for(video in newVideos) {
|
||||
if(video is IPlatformVideo && video.playbackDate != null) {
|
||||
|
||||
if(video.playbackDate!! < lastDate) {
|
||||
exceededDate = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if(video.playbackTime > 0) {
|
||||
toSync.add(video);
|
||||
toSyncAddedCount++;
|
||||
foundVideos = true;
|
||||
oldestPlayback = video.playbackDate!!;
|
||||
if(newestPlayback == OffsetDateTime.MIN)
|
||||
newestPlayback = video.playbackDate!!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pageCount++;
|
||||
videoCount += newVideos.size;
|
||||
isFirst = false;
|
||||
|
||||
if(!foundVideos)
|
||||
{
|
||||
Logger.i(TAG, "Found no more videos in remote history");
|
||||
break;
|
||||
}
|
||||
}
|
||||
while(videos.hasMorePages() && videoCount <= maxVideosCount && pageCount <= maxPageCount && !exceededDate);
|
||||
|
||||
var updated = 0;
|
||||
if(oldestPlayback < OffsetDateTime.MAX) {
|
||||
for(video in toSync){
|
||||
val hist = getHistoryByVideo(video, true, video.playbackDate);
|
||||
if(hist != null && hist.position < video.playbackTime) {
|
||||
Logger.i(TAG, "Updated history for video [${video.name}] from remote history");
|
||||
updateHistoryPosition(video, hist, true, video.playbackTime, video.playbackDate, false);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
if(updated > 0) {
|
||||
_remoteHistoryDatesStore.setAndSave(pluginId, newestPlayback);
|
||||
|
||||
try {
|
||||
val client = StatePlatform.instance.getClient(pluginId);
|
||||
UIDialogs.appToast("Updated ${updated} history from ${client.name}")
|
||||
}
|
||||
catch(ex: Throwable){}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null;
|
||||
Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "StateHistory";
|
||||
|
||||
@@ -395,8 +395,9 @@ class StatePlatform {
|
||||
}
|
||||
suspend fun selectClients(afterLoad: (() -> Unit)?, vararg ids: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
var removed: MutableList<IPlatformClient>;
|
||||
synchronized(_clientsLock) {
|
||||
val removed = _enabledClients.toMutableList();
|
||||
removed = _enabledClients.toMutableList();
|
||||
_enabledClients.clear();
|
||||
for (id in ids) {
|
||||
val client = getClient(id);
|
||||
@@ -412,11 +413,11 @@ class StatePlatform {
|
||||
}
|
||||
_enabledClientsPersistent.set(*ids);
|
||||
_enabledClientsPersistent.save();
|
||||
}
|
||||
|
||||
for (oldClient in removed) {
|
||||
oldClient.disable();
|
||||
onSourceDisabled.emit(oldClient);
|
||||
}
|
||||
for (oldClient in removed) {
|
||||
oldClient.disable();
|
||||
onSourceDisabled.emit(oldClient);
|
||||
}
|
||||
afterLoad?.invoke();
|
||||
};
|
||||
@@ -462,6 +463,47 @@ class StatePlatform {
|
||||
pager.initialize();
|
||||
return pager;
|
||||
}
|
||||
fun getShorts(): IPager<IPlatformVideo> {
|
||||
Logger.i(TAG, "Platform - getShorts");
|
||||
var clientIdsOngoing = mutableListOf<String>();
|
||||
val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true };
|
||||
|
||||
StateApp.instance.scopeOrNull?.let {
|
||||
it.launch(Dispatchers.Default) {
|
||||
try {
|
||||
// plugins that take longer than 5 seconds to load are considered "slow"
|
||||
delay(5000);
|
||||
val slowClients = synchronized(clientIdsOngoing) {
|
||||
return@synchronized clients.filter { clientIdsOngoing.contains(it.id) };
|
||||
};
|
||||
for(client in slowClients)
|
||||
UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show toast for slow source.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pages = clients.parallelStream()
|
||||
.map {
|
||||
Logger.i(TAG, "getShorts - ${it.name}")
|
||||
synchronized(clientIdsOngoing) {
|
||||
clientIdsOngoing.add(it.id);
|
||||
}
|
||||
val shortsResult = it.fromPool(_pagerClientPool).getShorts();
|
||||
synchronized(clientIdsOngoing) {
|
||||
clientIdsOngoing.remove(it.id);
|
||||
}
|
||||
return@map shortsResult;
|
||||
}
|
||||
.asSequence()
|
||||
.toList()
|
||||
.associateWith { 1f };
|
||||
|
||||
val pager = MultiDistributionContentPager(pages, 2);
|
||||
pager.initialize();
|
||||
return pager;
|
||||
}
|
||||
suspend fun getHomeRefresh(scope: CoroutineScope): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "Platform - getHome (Refresh)");
|
||||
val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true };
|
||||
@@ -994,6 +1036,16 @@ class StatePlatform {
|
||||
return client.getLiveChatWindow(url);
|
||||
}
|
||||
|
||||
//Account
|
||||
fun getUserHistory(id: String): IPager<IPlatformContent> {
|
||||
val client = getClient(id);
|
||||
if(client is JSClient && client.isLoggedIn) {
|
||||
return client.fromPool(_pagerClientPool).getUserHistory()
|
||||
}
|
||||
return EmptyPager<IPlatformContent>();
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun injectDevPlugin(source: SourcePluginConfig, script: String): String? {
|
||||
var devId: String? = null;
|
||||
|
||||
@@ -38,6 +38,7 @@ class StatePlayer {
|
||||
//Players
|
||||
private var _exoplayer : PlayerManager? = null;
|
||||
private var _thumbnailExoPlayer : PlayerManager? = null;
|
||||
private var _shortExoPlayer: PlayerManager? = null
|
||||
|
||||
//Video Status
|
||||
var rotationLock: Boolean = false
|
||||
@@ -633,6 +634,13 @@ class StatePlayer {
|
||||
}
|
||||
return _thumbnailExoPlayer!!;
|
||||
}
|
||||
fun getShortPlayerOrCreate(context: Context) : PlayerManager {
|
||||
if(_shortExoPlayer == null) {
|
||||
val player = createExoPlayer(context);
|
||||
_shortExoPlayer = PlayerManager(player);
|
||||
}
|
||||
return _shortExoPlayer!!;
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createExoPlayer(context : Context): ExoPlayer {
|
||||
@@ -656,10 +664,13 @@ class StatePlayer {
|
||||
fun dispose(){
|
||||
val player = _exoplayer;
|
||||
val thumbPlayer = _thumbnailExoPlayer;
|
||||
val shortPlayer = _shortExoPlayer
|
||||
_exoplayer = null;
|
||||
_thumbnailExoPlayer = null;
|
||||
_shortExoPlayer = null
|
||||
player?.release();
|
||||
thumbPlayer?.release();
|
||||
shortPlayer?.release()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,15 +34,18 @@ class PlayerManager {
|
||||
|
||||
@Synchronized
|
||||
fun attach(view: PlayerView, stateName: String) {
|
||||
if(view != _currentView) {
|
||||
_currentView?.player = null;
|
||||
switchState(stateName);
|
||||
view.player = player;
|
||||
_currentView = view;
|
||||
if (view != _currentView) {
|
||||
_currentView?.player = null
|
||||
_currentView = null
|
||||
switchState(stateName)
|
||||
view.player = player
|
||||
_currentView = view
|
||||
}
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
_currentView?.player = null;
|
||||
_currentView?.player = null
|
||||
_currentView = null
|
||||
}
|
||||
|
||||
fun getState(name: String): PlayerState {
|
||||
|
||||
@@ -9,8 +9,10 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
|
||||
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
|
||||
import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
|
||||
@@ -38,6 +40,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
||||
val onContentUrlClicked = Event2<String, ContentType>()
|
||||
val onUrlClicked = Event1<String>()
|
||||
val onContentClicked = Event2<IPlatformContent, Long>()
|
||||
val onShortClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>()
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>()
|
||||
val onAddToClicked = Event1<IPlatformContent>()
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>()
|
||||
@@ -81,7 +84,9 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
||||
when (_tabs[position]) {
|
||||
ChannelTab.VIDEOS -> {
|
||||
fragment = ChannelContentsFragment.newInstance().apply {
|
||||
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
|
||||
onContentClicked.subscribe { video, num, _ ->
|
||||
this@ChannelViewPagerAdapter.onContentClicked.emit(video, num)
|
||||
}
|
||||
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
|
||||
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
|
||||
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
|
||||
@@ -94,7 +99,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
||||
|
||||
ChannelTab.SHORTS -> {
|
||||
fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply {
|
||||
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
|
||||
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onShortClicked::emit)
|
||||
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
|
||||
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
|
||||
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ abstract class ContentPreviewViewHolder(itemView: View) : ViewHolder(itemView) {
|
||||
|
||||
abstract fun bind(content: IPlatformContent);
|
||||
|
||||
abstract fun preview(details: IPlatformContentDetails?, paused: Boolean);
|
||||
abstract suspend fun preview(details: IPlatformContentDetails?, paused: Boolean);
|
||||
abstract fun stopPreview();
|
||||
abstract fun pausePreview();
|
||||
abstract fun resumePreview();
|
||||
|
||||
@@ -11,7 +11,7 @@ class EmptyPreviewViewHolder(viewGroup: ViewGroup) : ContentPreviewViewHolder(Vi
|
||||
|
||||
override fun bind(content: IPlatformContent) {}
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {}
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {}
|
||||
|
||||
override fun stopPreview() {}
|
||||
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ class PreviewChannelViewHolder : ContentPreviewViewHolder {
|
||||
|
||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
||||
override fun stopPreview() = Unit;
|
||||
override fun pausePreview() = Unit;
|
||||
override fun resumePreview() = Unit;
|
||||
|
||||
+36
-11
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
@@ -15,6 +16,8 @@ import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.debug.Stopwatch
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortView
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
@@ -23,6 +26,9 @@ import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.internal.platform.Platform
|
||||
|
||||
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
|
||||
@@ -33,6 +39,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||
private val _feedStyle : FeedStyle;
|
||||
private var _paused: Boolean = false;
|
||||
private val _shouldShowTimeBar: Boolean
|
||||
private val _scope: CoroutineScope
|
||||
|
||||
val onUrlClicked = Event1<String>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
@@ -43,15 +50,9 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
|
||||
val onLongPress = Event1<IPlatformContent>();
|
||||
|
||||
private var _taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
|
||||
StateApp.instance.scopeGetter, { (viewHolder, video) ->
|
||||
val stopwatch = Stopwatch()
|
||||
val contentDetails = StatePlatform.instance.getContentDetails(video.url).await();
|
||||
stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)")
|
||||
return@TaskHandler Pair(viewHolder, contentDetails)
|
||||
}).exception<Throwable> { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { previewContentDetails(it.first, it.second) }
|
||||
private var _taskLoadContent: TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>
|
||||
|
||||
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
|
||||
constructor(scope: CoroutineScope, context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
|
||||
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
|
||||
viewsToAppend: ArrayList<View> = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) {
|
||||
|
||||
@@ -60,6 +61,24 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||
this._initialPlay = initialPlay;
|
||||
this._exoPlayer = exoPlayer;
|
||||
this._shouldShowTimeBar = shouldShowTimeBar
|
||||
this._scope = scope
|
||||
|
||||
_taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
|
||||
{ scope }, { (viewHolder, video) ->
|
||||
val stopwatch = Stopwatch()
|
||||
val contentDetails = StatePlatform.instance.getContentDetails(video.url).await();
|
||||
stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)")
|
||||
return@TaskHandler Pair(viewHolder, contentDetails)
|
||||
}).exception<Throwable> { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success {
|
||||
|
||||
_scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
previewContentDetails(it.first, it.second)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "bindChild preview failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChildCount(): Int = _dataSet.size;
|
||||
@@ -132,12 +151,18 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||
_initialPlay = false;
|
||||
|
||||
if (_feedStyle != FeedStyle.THUMBNAIL) {
|
||||
preview(holder);
|
||||
_scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
preview(holder)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "bindChild preview failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun preview(viewHolder: ContentPreviewViewHolder) {
|
||||
suspend fun preview(viewHolder: ContentPreviewViewHolder) {
|
||||
Log.v(TAG, "previewing content");
|
||||
if (viewHolder == _previewingViewHolder)
|
||||
return
|
||||
@@ -175,7 +200,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||
onAddToWatchLaterClicked.clear();
|
||||
}
|
||||
|
||||
private fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) {
|
||||
private suspend fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) {
|
||||
_previewingViewHolder?.stopPreview();
|
||||
viewHolder.preview(videoDetails, _paused);
|
||||
_previewingViewHolder = viewHolder;
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ class PreviewLockedViewHolder : ContentPreviewViewHolder {
|
||||
|
||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
||||
override fun stopPreview() { }
|
||||
override fun pausePreview() { }
|
||||
override fun resumePreview() { }
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ class PreviewNestedVideoView : PreviewVideoView {
|
||||
}
|
||||
}
|
||||
|
||||
override fun preview(video: IPlatformContentDetails?, paused: Boolean) {
|
||||
override suspend fun preview(video: IPlatformContentDetails?, paused: Boolean) {
|
||||
if(video != null) {
|
||||
super.preview(video, paused);
|
||||
} else if(_content is IPlatformVideoDetails) _contentNested?.let {
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
|
||||
view.bind(content);
|
||||
}
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {
|
||||
view.preview(details, paused);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class PreviewPlaceholderViewHolder : ContentPreviewViewHolder {
|
||||
}
|
||||
}
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
|
||||
override fun stopPreview() { }
|
||||
override fun pausePreview() { }
|
||||
override fun resumePreview() { }
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ class PreviewPlaylistViewHolder : ContentPreviewViewHolder {
|
||||
|
||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
||||
override fun stopPreview() = Unit;
|
||||
override fun pausePreview() = Unit;
|
||||
override fun resumePreview() = Unit;
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ class PreviewPostViewHolder : ContentPreviewViewHolder {
|
||||
|
||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {};
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {};
|
||||
override fun stopPreview() {};
|
||||
override fun pausePreview() {};
|
||||
override fun resumePreview() {};
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ open class PreviewVideoView : LinearLayout {
|
||||
_textVideoMetadata.text = metadata + timeMeta;
|
||||
}
|
||||
|
||||
open fun preview(video: IPlatformContentDetails?, paused: Boolean) {
|
||||
open suspend fun preview(video: IPlatformContentDetails?, paused: Boolean) {
|
||||
if(video == null)
|
||||
return;
|
||||
Logger.i(TAG, "Previewing");
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder {
|
||||
|
||||
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||
|
||||
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = view.preview(details, paused);
|
||||
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = view.preview(details, paused);
|
||||
override fun stopPreview() = view.stopPreview();
|
||||
override fun pausePreview() = view.pausePreview();
|
||||
override fun resumePreview() = view.resumePreview();
|
||||
|
||||
@@ -108,12 +108,20 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
||||
try {
|
||||
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to start activity.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
||||
try {
|
||||
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to start activity.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.futo.platformplayer.views.buttons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
|
||||
class ShortsButton : LinearLayout {
|
||||
private val _root: LinearLayout;
|
||||
private val _icon: ImageView;
|
||||
private val _textPrimary: TextView;
|
||||
val onClick = Event0();
|
||||
|
||||
var iconId: Int? = null;
|
||||
|
||||
constructor(context : Context, text: String, icon: Int, action: ()->Unit) : super(context) {
|
||||
inflate(context, R.layout.view_shorts_button, this);
|
||||
_icon = findViewById(R.id.button_icon);
|
||||
_textPrimary = findViewById(R.id.button_text);
|
||||
_root = findViewById(R.id.root);
|
||||
|
||||
withPrimaryText(text);
|
||||
withIcon(icon);
|
||||
|
||||
_root.apply {
|
||||
isClickable = true;
|
||||
setOnClickListener {
|
||||
if(!isEnabled)
|
||||
return@setOnClickListener;
|
||||
action();
|
||||
onClick.emit();
|
||||
UIDialogs.toast("Clicked button: " + _textPrimary.text);
|
||||
};
|
||||
}
|
||||
}
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.view_shorts_button, this);
|
||||
_icon = findViewById(R.id.image_icon);
|
||||
_textPrimary = findViewById(R.id.text_title);
|
||||
_root = findViewById(R.id.root);
|
||||
_root.apply {
|
||||
isClickable = true;
|
||||
setOnClickListener {
|
||||
if(!isEnabled)
|
||||
return@setOnClickListener;
|
||||
onClick.emit();
|
||||
};
|
||||
}
|
||||
|
||||
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.ShortsButton, 0, 0);
|
||||
val attrIconRef = attrArr.getResourceId(R.styleable.ShortsButton_buttonIcon_s, -1);
|
||||
val attrText = attrArr.getText(R.styleable.ShortsButton_buttonText_s) ?: "";
|
||||
attrArr.recycle()
|
||||
|
||||
withIcon(attrIconRef);
|
||||
withPrimaryText(attrText.toString());
|
||||
}
|
||||
|
||||
fun withMargin(bottom: Int, side: Int = 0): ShortsButton {
|
||||
setPadding(side, 0, side, bottom)
|
||||
return this;
|
||||
}
|
||||
fun withPrimaryText(text: String): ShortsButton {
|
||||
_textPrimary.text = text;
|
||||
|
||||
if(text.isNullOrBlank())
|
||||
_textPrimary.visibility = View.GONE;
|
||||
else
|
||||
_textPrimary.visibility = View.VISIBLE;
|
||||
return this;
|
||||
}
|
||||
|
||||
fun withIcon(resourceId: Int): ShortsButton {
|
||||
if (resourceId != -1) {
|
||||
_icon.visibility = View.VISIBLE;
|
||||
_icon.setImageResource(resourceId);
|
||||
} else
|
||||
_icon.visibility = View.GONE;
|
||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
||||
iconId = resourceId;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
fun withIcon(bitmap: Bitmap): ShortsButton {
|
||||
_icon.visibility = View.VISIBLE;
|
||||
_icon.setImageBitmap(bitmap);
|
||||
iconId = -1;
|
||||
|
||||
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
fun setButtonEnabled(enabled: Boolean) {
|
||||
if(enabled) {
|
||||
alpha = 1f;
|
||||
isEnabled = true;
|
||||
isClickable = true;
|
||||
}
|
||||
else {
|
||||
alpha = 0.5f;
|
||||
isEnabled = false;
|
||||
isClickable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.getDataLinkFromUrl
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.views.IdenticonView
|
||||
import userpackage.Protocol
|
||||
|
||||
@@ -82,14 +83,14 @@ class CreatorThumbnail : ConstraintLayout {
|
||||
Glide.with(_imageChannelThumbnail)
|
||||
.load(url)
|
||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.crossfade()
|
||||
.into(_imageChannelThumbnail);
|
||||
.into(_imageChannelThumbnail)
|
||||
} else {
|
||||
Glide.with(_imageChannelThumbnail)
|
||||
.load(url)
|
||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.into(_imageChannelThumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.futo.platformplayer.views.overlays
|
||||
|
||||
import android.animation.LayoutTransition
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.PointF
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.View
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
@@ -19,6 +22,7 @@ import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveEventChatMessage
|
||||
@@ -41,6 +45,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import toAndroidColor
|
||||
import androidx.core.net.toUri
|
||||
|
||||
|
||||
class LiveChatOverlay : LinearLayout {
|
||||
@@ -92,6 +97,7 @@ class LiveChatOverlay : LinearLayout {
|
||||
|
||||
val onRaidNow = Event1<LiveEventRaid>();
|
||||
val onRaidPrevent = Event1<LiveEventRaid>();
|
||||
val onUrlClick = Event1<Uri>()
|
||||
|
||||
private val _argJsonSerializer = Json;
|
||||
|
||||
@@ -116,6 +122,18 @@ class LiveChatOverlay : LinearLayout {
|
||||
view?.evaluateJavascript("setInterval(()=>{" + toRemoveJSInterval + "}, 1000)") {};
|
||||
};
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
onUrlClick.emit(request.url)
|
||||
return true
|
||||
}
|
||||
|
||||
// API < 24
|
||||
@Suppress("DEPRECATION")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
onUrlClick.emit(url.toUri())
|
||||
return true
|
||||
}
|
||||
};
|
||||
|
||||
_chatContainer = findViewById(R.id.chatContainer);
|
||||
@@ -202,6 +220,8 @@ class LiveChatOverlay : LinearLayout {
|
||||
|
||||
if(viewerCount != null)
|
||||
_textViewers.text = viewerCount.toHumanNumber() + " " + context.getString(R.string.viewers);
|
||||
else if(manager != null && manager.isVOD)
|
||||
_textViewers.text = manager.viewCount.toHumanNumber() + " past viewers";
|
||||
else if(manager != null)
|
||||
_textViewers.text = manager.viewCount.toHumanNumber() + " " + context.getString(R.string.viewers);
|
||||
else
|
||||
|
||||
@@ -19,7 +19,9 @@ class WebviewOverlay : LinearLayout {
|
||||
inflate(context, R.layout.overlay_webview, this)
|
||||
_topbar = findViewById(R.id.topbar);
|
||||
_webview = findViewById(R.id.webview);
|
||||
_webview.settings.javaScriptEnabled = true;
|
||||
if (!isInEditMode){
|
||||
_webview.settings.javaScriptEnabled = true;
|
||||
}
|
||||
|
||||
_topbar.onClose.subscribe(this, onClose::emit);
|
||||
}
|
||||
|
||||
@@ -68,15 +68,7 @@ class CommentsList : ConstraintLayout {
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadNextPage() });
|
||||
};
|
||||
|
||||
private val _scrollListener = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
onScrolled();
|
||||
|
||||
val totalScrollDistance = recyclerView.computeVerticalScrollOffset()
|
||||
_layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE
|
||||
}
|
||||
};
|
||||
private val _scrollListener: RecyclerView.OnScrollListener
|
||||
|
||||
private var _loader: (suspend () -> IPager<IPlatformComment>)? = null;
|
||||
private val _adapterComments: InsertedViewAdapterWithLoader<CommentViewHolder>;
|
||||
@@ -131,6 +123,14 @@ class CommentsList : ConstraintLayout {
|
||||
_llmReplies = LinearLayoutManager(context);
|
||||
_recyclerComments.layoutManager = _llmReplies;
|
||||
_recyclerComments.adapter = _adapterComments;
|
||||
_scrollListener = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
onScrolled();
|
||||
|
||||
_layoutScrollToTop.visibility = if (_llmReplies.findFirstCompletelyVisibleItemPosition() > 5) View.VISIBLE else View.GONE
|
||||
}
|
||||
};
|
||||
_recyclerComments.addOnScrollListener(_scrollListener);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.futo.platformplayer.views.video
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.animation.LinearInterpolator
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.TimeBar
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
|
||||
@UnstableApi
|
||||
class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
|
||||
FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FutoShortVideoPlayer"
|
||||
private const val PLAYER_STATE_NAME: String = "ShortPlayer"
|
||||
}
|
||||
|
||||
private var playerAttached = false
|
||||
private val videoView: PlayerView
|
||||
private val progressBar: DefaultTimeBar
|
||||
private lateinit var player: PlayerManager
|
||||
private var progressAnimator: ValueAnimator = createProgressBarAnimator()
|
||||
|
||||
val onPlaybackStateChanged = Event1<Int>();
|
||||
|
||||
private var playerEventListener = object : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
if (events.containsAny(
|
||||
Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED
|
||||
)
|
||||
) {
|
||||
progressAnimator.cancel()
|
||||
if (player.duration >= 0) {
|
||||
progressAnimator.duration = player.duration
|
||||
setProgressBarDuration(player.duration)
|
||||
progressAnimator.currentPlayTime = player.currentPosition
|
||||
}
|
||||
|
||||
if (player.isPlaying) {
|
||||
progressAnimator.start()
|
||||
}
|
||||
}
|
||||
|
||||
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
onPlaybackStateChanged.emit(player.playbackState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true)
|
||||
videoView = findViewById(R.id.short_player_view)
|
||||
progressBar = findViewById(R.id.short_player_progress_bar)
|
||||
|
||||
videoView.subtitleView?.setFixedTextSize(Dimension.SP, 18F);
|
||||
|
||||
if (!isInEditMode) {
|
||||
player = StatePlayer.instance.getShortPlayerOrCreate(context)
|
||||
player.player.repeatMode = Player.REPEAT_MODE_ONE
|
||||
}
|
||||
|
||||
progressBar.addListener(object : TimeBar.OnScrubListener {
|
||||
override fun onScrubStart(timeBar: TimeBar, position: Long) {
|
||||
progressAnimator.cancel()
|
||||
}
|
||||
|
||||
override fun onScrubMove(timeBar: TimeBar, position: Long) {}
|
||||
|
||||
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
|
||||
if (canceled) {
|
||||
progressAnimator.currentPlayTime = player.player.currentPosition
|
||||
progressAnimator.duration = player.player.duration
|
||||
progressAnimator.start()
|
||||
return
|
||||
}
|
||||
|
||||
// the progress bar should never be available to the user without the player being attached to this view
|
||||
assert(playerAttached)
|
||||
seekTo(position)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createProgressBarAnimator(): ValueAnimator {
|
||||
return ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
interpolator = LinearInterpolator()
|
||||
|
||||
addUpdateListener { animation ->
|
||||
progressBar.setPosition(animation.currentPlayTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgressBarDuration(duration: Long) {
|
||||
progressBar.setDuration(duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches this short player instance to the exo player instance for shorts
|
||||
*/
|
||||
fun attach() {
|
||||
// connect the exo player for shorts to the view for this instance
|
||||
player.attach(videoView, PLAYER_STATE_NAME)
|
||||
|
||||
// direct the base player what exo player instance to use
|
||||
changePlayer(player)
|
||||
|
||||
playerAttached = true
|
||||
|
||||
player.player.addListener(playerEventListener)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
playerAttached = false
|
||||
player.player.removeListener(playerEventListener)
|
||||
player.detach()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun setArtwork(drawable: Drawable?) {
|
||||
if (drawable != null) {
|
||||
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL
|
||||
videoView.defaultArtwork = drawable
|
||||
} else {
|
||||
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF
|
||||
videoView.defaultArtwork = null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPlaybackRate(): Float {
|
||||
return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f
|
||||
}
|
||||
|
||||
fun setPlaybackRate(playbackRate: Float) {
|
||||
val exoPlayer = exoPlayer?.player
|
||||
Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}")
|
||||
|
||||
val param = PlaybackParameters(playbackRate)
|
||||
exoPlayer?.playbackParameters = param
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
|
||||
_evMuteChanged.add(callback);
|
||||
}
|
||||
|
||||
fun setPreview(video: IPlatformVideoDetails) {
|
||||
suspend fun setPreview(video: IPlatformVideoDetails) {
|
||||
if (video.live != null) {
|
||||
setSource(video.live, null,true, false);
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,10 @@ import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
@@ -29,6 +32,9 @@ import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerControlView
|
||||
import androidx.media3.ui.PlayerView
|
||||
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.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -36,6 +42,7 @@ import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
@@ -491,6 +498,13 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
_control_autoplay_fullscreen.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
|
||||
}
|
||||
|
||||
fun getVideoRect(): Rect {
|
||||
val r = Rect()
|
||||
// this is the only way i could reliably get a reference to a view that matches perfectly with the video playback
|
||||
_videoView.subtitleView?.getGlobalVisibleRect(r)
|
||||
return r
|
||||
}
|
||||
|
||||
private fun setSystemBrightness(brightness: Float) {
|
||||
Log.i(TAG, "setSystemBrightness $brightness")
|
||||
if (android.provider.Settings.System.canWrite(context)) {
|
||||
@@ -890,4 +904,29 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader(expectedDurationMs.toLong())
|
||||
}
|
||||
|
||||
override fun switchToVideoMode() {
|
||||
super.switchToVideoMode()
|
||||
setArtwork(null)
|
||||
}
|
||||
|
||||
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
||||
super.switchToAudioMode(video)
|
||||
val thumbnail = video?.thumbnails?.getHQThumbnail()
|
||||
if (!thumbnail.isNullOrBlank()) {
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
.into(object : CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(
|
||||
resource: Bitmap,
|
||||
transition: Transition<in Bitmap>?
|
||||
) {
|
||||
setArtwork(BitmapDrawable(resources, resource));
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
setArtwork(null);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.futo.platformplayer.views.video
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -30,6 +34,10 @@ import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
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.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
@@ -86,8 +94,6 @@ import kotlin.math.abs
|
||||
abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
private val TAG = "FutoVideoPlayerBase"
|
||||
|
||||
private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory();
|
||||
|
||||
private var _mediaSource: MediaSource? = null;
|
||||
|
||||
var lastVideoSource: IVideoSource? = null
|
||||
@@ -267,7 +273,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
StateApp.instance.onConnectionAvailable.remove(_referenceObject);
|
||||
}
|
||||
|
||||
fun switchToVideoMode() {
|
||||
open fun switchToVideoMode() {
|
||||
Logger.i(TAG, "Switching to Video Mode");
|
||||
isAudioMode = false;
|
||||
val player = exoPlayer ?: return
|
||||
@@ -277,7 +283,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode)
|
||||
.build()
|
||||
}
|
||||
fun switchToAudioMode() {
|
||||
open fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
||||
Logger.i(TAG, "Switching to Audio Mode");
|
||||
isAudioMode = true;
|
||||
val player = exoPlayer ?: return
|
||||
@@ -359,48 +365,63 @@ 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, resume: Boolean = false) {
|
||||
suspend 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;
|
||||
var audioSourceUsed = audioSource;
|
||||
if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){
|
||||
videoSource.getUnderlyingPlugin()?.busy {
|
||||
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource);
|
||||
audioSourceUsed = null;
|
||||
suspend fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean {
|
||||
val didSet = withContext(Dispatchers.IO) {
|
||||
var videoSourceUsed = videoSource;
|
||||
var audioSourceUsed = audioSource;
|
||||
if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){
|
||||
videoSource.getUnderlyingPlugin()?.busy {
|
||||
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource);
|
||||
audioSourceUsed = null;
|
||||
}
|
||||
}
|
||||
|
||||
val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume);
|
||||
val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume);
|
||||
if(!keepSubtitles)
|
||||
_lastSubtitleMediaSource = null;
|
||||
|
||||
return@withContext didSetVideo && didSetAudio
|
||||
}
|
||||
|
||||
val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume);
|
||||
val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume);
|
||||
if(!keepSubtitles)
|
||||
_lastSubtitleMediaSource = null;
|
||||
if(didSetVideo && didSetAudio)
|
||||
return loadSelectedSources(play, resume);
|
||||
else
|
||||
return true;
|
||||
return withContext(Dispatchers.Main) {
|
||||
if (didSet)
|
||||
return@withContext loadSelectedSources(play, resume)
|
||||
else
|
||||
return@withContext true
|
||||
}
|
||||
}
|
||||
fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean {
|
||||
var videoSourceUsed = videoSource;
|
||||
if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource)
|
||||
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio);
|
||||
val didSet = swapSourceInternal(videoSourceUsed, play, resume);
|
||||
if(didSet)
|
||||
return loadSelectedSources(play, resume);
|
||||
else
|
||||
return true;
|
||||
suspend fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean {
|
||||
val didSet = withContext(Dispatchers.IO) {
|
||||
var videoSourceUsed = videoSource;
|
||||
if (videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource)
|
||||
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio);
|
||||
return@withContext swapSourceInternal(videoSourceUsed, play, resume);
|
||||
}
|
||||
return withContext(Dispatchers.Main) {
|
||||
if (didSet)
|
||||
return@withContext loadSelectedSources(play, resume);
|
||||
else
|
||||
return@withContext true;
|
||||
}
|
||||
}
|
||||
fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean {
|
||||
if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource)
|
||||
swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume);
|
||||
else
|
||||
swapSourceInternal(audioSource, play, resume);
|
||||
return loadSelectedSources(play, resume);
|
||||
suspend fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource)
|
||||
swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume);
|
||||
else
|
||||
swapSourceInternal(audioSource, play, resume);
|
||||
}
|
||||
return withContext(Dispatchers.Main) {
|
||||
return@withContext loadSelectedSources(play, resume);
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun swapSubtitles(scope: CoroutineScope, subtitles: ISubtitleSource?) {
|
||||
suspend fun swapSubtitles(subtitles: ISubtitleSource?) {
|
||||
if(subtitles == null)
|
||||
clearSubtitles();
|
||||
else {
|
||||
@@ -414,9 +435,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
C.TIME_UNSET);
|
||||
loadSelectedSources(true, true);
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val subUri = subtitles.getSubtitlesURI() ?: return@launch;
|
||||
val subUri = subtitles.getSubtitlesURI() ?: return@withContext;
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
_lastSubtitleMediaSource = SingleSampleMediaSource.Factory(DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<gradient
|
||||
android:centerX="50%"
|
||||
android:centerY="50%"
|
||||
android:endColor="#00FFFFFF"
|
||||
android:gradientRadius="35%p"
|
||||
android:startColor="#40FFFFFF"
|
||||
android:type="radial" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6.31516 18.2609C6.86932 17.8571 7.58672 17.754 8.23108 17.9859C9.36947 18.3983 10.6453 18.6346 12.0028 18.6346C17.3596 18.6346 20.938 15.1765 20.938 11.7613C20.938 8.3462 17.3596 4.88809 12.0028 4.88809C6.64594 4.88809 3.06755 8.3462 3.06755 11.7613C3.06755 13.136 3.60022 14.4591 4.60114 15.5932C4.97058 16.0099 5.151 16.5597 5.10805 17.1182C5.04791 17.8957 4.86319 18.6088 4.62262 19.2403C5.35291 18.9009 5.95861 18.5229 6.31516 18.2652V18.2609ZM1.91628 20.005C1.9936 19.889 2.06663 19.773 2.13536 19.657C2.56494 18.9439 2.97304 18.0074 3.05466 16.955C1.76592 15.4901 1.00557 13.6987 1.00557 11.7613C1.00557 6.82549 5.92854 2.82611 12.0028 2.82611C18.077 2.82611 23 6.82549 23 11.7613C23 16.6972 18.077 20.6966 12.0028 20.6966C10.409 20.6966 8.89693 20.4217 7.53087 19.9276C7.01967 20.3014 6.18629 20.8126 5.19826 21.2422C4.54959 21.5257 3.81072 21.7834 3.04607 21.9338C3.0117 21.9424 2.97734 21.9467 2.94297 21.9553C2.75396 21.9896 2.56924 22.0197 2.37593 22.0369C2.36733 22.0369 2.35445 22.0412 2.34586 22.0412C2.12677 22.0626 1.90769 22.0755 1.6886 22.0755C1.40937 22.0755 1.16022 21.908 1.05282 21.6503C0.945429 21.3925 1.00557 21.1004 1.19888 20.9028C1.37501 20.7224 1.53395 20.529 1.6843 20.3229C1.75733 20.224 1.82607 20.1252 1.8905 20.0264C1.8948 20.0179 1.89909 20.0136 1.90339 20.005H1.91628Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13.7562 3.3125C13.24 1.5625 10.76 1.5625 10.2437 3.3125L10.1187 3.7375C10.0416 3.99949 9.90682 4.2409 9.72424 4.44402C9.54166 4.64713 9.31594 4.80681 9.06362 4.91133C8.8113 5.01585 8.53878 5.06258 8.26606 5.04807C7.99333 5.03356 7.72731 4.9582 7.4875 4.8275L7.1 4.615C5.49625 3.7425 3.7425 5.49625 4.61625 7.09875L4.8275 7.4875C5.385 8.5125 4.85625 9.78875 3.7375 10.1187L3.3125 10.2437C1.5625 10.76 1.5625 13.24 3.3125 13.7562L3.7375 13.8813C3.99949 13.9584 4.2409 14.0932 4.44402 14.2758C4.64713 14.4583 4.80681 14.6841 4.91133 14.9364C5.01585 15.1887 5.06258 15.4612 5.04807 15.7339C5.03356 16.0067 4.9582 16.2727 4.8275 16.5125L4.615 16.9C3.7425 18.5037 5.49625 20.2575 7.09875 19.3837L7.4875 19.1725C7.72731 19.0418 7.99333 18.9664 8.26606 18.9519C8.53878 18.9374 8.8113 18.9841 9.06362 19.0887C9.31594 19.1932 9.54166 19.3529 9.72424 19.556C9.90682 19.7591 10.0416 20.0005 10.1187 20.2625L10.2437 20.6875C10.76 22.4375 13.24 22.4375 13.7562 20.6875L13.8813 20.2625C13.9584 20.0005 14.0932 19.7591 14.2758 19.556C14.4583 19.3529 14.6841 19.1932 14.9364 19.0887C15.1887 18.9841 15.4612 18.9374 15.7339 18.9519C16.0067 18.9664 16.2727 19.0418 16.5125 19.1725L16.9 19.385C18.5037 20.2575 20.2575 18.5037 19.3837 16.9012L19.1725 16.5125C19.0418 16.2727 18.9664 16.0067 18.9519 15.7339C18.9374 15.4612 18.9841 15.1887 19.0887 14.9364C19.1932 14.6841 19.3529 14.4583 19.556 14.2758C19.7591 14.0932 20.0005 13.9584 20.2625 13.8813L20.6875 13.7562C22.4375 13.24 22.4375 10.76 20.6875 10.2437L20.2625 10.1187C20.0005 10.0416 19.7591 9.90682 19.556 9.72424C19.3529 9.54166 19.1932 9.31594 19.0887 9.06362C18.9841 8.8113 18.9374 8.53878 18.9519 8.26606C18.9664 7.99333 19.0418 7.72731 19.1725 7.4875L19.385 7.1C20.2575 5.49625 18.5037 3.7425 16.9012 4.61625L16.5125 4.8275C16.2727 4.9582 16.0067 5.03356 15.7339 5.04807C15.4612 5.06258 15.1887 5.01585 14.9364 4.91133C14.6841 4.80681 14.4583 4.64713 14.2758 4.44402C14.0932 4.2409 13.9584 3.99949 13.8813 3.7375L13.7562 3.3125ZM12 15.6625C11.0286 15.6625 10.0971 15.2766 9.41022 14.5898C8.72337 13.9029 8.3375 12.9714 8.3375 12C8.3375 11.0286 8.72337 10.0971 9.41022 9.41022C10.0971 8.72337 11.0286 8.3375 12 8.3375C12.971 8.3375 13.9023 8.72324 14.5889 9.40985C15.2755 10.0965 15.6613 11.0277 15.6613 11.9987C15.6613 12.9698 15.2755 13.901 14.5889 14.5876C13.9023 15.2743 12.971 15.66 12 15.66V15.6625Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4.8338 12.6821H2C2 15.083 2.93455 17.5131 4.7735 19.2991C8.48155 22.9003 14.5109 22.9003 18.219 19.2991C21.927 15.6978 21.927 9.8421 18.219 6.24085C16.5307 4.60125 14.33 3.7229 12.0992 3.57651V1L4.8338 5.06971L12.0992 9.1687V6.32869C13.6065 6.4458 15.0536 7.08993 16.1991 8.20251C18.8219 10.7205 18.8219 14.8194 16.1991 17.3374C13.6065 19.8846 9.38596 19.8846 6.79334 17.3374C5.46688 16.0784 4.80365 14.351 4.8338 12.6821Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11.2046 1.70818C11.4156 1.50196 11.7017 1.38611 12 1.38611C12.2983 1.38611 12.5844 1.50196 12.7954 1.70818L16.1704 5.00821C16.3753 5.21568 16.4887 5.49354 16.4861 5.78196C16.4836 6.07038 16.3653 6.34628 16.1567 6.55023C15.9481 6.75418 15.6659 6.86987 15.371 6.87237C15.076 6.87488 14.7918 6.764 14.5796 6.56363L13.125 5.14131V15.686C13.125 15.9778 13.0065 16.2576 12.7955 16.4639C12.5845 16.6701 12.2984 16.786 12 16.786C11.7016 16.786 11.4155 16.6701 11.2045 16.4639C10.9935 16.2576 10.875 15.9778 10.875 15.686V5.14131L9.42037 6.56363C9.2082 6.764 8.92402 6.87488 8.62905 6.87237C8.33408 6.86987 8.05191 6.75418 7.84333 6.55023C7.63475 6.34628 7.51643 6.07038 7.51387 5.78196C7.5113 5.49354 7.6247 5.21568 7.82963 5.00821L11.2046 1.70818ZM3 11.286C3 10.7025 3.23705 10.1429 3.65901 9.73033C4.08097 9.31774 4.65326 9.08596 5.25 9.08596H7.5C7.79837 9.08596 8.08452 9.20185 8.2955 9.40814C8.50647 9.61443 8.625 9.89423 8.625 10.186C8.625 10.4777 8.50647 10.7575 8.2955 10.9638C8.08452 11.1701 7.79837 11.286 7.5 11.286H5.25V21.1861H18.75V11.286H16.5C16.2016 11.286 15.9155 11.1701 15.7045 10.9638C15.4935 10.7575 15.375 10.4777 15.375 10.186C15.375 9.89423 15.4935 9.61443 15.7045 9.40814C15.9155 9.20185 16.2016 9.08596 16.5 9.08596H18.75C19.3467 9.08596 19.919 9.31774 20.341 9.73033C20.7629 10.1429 21 10.7025 21 11.286V21.1861C21 21.7696 20.7629 22.3292 20.341 22.7417C19.919 23.1543 19.3467 23.3861 18.75 23.3861H5.25C4.65326 23.3861 4.08097 23.1543 3.65901 22.7417C3.23705 22.3292 3 21.7696 3 21.1861V11.286Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M9 14.3L5 14.3C4.68082 14.2887 4.36901 14.201 4.09064 14.0444C3.81228 13.8879 3.57546 13.6669 3.4 13.4C3.21079 13.1453 3.08491 12.8492 3.03274 12.5362C2.98058 12.2232 3.00363 11.9023 3.1 11.6L5.5 4.40002C5.8 3.50002 6 3.00002 7.4 3.00002C9.4 3.00002 11.6 3.70002 13.5 4.30002L15 4.70003L15 14.5C13.4053 16.1761 11.9971 18.0203 10.8 20C10.7 20.4 10.3 20.7 9.9 20.9L8.7 20.9C8.3 20.8 8 20.5 7.7 20.2L7.5 18.9L9 14.3ZM19.8 14L17 14L17 6.00002C17 5.46959 17.2107 4.96088 17.5858 4.58581C17.9609 4.21074 18.4696 4.00002 19 4.00002C19.5304 4.00002 20.0391 4.21074 20.4142 4.58581C20.7893 4.96088 21 5.46959 21 6.00002L21 12.8C21 13.5 20.5 14 19.8 14Z"
|
||||
android:strokeColor="@color/white"
|
||||
android:strokeWidth="1.5"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M8.99448 14.4L4.99448 14.4C4.6753 14.3886 4.36348 14.301 4.08512 14.1444C3.80675 13.9878 3.56993 13.7669 3.39448 13.5C3.20527 13.2453 3.07938 12.9492 3.02722 12.6362C2.97506 12.3232 2.99811 12.0023 3.09448 11.7L5.49448 4.5C5.79448 3.6 5.99448 3.1 7.39448 3.1C9.39448 3.1 11.5945 3.8 13.4945 4.4L14.9945 4.8L14.9945 14.6C13.3998 16.2761 11.9915 18.1202 10.7945 20.1C10.6945 20.5 10.2945 20.8 9.89448 21L8.69448 21C8.29448 20.9 7.99448 20.6 7.69448 20.3L7.49448 19L8.99448 14.4ZM19.7945 14.1L16.9945 14.1L16.9945 6.1C16.9945 5.56957 17.2052 5.06086 17.5803 4.68579C17.9553 4.31071 18.464 4.1 18.9945 4.1C19.5249 4.1 20.0336 4.31071 20.4087 4.68579C20.7838 5.06086 20.9945 5.56957 20.9945 6.1L20.9945 12.9C20.9945 13.6 20.4945 14.1 19.7945 14.1Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M15 9.69998H19C19.3192 9.71134 19.631 9.79898 19.9094 9.95556C20.1877 10.1121 20.4245 10.3331 20.6 10.6C20.7892 10.8547 20.9151 11.1508 20.9673 11.4638C21.0194 11.7768 20.9964 12.0977 20.9 12.4L18.5 19.6C18.2 20.5 18 21 16.6 21C14.6 21 12.4 20.3 10.5 19.7L9 19.3V9.49998C10.5947 7.82386 12.0029 5.97974 13.2 3.99998C13.3 3.59998 13.7 3.29998 14.1 3.09998H15.3C15.7 3.19998 16 3.49998 16.3 3.79998L16.5 5.09998L15 9.69998ZM4.2 9.99998H7V18C7 18.5304 6.78929 19.0391 6.41421 19.4142C6.03914 19.7893 5.53043 20 5 20C4.46957 20 3.96086 19.7893 3.58579 19.4142C3.21071 19.0391 3 18.5304 3 18V11.2C3 10.5 3.5 9.99998 4.2 9.99998Z"
|
||||
android:strokeColor="@color/white"
|
||||
android:strokeWidth="1.5"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15 9.69998H19C19.3192 9.71134 19.631 9.79898 19.9094 9.95556C20.1877 10.1121 20.4245 10.3331 20.6 10.6C20.7892 10.8547 20.9151 11.1508 20.9673 11.4638C21.0194 11.7768 20.9964 12.0977 20.9 12.4L18.5 19.6C18.2 20.5 18 21 16.6 21C14.6 21 12.4 20.3 10.5 19.7L9 19.3V9.49998C10.5947 7.82386 12.0029 5.97974 13.2 3.99998C13.3 3.59998 13.7 3.29998 14.1 3.09998H15.3C15.7 3.19998 16 3.49998 16.3 3.79998L16.5 5.09998L15 9.69998ZM4.2 9.99998H7V18C7 18.5304 6.78929 19.0391 6.41421 19.4142C6.03914 19.7893 5.53043 20 5 20C4.46957 20 3.96086 19.7893 3.58579 19.4142C3.21071 19.0391 3 18.5304 3 18V11.2C3 10.5 3.5 9.99998 4.2 9.99998Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M260,540L700,540L700,509.23L260,509.23L260,540ZM260,415.38L700,415.38L700,384.62L260,384.62L260,415.38ZM260,290.77L700,290.77L700,260L260,260L260,290.77ZM840,803.08L716.92,680L175.38,680Q152.33,680 136.16,663.84Q120,647.67 120,624.62L120,175.38Q120,152.33 136.16,136.16Q152.33,120 175.38,120L784.62,120Q807.67,120 823.84,136.16Q840,152.33 840,175.38L840,803.08ZM175.38,649.23L730.62,649.23L809.23,730.77L809.23,175.38Q809.23,166.15 801.54,158.46Q793.85,150.77 784.62,150.77L175.38,150.77Q166.15,150.77 158.46,158.46Q150.77,166.15 150.77,175.38L150.77,624.62Q150.77,633.85 158.46,641.54Q166.15,649.23 175.38,649.23ZM150.77,649.23Q150.77,649.23 150.77,641.54Q150.77,633.85 150.77,624.62L150.77,175.38Q150.77,166.15 150.77,158.46Q150.77,150.77 150.77,150.77L150.77,150.77Q150.77,150.77 150.77,158.46Q150.77,166.15 150.77,175.38L150.77,649.23Z"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user