mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-17 05:22:40 +02:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66f8711055 | |||
| b7c123c281 | |||
| 9481bbf3f1 | |||
| 43ec7e821b | |||
| ca3454afbe | |||
| 1edc8aabf8 | |||
| 91060faac9 | |||
| 17027ba364 | |||
| 8569eaa5db | |||
| d32d817e0a | |||
| a0f4cc760c | |||
| 5247997ea5 | |||
| 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 | |||
| 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'
|
||||
|
||||
@@ -1022,15 +1022,35 @@
|
||||
return x.value
|
||||
});
|
||||
|
||||
|
||||
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||
if (true) {
|
||||
for (let setting of this.Plugin?.currentPlugin?.settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(name == "enable") {
|
||||
if(parameterVals.length > 0)
|
||||
parameterVals[0] = this.Plugin.currentPlugin;
|
||||
else
|
||||
parameterVals.push(this.Plugin.currentPlugin);
|
||||
if(parameterVals.length > 1)
|
||||
parameterVals[1] = __DEV_SETTINGS;
|
||||
parameterVals[1] = settingsToUse;
|
||||
else
|
||||
parameterVals.push(__DEV_SETTINGS);
|
||||
parameterVals.push(settingsToUse);
|
||||
}
|
||||
|
||||
const func = source[name];
|
||||
|
||||
@@ -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,9 +75,26 @@ class LoginActivity : AppCompatActivity() {
|
||||
finish();
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||
var currentScale = 100;
|
||||
var currentDesktop = false;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(loginWarnings.size > 0 && url != null) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
@@ -86,6 +104,35 @@ class LoginActivity : AppCompatActivity() {
|
||||
//TODO: Find most reliable way to wait for page js to finish
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
|
||||
/*
|
||||
var specifiedScale = false;
|
||||
var specifiedDesktop = false;
|
||||
if(uiMods.size > 0 && url != null) {
|
||||
synchronized(uiMods) {
|
||||
val uimod = uiMods.find { url.matches(it.getRegex()) };
|
||||
if(uimod != null) {
|
||||
if(uimod.scale != null) {
|
||||
currentScale =(uimod.scale * 100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
specifiedScale = true;
|
||||
}
|
||||
if(uimod.desktop != null && uimod.desktop) {
|
||||
_webView.settings.useWideViewPort = true;
|
||||
specifiedDesktop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!specifiedScale && currentScale != 100) {
|
||||
currentScale = (100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
}
|
||||
if(!specifiedDesktop && currentDesktop) {
|
||||
_webView.settings.useWideViewPort = false;
|
||||
currentDesktop = false;
|
||||
}
|
||||
*/
|
||||
}
|
||||
_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();
|
||||
|
||||
+46
-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,44 @@ 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,
|
||||
val uiMods: List<UIMod>? = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
class UIMod(
|
||||
val url: String,
|
||||
val scale: Float?,
|
||||
val desktop: Boolean?
|
||||
) {
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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
|
||||
@@ -26,12 +27,15 @@ 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;
|
||||
@@ -47,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;
|
||||
@@ -72,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? {
|
||||
@@ -138,4 +142,15 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
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
|
||||
@@ -486,7 +487,7 @@ class StateCasting {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
@@ -751,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();
|
||||
@@ -1114,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,11 @@ class PackageBridge : V8Package {
|
||||
|
||||
val stackTrace = Thread.currentThread().stackTrace;
|
||||
val callerMethod = stackTrace.findLast {
|
||||
it.className == JSClient::class.java.name
|
||||
it.className == JSClient::class.java.name &&
|
||||
it.methodName != "isBusy" &&
|
||||
it.methodName != "busy" &&
|
||||
it.methodName != "getCopy" &&
|
||||
it.methodName != "isBusyWith"
|
||||
}?.methodName ?: "";
|
||||
val session = StateApp.instance.sessionId;
|
||||
val pluginId = _plugin.config.id;
|
||||
|
||||
+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)
|
||||
}
|
||||
+45
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
@@ -152,6 +153,50 @@ class SourceDetailFragment : MainFragment() {
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
if(!source.capabilities.hasGetUserHistory || !source.isLoggedIn) {
|
||||
val field = _settingsAppForm.findField("sync");
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
else {
|
||||
val field = _settingsAppForm.findField("syncHistory");
|
||||
field?.onChanged?.subscribe { field, new, old ->
|
||||
if(old != new && new == true && StatePlatform.instance.isClientEnabled(config.id)) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_sources, "Would you like to sync now?",
|
||||
"This will attempt to update your history from the platform, when this setting is enabled, it is done during startup.", null, 0,
|
||||
UIDialogs.Action("No", {
|
||||
|
||||
}),
|
||||
UIDialogs.Action("Yes", {
|
||||
UIDialogs.showDialogProgress(context, {
|
||||
it.setText("Importing history..");
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val client = StatePlatform.instance.getClient(config.id);
|
||||
if (client != null && client is JSClient) {
|
||||
val count = StateHistory.instance.syncRemoteHistory(client);
|
||||
withContext(Dispatchers.Main) {
|
||||
it.hide();
|
||||
if(count > 0)
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_pair_success, "Imported ${count} history items");
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_help, "Imported no history items");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.appToast("Sync History failed due to:\n" + ex.message);
|
||||
it.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
_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()
|
||||
}
|
||||
|
||||
+10
-12
@@ -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;
|
||||
@@ -444,11 +437,16 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
fun onUserLeaveHint() {
|
||||
val viewDetail = _viewDetail;
|
||||
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
|
||||
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.isAudioOnlyUserAction}");
|
||||
|
||||
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.isAudioOnlyUserAction) {
|
||||
val params = _viewDetail?.getPictureInPictureParams();
|
||||
if(params != null) {
|
||||
Logger.i(TAG, "enterPictureInPictureMode")
|
||||
@@ -457,7 +455,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
viewDetail?.restoreBrightness()
|
||||
viewDetail.restoreBrightness()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +526,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
private fun stopIfRequired() {
|
||||
var shouldStop = true;
|
||||
if (_viewDetail?.allowBackground == true) {
|
||||
if (_viewDetail?.isAudioOnlyUserAction == true) {
|
||||
shouldStop = false;
|
||||
} else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) {
|
||||
shouldStop = false;
|
||||
|
||||
+285
-103
@@ -10,12 +10,12 @@ import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Animatable
|
||||
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
|
||||
@@ -80,7 +80,6 @@ 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
|
||||
@@ -247,7 +246,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;
|
||||
@@ -316,11 +321,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 isAudioOnlyUserAction: Boolean = false
|
||||
private set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
}
|
||||
|
||||
val shouldEnterPictureInPicture: Boolean
|
||||
get() = !preventPictureInPicture &&
|
||||
!StateCasting.instance.isCasting &&
|
||||
Settings.instance.playback.isBackgroundPictureInPicture() &&
|
||||
!isAudioOnlyUserAction &&
|
||||
isPlaying
|
||||
|
||||
val onShouldEnterPictureInPictureChanged = Event0();
|
||||
|
||||
val onTouchCancel = Event0();
|
||||
private var _lastPositionSaveTime: Long = -1;
|
||||
@@ -433,6 +451,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);
|
||||
@@ -457,11 +498,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;
|
||||
@@ -622,8 +658,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;
|
||||
}
|
||||
@@ -635,7 +676,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
setCastEnabled(true);
|
||||
}
|
||||
CastConnectionState.DISCONNECTED -> {
|
||||
loadCurrentVideo(lastPositionMilliseconds);
|
||||
loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying);
|
||||
updatePillButtonVisibilities();
|
||||
setCastEnabled(false);
|
||||
|
||||
@@ -719,8 +760,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
};
|
||||
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
||||
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
||||
_player.switchToAudioMode();
|
||||
allowBackground = true;
|
||||
_player.switchToAudioMode(video);
|
||||
isAudioOnlyUserAction = true;
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
try {
|
||||
if (it is MainActivity) {
|
||||
@@ -938,6 +979,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,15 +1005,26 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
_slideUpOverlay?.hide();
|
||||
} else if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents())
|
||||
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.vod_chat), TAG_VODCHAT) {
|
||||
video?.let {
|
||||
try {
|
||||
loadVODChat(it);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to reopen vod chat", ex);
|
||||
}
|
||||
}
|
||||
_slideUpOverlay?.hide();
|
||||
} 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();
|
||||
allowBackground = true;
|
||||
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
|
||||
if (!isAudioOnlyUserAction) {
|
||||
_player.switchToAudioMode(video);
|
||||
isAudioOnlyUserAction = true;
|
||||
it.text.text = resources.getString(R.string.background_revert);
|
||||
} else {
|
||||
_player.switchToVideoMode();
|
||||
allowBackground = false;
|
||||
isAudioOnlyUserAction = false;
|
||||
it.text.text = resources.getString(R.string.background);
|
||||
}
|
||||
_slideUpOverlay?.hide();
|
||||
@@ -1087,10 +1140,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
|
||||
//Lifecycle
|
||||
var isLoginStop = false; //TODO: This is a bit jank, but easiest solution for now without reworking flow. (Alternatively, fix MainActivity getting stopped/disposing video)
|
||||
fun onResume() {
|
||||
Logger.v(TAG, "onResume");
|
||||
_onPauseCalled = false;
|
||||
|
||||
val wasLoginCall = isLoginStop;
|
||||
isLoginStop = false;
|
||||
|
||||
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
|
||||
Logger.i(TAG, "_didStop: $_didStop");
|
||||
|
||||
@@ -1099,7 +1156,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
val t = (lastPositionMilliseconds / 1000.0f).roundToLong();
|
||||
if(_searchVideo != null)
|
||||
setVideoOverview(_searchVideo!!, true, t);
|
||||
else if(_url != null)
|
||||
else if(_url != null && !wasLoginCall)
|
||||
setVideo(_url!!, t, _playWhenReady);
|
||||
}
|
||||
else if(_didStop) {
|
||||
@@ -1111,10 +1168,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
if(_player.isAudioMode) {
|
||||
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
|
||||
if(!allowBackground) {
|
||||
if(!isAudioOnlyUserAction) {
|
||||
_player.switchToVideoMode();
|
||||
isAudioOnlyUserAction = false;
|
||||
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
|
||||
}
|
||||
else {
|
||||
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.video);
|
||||
}
|
||||
}
|
||||
if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture)
|
||||
_player.fitHeight();
|
||||
@@ -1130,18 +1191,23 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(StateCasting.instance.isCasting)
|
||||
return;
|
||||
|
||||
if(allowBackground)
|
||||
if(isAudioOnlyUserAction)
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
else {
|
||||
when (Settings.instance.playback.backgroundPlay) {
|
||||
0 -> handlePause();
|
||||
1 -> {
|
||||
if(!(video?.isLive ?: false))
|
||||
_player.switchToAudioMode();
|
||||
if(!(video?.isLive ?: false)) {
|
||||
_player.switchToAudioMode(video);
|
||||
}
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_player.isFullScreen) {
|
||||
restoreBrightness()
|
||||
}
|
||||
}
|
||||
fun onStop() {
|
||||
Logger.i(TAG, "onStop");
|
||||
@@ -1155,6 +1221,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_taskLoadVideo.cancel();
|
||||
handleStop();
|
||||
_didStop = true;
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
Logger.i(TAG, "_didStop set to true");
|
||||
|
||||
StatePlayer.instance.rotationLock = false;
|
||||
@@ -1743,12 +1810,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();
|
||||
@@ -1772,6 +1846,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();
|
||||
@@ -1846,7 +1957,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;
|
||||
@@ -1867,26 +1978,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()
|
||||
isAudioOnlyUserAction = false;
|
||||
} else {
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||
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;
|
||||
@@ -1901,47 +2038,45 @@ 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)
|
||||
}
|
||||
|
||||
private fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
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
|
||||
|
||||
val startId = plugin?.getUnderlyingPlugin()?.runtimeId
|
||||
try {
|
||||
val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin()
|
||||
else if (audioSource is JSSource) audioSource.getUnderlyingPlugin()
|
||||
else null
|
||||
val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
|
||||
_cast.setLoading(it)
|
||||
}, onLoadingEstimate = {
|
||||
_cast.setLoading(it)
|
||||
})
|
||||
|
||||
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);
|
||||
}
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1982,6 +2117,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;
|
||||
@@ -2442,7 +2581,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
isPlaying = playing;
|
||||
onPlayChanged.emit(playing);
|
||||
updateTracker(lastPositionMilliseconds, playing, true);
|
||||
}
|
||||
|
||||
@@ -2453,11 +2591,17 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(_lastVideoSource == videoSource)
|
||||
return;
|
||||
|
||||
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?
|
||||
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;
|
||||
}
|
||||
@@ -2468,11 +2612,17 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(_lastAudioSource == audioSource)
|
||||
return;
|
||||
|
||||
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(!_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;
|
||||
}
|
||||
@@ -2484,12 +2634,18 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(_lastSubtitleSource == subtitleSource)
|
||||
toSet = null;
|
||||
|
||||
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(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;
|
||||
}
|
||||
|
||||
@@ -2576,6 +2732,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
setProgressBarOverlayed(false);
|
||||
}
|
||||
onFullscreenChanged.emit(fullscreen);
|
||||
_layoutPlayerContainer.post {
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCastEnabled(isCasting: Boolean) {
|
||||
@@ -2603,6 +2762,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (changed) {
|
||||
stopAllGestures();
|
||||
}
|
||||
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
|
||||
fun isLandscapeVideo(): Boolean? {
|
||||
@@ -2833,6 +2994,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_overlayContainer.removeAllViews();
|
||||
_overlay_quality_selector?.hide();
|
||||
_container_content.visibility = GONE
|
||||
|
||||
_player.fillHeight(false)
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
||||
@@ -2841,6 +3003,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 {
|
||||
@@ -2856,29 +3019,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
|
||||
@@ -2896,6 +3070,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)) {
|
||||
@@ -3103,8 +3279,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
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) {
|
||||
fetchVideo();
|
||||
else {
|
||||
isLoginStop = true;
|
||||
StatePlugins.instance.loginPlugin(context, id) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
fetchVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!didLogin)
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
|
||||
@@ -3282,6 +3463,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
const val TAG_SHARE = "share";
|
||||
const val TAG_OVERLAY = "overlay";
|
||||
const val TAG_LIVECHAT = "livechat";
|
||||
const val TAG_VODCHAT = "vodchat";
|
||||
const val TAG_CHAPTERS = "chapters";
|
||||
const val TAG_OPEN = "open";
|
||||
const val TAG_SEND_TO_DEVICE = "send_to_device";
|
||||
|
||||
+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,98 @@ 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): Int {
|
||||
if (plugin.capabilities.hasGetUserHistory &&
|
||||
plugin.isLoggedIn) {
|
||||
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
|
||||
|
||||
val hist = StatePlatform.instance.getUserHistory(plugin.id);
|
||||
|
||||
return syncRemoteHistory(plugin.id, hist, 100, 3);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int): 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){}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "StateHistory";
|
||||
|
||||
@@ -463,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 };
|
||||
@@ -995,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()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -179,8 +179,9 @@ class StatePlugins {
|
||||
}
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
StatePlatform.instance.reloadClient(context, id);
|
||||
afterLogin.invoke();
|
||||
StatePlatform.instance.reloadClient(context, id) {
|
||||
afterLogin.invoke();
|
||||
}
|
||||
}
|
||||
};
|
||||
return true;
|
||||
@@ -475,6 +476,7 @@ class StatePlugins {
|
||||
delay(500);
|
||||
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress.invoke("Validating script", 0.25);
|
||||
@@ -489,14 +491,14 @@ class StatePlugins {
|
||||
}
|
||||
|
||||
val icon = config.absoluteIconUrl?.let { absIconUrl ->
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress.invoke("Saving plugin", 0.75);
|
||||
}
|
||||
val iconResp = client.get(absIconUrl);
|
||||
if (iconResp.isOk)
|
||||
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
|
||||
return@let null;
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress.invoke("Saving plugin", 0.75);
|
||||
}
|
||||
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
|
||||
if (installEx != null)
|
||||
throw installEx;
|
||||
|
||||
@@ -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,33 @@ 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)
|
||||
|
||||
//This causes issues, and is in general confusing, needs improvements
|
||||
/*
|
||||
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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:strokeColor="#222"
|
||||
android:strokeWidth="20"
|
||||
android:pathData="M240,560L720,560L720,480L240,480L240,560ZM240,440L720,440L720,360L240,360L240,440ZM240,320L720,320L720,240L240,240L240,320ZM880,880L720,720L160,720Q127,720 103.5,696.5Q80,673 80,640L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,880ZM160,640L754,640L800,685L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640ZM160,640Q160,640 160,640Q160,640 160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Z"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user