Merge branch 'master' into ts/polycentric-moderation

This commit is contained in:
Trevor
2025-08-01 10:14:16 -05:00
109 changed files with 4448 additions and 438 deletions
+1
View File
@@ -156,6 +156,7 @@ android {
dependencies {
implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.7.2'
implementation 'com.google.android.material:material:1.12.0'
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
//Core
+4
View File
@@ -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;
}
@@ -785,6 +788,7 @@ let plugin = {
//To override by plugin
const source = {
getHome() { return new ContentPager([], false, {}); },
getShorts() { return new VideoPager([], false, {}); },
enable(config){ },
disable() {},
@@ -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,14 +129,23 @@ 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){
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(
items.addAll(
listOf(
SlideUpMenuItem(
container.context,
R.drawable.ic_notifications,
@@ -144,34 +153,61 @@ class UISlideOverlays {
"",
tag = "notifications",
call = {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
subscription.doNotifications =
menu?.selectOption(null, "notifications", true, true)
?: subscription.doNotifications;
},
invokeParent = false
),
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuGroup(container.context, "Subscription Groups",
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())
-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)) }
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;
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)
if (it is SubscriptionGroup.Selectable) {
val actualGroup =
StateSubscriptionGroups.instance.getSubscriptionGroup(
it.id
)
?: return@subscribe;
groups.clear();
if(it.selected)
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)) }
StateSubscriptionGroups.instance.updateSubscriptionGroup(
actualGroup
);
groups.addAll(
StateSubscriptionGroups.instance.getSubscriptionGroups()
.map {
SubscriptionGroup.Selectable(
it,
it.urls.contains(subscription.channel.url)
)
}
.sortedBy { !it.selected });
adapter?.notifyContentChanged();
}
@@ -179,32 +215,38 @@ class UISlideOverlays {
};
return@SlideUpMenuRecycler adapter;
} else null,
SlideUpMenuGroup(container.context, "Fetch Settings",
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(
-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;
subscription.doFetchLive =
menu?.selectOption(null, "fetchLive", true, true)
?: subscription.doFetchLive;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
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;
subscription.doFetchStreams =
menu?.selectOption(null, "fetchStreams", true, true)
?: subscription.doFetchStreams;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
@@ -212,10 +254,12 @@ class UISlideOverlays {
"Check for videos",
tag = "fetchVideos",
call = {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
subscription.doFetchVideos =
menu?.selectOption(null, "fetchVideos", true, true)
?: subscription.doFetchVideos;
},
invokeParent = false
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
@@ -223,18 +267,22 @@ class UISlideOverlays {
"Check for content",
tag = "fetchVideos",
call = {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
subscription.doFetchVideos =
menu?.selectOption(null, "fetchVideos", true, true)
?: subscription.doFetchVideos;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
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;
subscription.doFetchPosts =
menu?.selectOption(null, "fetchPosts", true, true)
?: subscription.doFetchPosts;
},
invokeParent = false
) else null/*,,
@@ -245,45 +293,63 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
showCreateSubscriptionGroup(container, subscription.channel);
}, false)*/
).filterNotNull());
).filterNotNull()
);
menu.setItems(items);
if(subscription.doNotifications)
if (subscription.doNotifications)
menu.selectOption(null, "notifications", true, true);
if(subscription.doFetchLive)
if (subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
if (subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos)
if (subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts)
if (subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
if(subscription.doNotifications && !originalNotif) {
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 (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,
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));
val intent = Intent(
mainContext,
SettingsActivity::class.java
);
intent.putExtra(
"query",
mainContext.getString(R.string.background_update)
);
mainContext.startActivity(intent);
}, UIDialogs.ActionStyle.PRIMARY));
}, 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) {
} 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");
}
}
@@ -301,6 +367,9 @@ class UISlideOverlays {
menu.show();
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show subscription overlay.", e)
}
}
return menu;
@@ -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,7 +45,23 @@ class LiveChatManager {
_scope = scope;
_pager = pager;
viewCount = initialViewCount;
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());
}
@@ -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,36 @@ 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;
val newEvents = if(_pager is JSVODEventPager) {
val requestPosition = _position;
_pager.nextPage(requestPosition.toInt());
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
//TODO: Remove this once dripfeed is done properly
replayResults = replayResults.filter{ it.time < requestPosition + 1500 || 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();
val newEvents = _pager.getResults();
_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,8 +6,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
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 +16,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 +31,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);
}
@@ -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();
@@ -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,
@@ -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,35 @@ 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)
var sync = Sync();
@Serializable
class Sync {
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1)
var enableHistorySync: Boolean? = null;
@FormField(R.string.sync_history, FieldForm.BUTTON, R.string.sync_history_desc, 2)
@FormFieldButton()
fun syncHistoryNow() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val clients = StatePlatform.instance.getEnabledClients();
for (client in clients) {
if (client is JSClient) {//) && client.descriptor.appSettings.sync.enableHistorySync == true) {
StateHistory.instance.syncRemoteHistory(client);
}
}
};
}
}
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
var rateLimit = RateLimit();
@Serializable
class RateLimit {
@@ -143,6 +172,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;
@@ -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);
}
}
@@ -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>()));
}
}
@@ -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;
@@ -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>) : super(pagers.keys.toMutableList()) {
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 (_autoLaunchEnabled) {
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) {
} 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) {
@@ -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);
}
}
@@ -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);
@@ -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)
@@ -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,6 +392,7 @@ 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) }),
@@ -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 =
@@ -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)
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,359 @@
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.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
@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
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
@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 {
sourcesButton.playSoundEffect(SoundEffectConstants.CLICK)
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() {
nextPageTask?.cancel()
val nextPageTask =
TaskHandler<ShortsFragment, List<IPlatformVideo>>(StateApp.instance.scopeGetter, {
currentShortsPager!!.nextPage()
return@TaskHandler currentShortsPager!!.getResults()
}).success { newVideos ->
val prevCount = customViewAdapter!!.itemCount
currentShorts.addAll(newVideos)
if (isChannelShortsMode) {
channelShorts.addAll(newVideos)
} else {
mainShorts.addAll(newVideos)
}
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
nextPageTask = null
}
nextPageTask.run(this)
this.nextPageTask = nextPageTask
}
// we just completely reset the data structure so we want to tell the adapter that
@SuppressLint("NotifyDataSetChanged")
private fun loadPager() {
loadPagerTask?.cancel()
val loadPagerTask =
TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, {
val pager = StatePlatform.instance.getShorts()
return@TaskHandler pager
}).success { pager ->
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.i(TAG, message)
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) {
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)
}
@@ -25,7 +25,7 @@ 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;
@@ -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()
}
@@ -337,13 +337,6 @@ class VideoDetailFragment() : MainFragment() {
closeVideoDetails();
};
it.onMaximize.subscribe { maximizeVideoDetail(it) };
it.onPlayChanged.subscribe {
if(isInPictureInPicture) {
val params = _viewDetail?.getPictureInPictureParams();
if (params != null)
activity?.setPictureInPictureParams(params);
}
};
it.onEnterPictureInPicture.subscribe {
Logger.i(TAG, "onEnterPictureInPicture")
isInPictureInPicture = true;
@@ -446,9 +439,14 @@ class VideoDetailFragment() : MainFragment() {
val viewDetail = _viewDetail;
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
_leavingPiP = false;
if (viewDetail === null) {
return
}
if (viewDetail.shouldEnterPictureInPicture) {
_leavingPiP = false
}
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode")
@@ -457,7 +455,7 @@ class VideoDetailFragment() : MainFragment() {
}
if (isFullscreen) {
viewDetail?.restoreBrightness()
viewDetail.restoreBrightness()
}
}
@@ -16,6 +16,7 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.support.v4.media.session.PlaybackStateCompat
import android.text.Spanned
import android.util.AttributeSet
@@ -50,6 +51,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID
@@ -247,7 +249,13 @@ class VideoDetailView : ConstraintLayout {
private val _buttonPins: RoundButtonGroup;
//private val _buttonMore: RoundButton;
var preventPictureInPicture: Boolean = false;
var preventPictureInPicture: Boolean = false
set(value) {
if (field != value) {
field = value
onShouldEnterPictureInPictureChanged.emit()
}
}
private val _addCommentView: AddCommentView;
private var _tabIndex: Int? = null;
@@ -316,11 +324,24 @@ class VideoDetailView : ConstraintLayout {
val onClose = Event0();
val onFullscreenChanged = Event1<Boolean>();
val onEnterPictureInPicture = Event0();
val onPlayChanged = Event1<Boolean>();
val onVideoChanged = Event2<Int, Int>()
var allowBackground : Boolean = false
private set;
var allowBackground: Boolean = false
private set(value) {
if (field != value) {
field = value
onShouldEnterPictureInPictureChanged.emit()
}
}
val shouldEnterPictureInPicture: Boolean
get() = !preventPictureInPicture &&
!StateCasting.instance.isCasting &&
Settings.instance.playback.isBackgroundPictureInPicture() &&
!allowBackground &&
isPlaying
val onShouldEnterPictureInPictureChanged = Event0();
val onTouchCancel = Event0();
private var _lastPositionSaveTime: Long = -1;
@@ -433,6 +454,29 @@ class VideoDetailView : ConstraintLayout {
fragment.navigate<VideoDetailFragment>(it.targetUrl);
};
_container_content_liveChat.onUrlClick.subscribe { uri ->
val c = context
if (c is MainActivity) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
if (!c.handleUrl(uri.toString())) {
Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
context.startActivity(this)
}
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to handle live chat URL")
}
}
} else {
Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
context.startActivity(this)
}
}
}
_monetization.onSupportTap.subscribe {
_container_content_support.setPolycentricProfile(_polycentricProfile);
switchContentView(_container_content_support);
@@ -457,11 +501,6 @@ class VideoDetailView : ConstraintLayout {
_player.attachPlayer();
_container_content_liveChat.onRaidNow.subscribe {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(it.targetUrl);
};
StateApp.instance.preventPictureInPicture.subscribe(this) {
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
preventPictureInPicture = true;
@@ -622,8 +661,13 @@ class VideoDetailView : ConstraintLayout {
}
};
onShouldEnterPictureInPictureChanged.subscribe {
val params = getPictureInPictureParams()
fragment.activity?.setPictureInPictureParams(params)
}
if (!isInEditMode) {
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState ->
if (_onPauseCalled) {
return@subscribe;
}
@@ -635,7 +679,7 @@ class VideoDetailView : ConstraintLayout {
setCastEnabled(true);
}
CastConnectionState.DISCONNECTED -> {
loadCurrentVideo(lastPositionMilliseconds);
loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying);
updatePillButtonVisibilities();
setCastEnabled(false);
@@ -719,7 +763,7 @@ class VideoDetailView : ConstraintLayout {
};
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
_player.switchToAudioMode();
_player.switchToAudioMode(video);
allowBackground = true;
StateApp.instance.contextOrNull?.let {
try {
@@ -938,6 +982,7 @@ class VideoDetailView : ConstraintLayout {
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
@@ -966,7 +1011,7 @@ class VideoDetailView : ConstraintLayout {
} else null,
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
if (!allowBackground) {
_player.switchToAudioMode();
_player.switchToAudioMode(video);
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
} else {
@@ -1113,6 +1158,7 @@ class VideoDetailView : ConstraintLayout {
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
if(!allowBackground) {
_player.switchToVideoMode();
allowBackground = false;
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
}
}
@@ -1136,12 +1182,18 @@ class VideoDetailView : ConstraintLayout {
when (Settings.instance.playback.backgroundPlay) {
0 -> handlePause();
1 -> {
if(!(video?.isLive ?: false))
_player.switchToAudioMode();
if(!(video?.isLive ?: false)) {
_player.switchToAudioMode(video);
allowBackground = true;
}
StatePlayer.instance.startOrUpdateMediaSession(context, video);
}
}
}
if (_player.isFullScreen) {
restoreBrightness()
}
}
fun onStop() {
Logger.i(TAG, "onStop");
@@ -1743,12 +1795,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 +1831,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 +1942,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,8 +1963,18 @@ class VideoDetailView : ConstraintLayout {
if (!isCasting) {
setCastEnabled(false);
val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let {
if (it is JSClient)
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
if (isLimitedVersion && _player.isAudioMode) {
_player.switchToVideoMode()
allowBackground = false;
} else {
val thumbnail = video.thumbnails.getHQThumbnail();
if (videoSource == null && !thumbnail.isNullOrBlank())
if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank())
Glide.with(context).asBitmap().load(thumbnail)
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@@ -1880,13 +1986,29 @@ class VideoDetailView : ConstraintLayout {
});
else
_player.setArtwork(null);
_player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0);
if(subtitleSource != null)
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
_player.seekTo(resumePositionMs);
}
else
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 {
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,13 +2023,12 @@ 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()
@@ -1943,7 +2064,6 @@ class VideoDetailView : ConstraintLayout {
Logger.e(TAG, "loadCurrentVideoCast", e)
}
}
}
//Events
@androidx.annotation.OptIn(UnstableApi::class)
@@ -1982,6 +2102,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 +2566,6 @@ class VideoDetailView : ConstraintLayout {
}
isPlaying = playing;
onPlayChanged.emit(playing);
updateTracker(lastPositionMilliseconds, playing, true);
}
@@ -2453,11 +2576,17 @@ class VideoDetailView : ConstraintLayout {
if(_lastVideoSource == videoSource)
return;
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 +2597,17 @@ class VideoDetailView : ConstraintLayout {
if(_lastAudioSource == audioSource)
return;
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(!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
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 +2619,18 @@ class VideoDetailView : ConstraintLayout {
if(_lastSubtitleSource == subtitleSource)
toSet = null;
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(fragment.lifecycleScope, toSet);
else {
_player.swapSubtitles(toSet);
}
} catch (e: Throwable) {
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
}
}
_lastSubtitleSource = toSet;
}
@@ -2576,6 +2717,9 @@ class VideoDetailView : ConstraintLayout {
setProgressBarOverlayed(false);
}
onFullscreenChanged.emit(fullscreen);
_layoutPlayerContainer.post {
onShouldEnterPictureInPictureChanged.emit()
}
}
private fun setCastEnabled(isCasting: Boolean) {
@@ -2603,6 +2747,8 @@ class VideoDetailView : ConstraintLayout {
if (changed) {
stopAllGestures();
}
onShouldEnterPictureInPictureChanged.emit()
}
fun isLandscapeVideo(): Boolean? {
@@ -2833,6 +2979,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 +2988,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 +3004,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 +3055,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)) {
@@ -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) {
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;
@@ -657,6 +657,20 @@ class StateApp {
}
}
}
scopeOrNull?.launch(Dispatchers.IO) {
val enabledPlugins = StatePlatform.instance.getEnabledClients();
for(plugin in enabledPlugins) {
try {
if(plugin is JSClient) {
if(plugin.descriptor.appSettings.sync.enableHistorySync == true)
StateHistory.instance.syncRemoteHistory(plugin);
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to update remote history for ${plugin.name}", ex);
}
}
}
}
fun mainAppStartedWithExternalFiles(context: Context) {
@@ -1,15 +1,18 @@
package com.futo.platformplayer.states
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StatePlaylists.Companion
import com.futo.platformplayer.states.StateApp.Companion
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.stores.v2.ReconstructStore
@@ -19,7 +22,6 @@ import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import kotlin.math.min
class StateHistory {
//Legacy
@@ -31,6 +33,8 @@ class StateHistory {
})
.load();
private val _remoteHistoryDatesStore = FragmentedStorage.get<StringDateMapStorage>("remoteHistoryDates");
private val historyIndex: ConcurrentMap<Any, DBHistory.Index> = ConcurrentHashMap();
val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor())
.withIndex({ it.url }, historyIndex, false, true)
@@ -186,8 +190,95 @@ class StateHistory {
val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 };
for(item in toDelete)
_historyDBStore.delete(item);
_remoteHistoryDatesStore.map = HashMap<String, Long>();
_remoteHistoryDatesStore.save();
}
fun syncRemoteHistory(plugin: JSClient) {
if (plugin.capabilities.hasGetUserHistory &&
plugin.isLoggedIn) {
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
val hist = StatePlatform.instance.getUserHistory(plugin.id);
syncRemoteHistory(plugin.id, hist, 100, 3);
}
}
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int) {
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
val maxPageCount = if(maxPages <= 0) 3 else maxPages;
var exceededDate = false;
try {
val toSync = mutableListOf<IPlatformVideo>();
var pageCount = 0;
var videoCount = 0;
var isFirst = true;
var oldestPlayback = OffsetDateTime.MAX;
var newestPlayback = OffsetDateTime.MIN;
do {
if (!isFirst) videos.nextPage();
val newVideos = videos.getResults();
var foundVideos = false;
var toSyncAddedCount = 0;
for(video in newVideos) {
if(video is IPlatformVideo && video.playbackDate != null) {
if(video.playbackDate!! < lastDate) {
exceededDate = true;
break;
}
if(video.playbackTime > 0) {
toSync.add(video);
toSyncAddedCount++;
foundVideos = true;
oldestPlayback = video.playbackDate!!;
if(newestPlayback == OffsetDateTime.MIN)
newestPlayback = video.playbackDate!!;
}
}
}
pageCount++;
videoCount += newVideos.size;
isFirst = false;
if(!foundVideos)
{
Logger.i(TAG, "Found no more videos in remote history");
break;
}
}
while(videos.hasMorePages() && videoCount <= maxVideosCount && pageCount <= maxPageCount && !exceededDate);
var updated = 0;
if(oldestPlayback < OffsetDateTime.MAX) {
for(video in toSync){
val hist = getHistoryByVideo(video, true, video.playbackDate);
if(hist != null && hist.position < video.playbackTime) {
Logger.i(TAG, "Updated history for video [${video.name}] from remote history");
updateHistoryPosition(video, hist, true, video.playbackTime, video.playbackDate, false);
updated++;
}
}
if(updated > 0) {
_remoteHistoryDatesStore.setAndSave(pluginId, newestPlayback);
try {
val client = StatePlatform.instance.getClient(pluginId);
UIDialogs.appToast("Updated ${updated} history from ${client.name}")
}
catch(ex: Throwable){}
}
}
}
catch(ex: Throwable) {
val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null;
Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message)
}
}
companion object {
val TAG = "StateHistory";
@@ -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);
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()
}
@@ -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)
@@ -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() {}
@@ -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;
@@ -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;
@@ -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() { }
@@ -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 {
@@ -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);
}
@@ -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() { }
@@ -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;
@@ -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() {};
@@ -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");
@@ -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) {
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) {
try {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} catch (e: Throwable) {
Logger.i(TAG, "Failed to start activity.", e)
}
}
}
}
@@ -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);
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,153 @@
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.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)
if (!isInEditMode) {
player = StatePlayer.instance.getShortPlayerOrCreate(context)
player.player.repeatMode = Player.REPEAT_MODE_ONE
}
progressBar.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) {
progressAnimator.cancel()
}
override fun onScrubMove(timeBar: TimeBar, position: Long) {}
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
if (canceled) {
progressAnimator.currentPlayTime = player.player.currentPosition
progressAnimator.duration = player.player.duration
progressAnimator.start()
return
}
// the progress bar should never be available to the user without the player being attached to this view
assert(playerAttached)
seekTo(position)
}
})
}
@OptIn(UnstableApi::class)
private fun createProgressBarAnimator(): ValueAnimator {
return ValueAnimator.ofFloat(0f, 1f).apply {
interpolator = LinearInterpolator()
addUpdateListener { animation ->
progressBar.setPosition(animation.currentPlayTime)
}
}
}
fun setProgressBarDuration(duration: Long) {
progressBar.setDuration(duration)
}
/**
* Attaches this short player instance to the exo player instance for shorts
*/
fun attach() {
// connect the exo player for shorts to the view for this instance
player.attach(videoView, PLAYER_STATE_NAME)
// direct the base player what exo player instance to use
changePlayer(player)
playerAttached = true
player.player.addListener(playerEventListener)
}
fun detach() {
playerAttached = false
player.player.removeListener(playerEventListener)
player.detach()
}
@OptIn(UnstableApi::class)
fun setArtwork(drawable: Drawable?) {
if (drawable != null) {
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL
videoView.defaultArtwork = drawable
} else {
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF
videoView.defaultArtwork = null
}
}
fun getPlaybackRate(): Float {
return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f
}
fun setPlaybackRate(playbackRate: Float) {
val exoPlayer = exoPlayer?.player
Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}")
val param = PlaybackParameters(playbackRate)
exoPlayer?.playbackParameters = param
}
}
@@ -126,7 +126,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
_evMuteChanged.add(callback);
}
fun setPreview(video: IPlatformVideoDetails) {
suspend fun setPreview(video: IPlatformVideoDetails) {
if (video.live != null) {
setSource(video.live, null,true, false);
} else {
@@ -4,7 +4,10 @@ import android.animation.ValueAnimator
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.media.AudioManager
import android.net.Uri
@@ -29,6 +32,9 @@ import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -36,6 +42,7 @@ import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@@ -491,6 +498,13 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_control_autoplay_fullscreen.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
}
fun getVideoRect(): Rect {
val r = Rect()
// this is the only way i could reliably get a reference to a view that matches perfectly with the video playback
_videoView.subtitleView?.getGlobalVisibleRect(r)
return r
}
private fun setSystemBrightness(brightness: Float) {
Log.i(TAG, "setSystemBrightness $brightness")
if (android.provider.Settings.System.canWrite(context)) {
@@ -890,4 +904,29 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong())
}
override fun switchToVideoMode() {
super.switchToVideoMode()
setArtwork(null)
}
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
super.switchToAudioMode(video)
val thumbnail = video?.thumbnails?.getHQThumbnail()
if (!thumbnail.isNullOrBlank()) {
Glide.with(context).asBitmap().load(thumbnail)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
setArtwork(BitmapDrawable(resources, resource));
}
override fun onLoadCleared(placeholder: Drawable?) {
setArtwork(null);
}
})
}
}
}
@@ -1,11 +1,15 @@
package com.futo.platformplayer.views.video
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.RelativeLayout
import androidx.annotation.OptIn
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -30,6 +34,10 @@ import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -86,8 +94,6 @@ import kotlin.math.abs
abstract class FutoVideoPlayerBase : RelativeLayout {
private val TAG = "FutoVideoPlayerBase"
private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory();
private var _mediaSource: MediaSource? = null;
var lastVideoSource: IVideoSource? = null
@@ -267,7 +273,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
StateApp.instance.onConnectionAvailable.remove(_referenceObject);
}
fun switchToVideoMode() {
open fun switchToVideoMode() {
Logger.i(TAG, "Switching to Video Mode");
isAudioMode = false;
val player = exoPlayer ?: return
@@ -277,7 +283,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode)
.build()
}
fun switchToAudioMode() {
open fun switchToAudioMode(video: IPlatformVideoDetails?) {
Logger.i(TAG, "Switching to Audio Mode");
isAudioMode = true;
val player = exoPlayer ?: return
@@ -359,10 +365,11 @@ 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 {
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){
@@ -376,31 +383,45 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume);
if(!keepSubtitles)
_lastSubtitleMediaSource = null;
if(didSetVideo && didSetAudio)
return loadSelectedSources(play, resume);
else
return true;
return@withContext didSetVideo && didSetAudio
}
fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean {
return withContext(Dispatchers.Main) {
if (didSet)
return@withContext loadSelectedSources(play, resume)
else
return@withContext 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)
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;
return@withContext swapSourceInternal(videoSourceUsed, play, resume);
}
fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean {
if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource)
return withContext(Dispatchers.Main) {
if (didSet)
return@withContext loadSelectedSources(play, resume);
else
return@withContext true;
}
}
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 loadSelectedSources(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>
+10
View File
@@ -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,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M592.69,651.08L623.46,651.08L623.46,589.15L655.23,589.15Q671.76,589.15 683.57,577.41Q695.38,565.66 695.38,549.23L695.38,411Q695.38,394.47 683.57,382.66Q671.76,370.85 655.23,370.85L563.08,370.85Q546.77,370.85 533.38,382.66Q520,394.47 520,411L520,549.23Q520,565.66 533.38,577.41Q546.77,589.15 563.08,589.15L592.69,589.15L592.69,651.08ZM264.62,589.15L295.38,589.15L295.38,504.77L409.23,504.77L409.23,589.15L440,589.15L440,370.85L409.23,370.85L409.23,474L295.38,474L295.38,370.85L264.62,370.85L264.62,589.15ZM563.08,558.38Q558.46,558.38 554.62,554.54Q550.77,550.69 550.77,546.08L550.77,413.92Q550.77,409.31 554.62,405.46Q558.46,401.62 563.08,401.62L652.31,401.62Q656.92,401.62 660.77,405.46Q664.62,409.31 664.62,413.92L664.62,546.08Q664.62,550.69 660.77,554.54Q656.92,558.38 652.31,558.38L563.08,558.38ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23Z"/>
</vector>
@@ -5,5 +5,5 @@
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23L150.77,729.23Z"/>
android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.15,760 136.08,743.92Q120,727.85 120,704.62L120,255.38Q120,232.15 136.08,216.08Q152.15,200 175.38,200L784.62,200Q807.85,200 823.92,216.08Q840,232.15 840,255.38L840,704.62Q840,727.85 823.92,743.92Q807.85,760 784.62,760L175.38,760Z"/>
</vector>
+3 -3
View File
@@ -1,9 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M13.15,9.6H33.3V31.45L20.8,44L20.2,43.55Q19.9,43.3 19.75,42.95Q19.6,42.6 19.6,42.2V42L21.7,31.45H6.85Q5.75,31.45 4.9,30.6Q4.05,29.75 4.05,28.7V26.15Q4.05,25.9 4.05,25.575Q4.05,25.25 4.15,24.95L9.75,11.85Q10.1,10.9 11.1,10.25Q12.1,9.6 13.15,9.6ZM31.75,11.15H12.85Q12.4,11.15 11.975,11.375Q11.55,11.6 11.3,12.15L5.6,25.6V28.7Q5.6,29.2 5.95,29.55Q6.3,29.9 6.85,29.9H23.55L21.25,41.45L31.75,30.75ZM31.75,30.75V29.9Q31.75,29.9 31.75,29.55Q31.75,29.2 31.75,28.7V25.6V12.15Q31.75,11.6 31.75,11.375Q31.75,11.15 31.75,11.15ZM33.3,31.45V29.9H40.45V11.15H33.3V9.6H42V31.45Z"/>
android:pathData="M262.65,192.31L666,192.31L666,628.92L415.69,880L403.6,871.19Q398.38,866.31 395.38,859.23Q392.38,852.15 392.38,843.77L392.38,839.92L433.54,628.92L136.85,628.92Q115.46,628.92 98.46,611.92Q81.46,594.92 81.46,573.54L81.46,523.18Q81.46,517.62 81.12,511.27Q80.77,504.92 83,499.46L195.15,237.15Q202.49,218.21 222.44,205.26Q242.39,192.31 262.65,192.31ZM635.23,223.08L256.69,223.08Q248.23,223.08 239.38,227.69Q230.54,232.31 225.92,243.08L112.23,512.08L112.23,573.54Q112.23,583.54 119.15,590.85Q126.08,598.15 136.85,598.15L470.62,598.15L424.54,829.46L635.23,615.46L635.23,223.08ZM635.23,615.46L635.23,615.46L635.23,598.15L635.23,598.15Q635.23,598.15 635.23,590.85Q635.23,583.54 635.23,573.54L635.23,512.08L635.23,243.08Q635.23,232.31 635.23,227.69Q635.23,223.08 635.23,223.08L635.23,223.08L635.23,615.46ZM666,628.92L666,598.15L809,598.15L809,223.08L666,223.08L666,192.31L839.77,192.31L839.77,628.92L666,628.92Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M261.31,192.31L684.46,192.31L684.46,628.92L434.15,880L425,873.23Q417.08,866.77 413.96,859.46Q410.85,852.15 410.85,843.77L410.85,839.92L452,628.92L135.38,628.92Q113.92,628.92 96.96,611.96Q80,595 80,573.54L80,523.08Q80,515.15 81.73,505.65Q83.46,496.15 86.15,489.77L193.69,237.15Q201.92,218.46 221.27,205.38Q240.62,192.31 261.31,192.31ZM715.23,628.92L715.23,192.31L840,192.31L840,628.92L715.23,628.92Z"/>
</vector>
+3 -3
View File
@@ -1,9 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M34.85,40H14.7V18.15L27.15,5.6L27.8,6.05Q28.05,6.3 28.2,6.65Q28.35,7 28.35,7.4V7.6L26.25,18.15H41.15Q42.2,18.15 43.05,19Q43.9,19.85 43.9,20.95V23.45Q43.9,23.75 43.9,24.05Q43.9,24.35 43.8,24.65L38.2,37.75Q37.8,38.7 36.8,39.35Q35.8,40 34.85,40ZM16.2,38.45H35.15Q35.55,38.45 36,38.225Q36.45,38 36.7,37.45L42.35,24V20.95Q42.35,20.45 42,20.075Q41.65,19.7 41.15,19.7H24.45L26.7,8.15L16.2,18.85ZM16.2,18.85V19.7Q16.2,19.7 16.2,20.075Q16.2,20.45 16.2,20.95V24V37.45Q16.2,38 16.2,38.225Q16.2,38.45 16.2,38.45ZM14.7,18.15V19.7H7.55V38.45H14.7V40H6V18.15Z"/>
android:pathData="M696.77,800L293.54,800L293.54,363.38L543.08,112.31L555.84,121.12Q561.15,126 564.15,133.08Q567.15,140.15 567.15,147.77L567.15,152.38L525.23,363.38L822.69,363.38Q844.08,363.38 861.08,380.38Q878.08,397.38 878.08,418.77L878.08,469.13Q878.08,474.69 878.04,481.04Q878,487.38 875.77,492.85L764.38,755.15Q756,774.22 736.19,787.11Q716.38,800 696.77,800ZM324.31,769.23L702.85,769.23Q710.54,769.23 719.77,764.62Q729,760 733.62,749.23L847.31,480.23L847.31,418.77Q847.31,408.77 840,401.46Q832.69,394.15 822.69,394.15L488.92,394.15L534.23,162.85L324.31,376.85L324.31,769.23ZM324.31,376.85L324.31,376.85L324.31,394.15L324.31,394.15Q324.31,394.15 324.31,401.46Q324.31,408.77 324.31,418.77L324.31,480.23L324.31,749.23Q324.31,760 324.31,764.62Q324.31,769.23 324.31,769.23L324.31,769.23L324.31,376.85ZM293.54,363.38L293.54,394.15L150.54,394.15L150.54,769.23L293.54,769.23L293.54,800L119.77,800L119.77,363.38L293.54,363.38Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M698.69,800L275.54,800L275.54,363.38L525.85,112.31L535.77,119.08Q542.92,125.54 546.04,132.85Q549.15,140.15 549.15,147.77L549.15,152.38L508,363.38L824.62,363.38Q846.08,363.38 863.04,380.35Q880,397.31 880,418.77L880,469.23Q880,477.15 878.27,486.65Q876.54,496.15 873.85,502.54L766.31,755.15Q758.08,773.85 738.73,786.92Q719.38,800 698.69,800ZM244.77,363.38L244.77,800L120,800L120,363.38L244.77,363.38Z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:drawable="@drawable/desktop_thumb_down_filled" />
<item android:drawable="@drawable/desktop_thumb_down" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:drawable="@drawable/desktop_thumb_up_filled" />
<item android:drawable="@drawable/desktop_thumb_up" />
</selector>
+3 -2
View File
@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.motion.widget.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
tools:context=".activities.MainActivity"
android:background="@color/black"
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="4dp">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />
<FrameLayout
android:id="@+id/short_view_loading_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#77000000"
android:elevation="4dp"
android:visibility="gone">
<ImageView
android:id="@+id/short_view_loader"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_gravity="center_vertical|center_horizontal"
android:alpha="0.7"
android:contentDescription="@string/loading"
app:srcCompat="@drawable/ic_loader_animated" />
</FrameLayout>
<!-- zero state -->
<LinearLayout
android:id="@+id/zero_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginHorizontal="24dp"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:id="@+id/icon"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginBottom="10dp"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
android:src="@drawable/ic_help" />
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:fontFamily="@font/inter_bold"
android:gravity="center"
android:text="@string/no_results"
android:textColor="@color/white"
android:textSize="22dp" />
<TextView
android:id="@+id/text_centered"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="10dp"
android:fontFamily="@font/inter_regular"
android:gravity="center"
android:text="@string/no_results_shorts"
android:textColor="@color/gray_ac"
android:textSize="13dp" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/sources_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:buttonIcon="@drawable/ic_creators"
app:buttonSubText="Go to the sources tab"
app:buttonText="Sources" />
</LinearLayout>
<FrameLayout
android:id="@+id/shorts_quality_overview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="100dp"
android:visibility="gone" />
</FrameLayout>
@@ -3,24 +3,21 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintDimensionRatio="H,16:13">
<FrameLayout
android:id="@+id/player_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/video_meta">
<ImageView
android:id="@+id/image_video_thumbnail"
@@ -113,10 +110,10 @@
</FrameLayout>
<LinearLayout
android:id="@+id/video_meta"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="-6dp"
android:gravity="center_vertical"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/player_container">
@@ -226,6 +223,7 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingEnd="6dp"
app:layout_constraintTop_toBottomOf="@id/creator_thumbnail"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent">
+337
View File
@@ -0,0 +1,337 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/standard_bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- Drag handle for accessibility -->
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/drag_handle"
style="@style/Widget.Material3.BottomSheet.DragHandle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="0dp" />
<FrameLayout
android:id="@+id/content_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/videodetail_container_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:orientation="vertical"
tools:ignore="SpeakableTextPresentCheck">
<LinearLayout
android:id="@+id/layout_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!--Title Segment-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<!--Title + Meta-->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp">
<TextView
android:id="@+id/videodetail_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:fontFamily="@font/inter_medium"
android:textColor="@color/white"
android:textSize="17sp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/layout_title_right"
app:layout_constraintTop_toTopOf="parent"
tools:text="Some Text" />
<TextView
android:id="@+id/videodetail_meta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:fontFamily="@font/inter_regular"
android:textColor="#ACACAC"
android:textSize="11sp"
app:layout_constraintLeft_toLeftOf="@id/videodetail_title"
app:layout_constraintRight_toLeftOf="@id/layout_title_right"
app:layout_constraintTop_toBottomOf="@id/videodetail_title"
tools:text="51K views - 3 years ago" />
<!--Source Button-->
<LinearLayout
android:id="@+id/layout_title_right"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="vertical"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/videodetail_platform"
android:layout_width="25dp"
android:layout_height="25dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/layout_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/image_like_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:contentDescription="@string/cd_image_like_icon"
app:srcCompat="@drawable/ic_thumb_up"
app:tint="#ACACAC" />
<TextView
android:id="@+id/text_likes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="center_vertical"
android:textColor="#ACACAC"
android:textSize="11sp"
tools:text="500K" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/image_dislike_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginTop="2dp"
android:contentDescription="@string/cd_image_dislike_icon"
app:srcCompat="@drawable/ic_thumb_down"
app:tint="#ACACAC" />
<TextView
android:id="@+id/text_dislikes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="center_vertical"
android:textColor="#ACACAC"
android:textSize="11sp"
tools:text="500K" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<!--Channel/Subscribe Segment-->
<LinearLayout
android:id="@+id/videodetail_channel_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
android:layout_marginTop="17dp"
android:layout_marginRight="14dp"
android:layout_marginBottom="10dp"
android:orientation="horizontal">
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/creator_thumbnail"
android:layout_width="35dp"
android:layout_height="35dp"
android:contentDescription="@string/cd_creator_thumbnail" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="5dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/videodetail_channel_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="11sp"
tools:text="Channel Name" />
<TextView
android:id="@+id/videodetail_channel_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#ACACAC"
android:textSize="11sp"
tools:text="" />
</LinearLayout>
</LinearLayout>
<!--Description-->
<LinearLayout
android:id="@+id/videodetail_description_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp"
android:animateLayoutChanges="true"
android:background="@drawable/background_videodetail_description"
android:gravity="center"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="3dp"
android:paddingRight="12dp"
android:paddingBottom="5dp">
<com.futo.platformplayer.views.behavior.NonScrollingTextView
android:id="@+id/videodetail_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:background="@color/transparent"
android:fontFamily="@font/inter_light"
android:isScrollContainer="false"
android:maxWidth="500dp"
android:maxLines="3"
android:textColor="@color/white"
android:textSize="11sp"
tools:text="@string/lorem_ipsum" />
<TextView
android:id="@+id/videodetail_description_view_more"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@color/transparent"
android:fontFamily="@font/inter_regular"
android:text="@string/click_to_read_more"
android:textAlignment="center"
android:textColor="#838181"
android:textSize="12sp" />
</LinearLayout>
<com.futo.platformplayer.views.MonetizationView
android:id="@+id/monetization"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:id="@+id/layout_change_bottom_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
android:layout_marginTop="10dp"
android:layout_marginRight="14dp"
android:layout_marginBottom="10dp"
android:background="@drawable/background_videodetail_description"
android:gravity="center"
android:orientation="horizontal">
<Button
android:id="@+id/button_polycentric"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="90dp"
android:background="?android:attr/selectableItemBackground"
android:ellipsize="marquee"
android:lines="1"
android:maxWidth="100dp"
android:padding="10dp"
android:text="@string/polycentric"
android:textColor="#fff"
android:textSize="11sp" />
<Button
android:id="@+id/button_platform"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:ellipsize="marquee"
android:lines="1"
android:maxWidth="100dp"
android:padding="10dp"
android:text="@string/platform"
android:textColor="#fff"
android:textSize="11sp" />
</LinearLayout>
<com.futo.platformplayer.views.comments.AddCommentView
android:id="@+id/add_comment_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="28dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="28dp"
android:layout_marginBottom="12dp" />
</LinearLayout>
<com.futo.platformplayer.views.segments.CommentsList
android:id="@+id/comments_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="400dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_top" />
</LinearLayout>
<com.futo.platformplayer.views.overlays.DescriptionOverlay
android:id="@+id/videodetail_container_description"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<com.futo.platformplayer.views.overlays.WebviewOverlay
android:id="@+id/videodetail_container_webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<com.futo.platformplayer.views.overlays.RepliesOverlay
android:id="@+id/videodetail_container_replies"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<com.futo.platformplayer.views.overlays.SupportOverlay
android:id="@+id/videodetail_container_support"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
@@ -16,7 +16,7 @@
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<ScrollView
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginStart="15dp"
@@ -37,6 +37,6 @@
android:textSize="13sp"
android:textIsSelectable="true"
tools:text="@string/lorem_ipsum" />
</ScrollView>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bottom_menu_border">
<com.futo.platformplayer.views.video.FutoShortPlayer
android:id="@+id/short_player"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/transparent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include layout="@layout/view_short_overlay" />
<!-- make the overlay clickable to intercept touches -->
<FrameLayout
android:id="@+id/short_view_loading_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#77000000"
android:clickable="true"
android:elevation="4dp"
android:visibility="gone">
<ImageView
android:id="@+id/short_view_loader"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_gravity="center_vertical|center_horizontal"
android:alpha="0.7"
android:contentDescription="@string/loading"
app:srcCompat="@drawable/ic_loader_animated" />
</FrameLayout>
</merge>

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