This commit is contained in:
Kai 2025-02-20 16:15:04 -06:00
commit a058bdbfef
No known key found for this signature in database
105 changed files with 1174 additions and 325 deletions

View File

@ -1,19 +1,19 @@
name: Bug Report name: Bug Report
description: Let us know about an unexpected error, a crash, or an incorrect behavior. description: Let us know about an unexpected error, a crash, or an incorrect behavior.
labels: ["bug", "new"] labels: ["Bug"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
# Thank you for taking the time to fill out this bug report. # Thank you for taking the time to fill out this bug report.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
## Filing a bug report ## Filing a bug report
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally. To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
* Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info. * Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown * if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
@ -41,18 +41,21 @@ body:
label: What plugins are you seeing the problem on? label: What plugins are you seeing the problem on?
multiple: true multiple: true
options: options:
- All - "All"
- Youtube - "Youtube"
- BiliBili (CN) - "Odysee"
- Twitch - "Rumble"
- Odysee - "Kick"
- Rumble - "Twitch"
- Kick - "PeerTube"
- PeerTube - "Patreon"
- Patreon - "Nebula"
- Nebula - "BiliBili (CN)"
- SoundCloud - "Bitchute"
- Other - "SoundCloud"
- "Dailymotion"
- "Apple Podcasts"
- "Other"
validations: validations:
required: true required: true
@ -72,6 +75,17 @@ body:
- label: While logged out - label: While logged out
- label: N/A - label: N/A
- type: dropdown
id: vpn
attributes:
label: Are you using a VPN?
multiple: false
options:
- "No"
- "Yes"
validations:
required: true
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:

View File

@ -1,13 +1,13 @@
name: Documentation Issue name: Documentation Issue
description: Report an issue or suggest a change in the documentation. description: Report an issue or suggest a change in the documentation.
labels: ["documentation", "new"] labels: ["Documentation"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
# Thank you for opening a documentation change request. # Thank you for opening a documentation change request.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app) The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention. Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)

View File

@ -1,6 +1,6 @@
name: Feature Request name: Feature Request
description: Suggest a new feature or other enhancement. description: Suggest a new feature or other enhancement.
labels: ["enhancement", "new"] labels: ["Enhancement"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -9,8 +9,6 @@ body:
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
[External Contributions are closed at this time](https://github.com/tom-futo/grayjay-android/blob/master/CONTRIBUTION.md#contributing-to-core)
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
- type: textarea - type: textarea
@ -55,4 +53,4 @@ body:
attributes: attributes:
value: | value: |
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue. **Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.

View File

@ -1,34 +0,0 @@
name: Issue labeler
on:
issues:
types: [ opened ]
permissions:
contents: read
jobs:
label-component:
runs-on: ubuntu-latest
permissions:
# required for all workflows
issues: write
steps:
- uses: actions/checkout@v3
- name: Parse issue form
uses: stefanbuck/github-issue-parser@v3
id: issue-parser
with:
template-path: .github/ISSUE_TEMPLATE/bug_report.yml
- name: Set labels based on plugin field
uses: redhat-plumbers-in-action/advanced-issue-labeler@v2
with:
issue-form: ${{ steps.issue-parser.outputs.jsonString }}
section: plugin
block-list: |
None
Other
token: ${{ secrets.GITHUB_TOKEN }}

2
.gitmodules vendored
View File

@ -83,7 +83,7 @@
path = app/src/stable/assets/sources/dailymotion path = app/src/stable/assets/sources/dailymotion
url = ../plugins/dailymotion.git url = ../plugins/dailymotion.git
[submodule "app/src/stable/assets/sources/apple-podcast"] [submodule "app/src/stable/assets/sources/apple-podcast"]
path = app/src/stable/assets/sources/apple-podcast path = app/src/stable/assets/sources/apple-podcasts
url = ../plugins/apple-podcasts.git url = ../plugins/apple-podcasts.git
[submodule "app/src/unstable/assets/sources/apple-podcasts"] [submodule "app/src/unstable/assets/sources/apple-podcasts"]
path = app/src/unstable/assets/sources/apple-podcasts path = app/src/unstable/assets/sources/apple-podcasts

File diff suppressed because one or more lines are too long

View File

@ -11,7 +11,8 @@ let Type = {
Streams: "STREAMS", Streams: "STREAMS",
Mixed: "MIXED", Mixed: "MIXED",
Live: "LIVE", Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS" Subscriptions: "SUBSCRIPTIONS",
Shorts: "SHORTS"
}, },
Order: { Order: {
Chronological: "CHRONOLOGICAL" Chronological: "CHRONOLOGICAL"
@ -244,6 +245,7 @@ class PlatformVideo extends PlatformContent {
this.viewCount = obj.viewCount ?? -1; //Long this.viewCount = obj.viewCount ?? -1; //Long
this.isLive = obj.isLive ?? false; //Boolean this.isLive = obj.isLive ?? false; //Boolean
this.isShort = !!obj.isShort ?? false;
} }
} }
class PlatformVideoDetails extends PlatformVideo { class PlatformVideoDetails extends PlatformVideo {
@ -260,6 +262,11 @@ class PlatformVideoDetails extends PlatformVideo {
this.rating = obj.rating ?? null; //IRating this.rating = obj.rating ?? null; //IRating
this.subtitles = obj.subtitles ?? []; this.subtitles = obj.subtitles ?? [];
this.isShort = !!obj.isShort ?? false;
if (obj.getContentRecommendations) {
this.getContentRecommendations = obj.getContentRecommendations
}
} }
} }

View File

@ -226,6 +226,25 @@ fun Long.toHumanTime(isMs: Boolean): String {
else else
return "${prefix}${minsStr}:${secsStr}" return "${prefix}${minsStr}:${secsStr}"
} }
fun Long.toHumanDuration(isMs: Boolean): String {
var scaler = 1;
if(isMs)
scaler = 1000;
val v = Math.abs(this);
val hours = Math.max(v/(secondsInHour*scaler), 0);
val mins = Math.max((v % (secondsInHour*scaler)) / (secondsInMinute * scaler), 0);
val minsStr = mins.toString();
val seconds = Math.max(((v % (secondsInHour*scaler)) % (secondsInMinute * scaler))/scaler, 0);
val secsStr = seconds.toString().padStart(2, '0');
val prefix = if (this < 0) { "-" } else { "" };
return listOf(
if(hours > 0) "${hours}h" else null,
if(mins > 0) "${mins}m" else null ,
if(seconds > 0) "${seconds}s" else null
).filterNotNull().joinToString(" ");
}
//TODO: Determine if below stuff should have its own proper class, seems a bit too complex for a utility method //TODO: Determine if below stuff should have its own proper class, seems a bit too complex for a utility method
fun String.fixHtmlWhitespace(): Spanned { fun String.fixHtmlWhitespace(): Spanned {

View File

@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.net.Inet4Address import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket

View File

@ -33,10 +33,10 @@ fun Boolean?.toYesNo(): String {
fun InetAddress?.toUrlAddress(): String { fun InetAddress?.toUrlAddress(): String {
return when (this) { return when (this) {
is Inet6Address -> { is Inet6Address -> {
"[${toString()}]" "[${hostAddress}]"
} }
is Inet4Address -> { is Inet4Address -> {
toString() hostAddress
} }
else -> { else -> {
throw Exception("Invalid address type") throw Exception("Invalid address type")

View File

@ -254,6 +254,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true; var progressBar: Boolean = true;
@FormField(R.string.hide_hidden_from_search, FieldForm.TOGGLE, R.string.hide_hidden_from_search_description, 7)
var hidefromSearch: Boolean = false;
fun getSearchFeedStyle(): FeedStyle { fun getSearchFeedStyle(): FeedStyle {
if(searchFeedStyle == 0) if(searchFeedStyle == 0)
@ -641,6 +644,9 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class Plugins { class Plugins {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0) @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true; var clearCookiesOnLogout: Boolean = true;
@ -861,11 +867,13 @@ class Settings : FragmentedStorageFileJson() {
class Other { class Other {
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2) @FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
var playlistDeleteConfirmation: Boolean = true; var playlistDeleteConfirmation: Boolean = true;
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
var playlistAllowDups: Boolean = true;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 3) @FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4)
var polycentricEnabled: Boolean = true; var polycentricEnabled: Boolean = true;
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 4) @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5)
var polycentricLocalCache: Boolean = true; var polycentricLocalCache: Boolean = true;
} }

View File

@ -1,6 +1,7 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.content.Context import android.content.Context
import android.content.Intent
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.work.Data import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder

View File

@ -368,8 +368,8 @@ class UIDialogs {
} }
} }
fun showChangelogDialog(context: Context, lastVersion: Int) { fun showChangelogDialog(context: Context, lastVersion: Int, changelogs: Map<Int, String>? = null) {
val dialog = ChangelogDialog(context); val dialog = ChangelogDialog(context, changelogs);
registerDialogOpened(dialog); registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();

View File

@ -79,6 +79,36 @@ class UISlideOverlays {
return menu; return menu;
} }
fun showQueueOptionsOverlay(context: Context, container: ViewGroup) {
UISlideOverlays.showOverlay(container, "Queue options", null, {
}, SlideUpMenuItem(context, R.drawable.ic_playlist, "Save as playlist", "", "Creates a new playlist with queue as videos", null, {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text.trim()
if (text.isBlank()) {
return@subscribe;
}
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
StatePlayer.instance.saveQueueAsPlaylist(text);
UIDialogs.appToast("Playlist [${text}] created");
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.show();
nameInput.activate();
}, false));
}
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay { fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
@ -335,7 +365,9 @@ class UISlideOverlays {
call = { call = {
selectedVideoVariant = it selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it) slideUpMenuOverlay.selectOption(videoButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
}, },
invokeParent = false invokeParent = false
)) ))
@ -417,7 +449,7 @@ class UISlideOverlays {
} }
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem( listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_movie, R.drawable.ic_movie,
container.context.getString(R.string.none), container.context.getString(R.string.none),
@ -430,7 +462,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
)) + )) else listOf()) +
videoSources videoSources
.filter { it.isDownloadable() } .filter { it.isDownloadable() }
.map { .map {
@ -895,7 +927,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); if(StatePlaylists.instance.addToPlaylist(lastUpdated.id, video))
UIDialogs.appToast("Added to playlist [${lastUpdated?.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})) }))
); );
@ -906,7 +939,7 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf( (listOf(
if(!isLimited) if(!isLimited && !video.isLive)
SlideUpMenuItem( SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_download, R.drawable.ic_download,
@ -991,7 +1024,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos), "${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video); if(StatePlaylists.instance.addToPlaylist(playlist.id, video))
UIDialogs.appToast("Added to playlist [${playlist.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})); }));
} }
@ -1018,7 +1052,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); if(StatePlaylists.instance.addToPlaylist(lastUpdated.id, video))
UIDialogs.appToast("Added to playlist [${lastUpdated?.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})) }))
); );
@ -1040,7 +1075,10 @@ class UISlideOverlays {
StatePlayer.TYPE_WATCHLATER, StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later", tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), call = {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false);
}),
) )
); );
@ -1067,7 +1105,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos), "${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video); if(StatePlaylists.instance.addToPlaylist(playlist.id, video))
UIDialogs.appToast("Added to playlist [${playlist.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})); }));
} }

View File

@ -1269,7 +1269,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (toast.long) if (toast.long)
delay(5000); delay(5000);
else else
delay(3000); delay(2500);
} }
Logger.i(TAG, "Ending appToast loop"); Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {

View File

@ -122,7 +122,11 @@ class SyncPairActivity : AppCompatActivity() {
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_layoutPairingError.visibility = View.VISIBLE _layoutPairingError.visibility = View.VISIBLE
_textError.text = e.message if(e.message == "Failed to connect") {
_textError.text = "Failed to connect.\n\nThis may be due to not being on the same network, due to firewall, or vpn.\nSync currently operates only over local direct connections."
}
else
_textError.text = e.message
_layoutPairing.visibility = View.GONE _layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e) Logger.e(TAG, "Failed to pair", e)
} }

View File

@ -5,6 +5,8 @@ import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.FragmentedStorage
import okhttp3.Call import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -63,7 +65,7 @@ open class ManagedHttpClient {
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) { constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
_builderTemplate = builder; _builderTemplate = builder;
if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates) if(FragmentedStorage.isInitialized && StateApp.instance.isMainActive && SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
trustAllCertificates(builder); trustAllCertificates(builder);
client = builder.addNetworkInterceptor { chain -> client = builder.addNetworkInterceptor { chain ->
val request = beforeRequest(chain.request()); val request = beforeRequest(chain.request());

View File

@ -73,7 +73,7 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent") Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
current += bytesToSend.toLong() current += bytesToSend.toLong()
if (current >= end) { if (current > end) {
Logger.i(TAG, "Expected amount of bytes sent") Logger.i(TAG, "Expected amount of bytes sent")
break break
} }

View File

@ -30,6 +30,7 @@ class ResultCapabilities(
const val TYPE_POSTS = "POSTS"; const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED"; const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS"; const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val TYPE_SHORTS = "SHORTS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";

View File

@ -33,13 +33,13 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
} }
companion object { companion object {
fun fromSource(source: IAudioSource, path: String, fileSize: Long): LocalAudioSource { fun fromSource(source: IAudioSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalAudioSource {
return LocalAudioSource( return LocalAudioSource(
source.name, source.name,
path, path,
fileSize, fileSize,
source.bitrate, source.bitrate,
source.container, overrideContainer ?: source.container,
source.codec, source.codec,
source.language source.language
); );

View File

@ -35,7 +35,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
} }
companion object { companion object {
fun fromSource(source: IVideoSource, path: String, fileSize: Long): LocalVideoSource { fun fromSource(source: IVideoSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalVideoSource {
return LocalVideoSource( return LocalVideoSource(
source.name, source.name,
path, path,
@ -43,7 +43,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
source.width, source.width,
source.height, source.height,
source.duration, source.duration,
source.container, overrideContainer ?: source.container,
source.codec, source.codec,
source.bitrate?:0 source.bitrate?:0
); );

View File

@ -13,4 +13,6 @@ interface IPlatformVideo : IPlatformContent {
val viewCount: Long; val viewCount: Long;
val isLive : Boolean; val isLive : Boolean;
val isShort: Boolean;
} }

View File

@ -25,6 +25,7 @@ open class SerializedPlatformVideo(
override val duration: Long, override val duration: Long,
override val viewCount: Long, override val viewCount: Long,
override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent { ) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA; override val contentType: ContentType = ContentType.MEDIA;

View File

@ -38,7 +38,8 @@ open class SerializedPlatformVideoDetails(
override val video: ISerializedVideoSourceDescriptor, override val video: ISerializedVideoSourceDescriptor,
override val preview: ISerializedVideoSourceDescriptor?, override val preview: ISerializedVideoSourceDescriptor?,
override val subtitles: List<SubtitleRawSource> = listOf() override val subtitles: List<SubtitleRawSource> = listOf(),
override val isShort: Boolean = false
) : IPlatformVideo, IPlatformVideoDetails { ) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA; final override val contentType: ContentType get() = ContentType.MEDIA;

View File

@ -33,6 +33,7 @@ class SourcePluginConfig(
override val allowEval: Boolean = false, override val allowEval: Boolean = false,
override val allowUrls: List<String> = listOf(), override val allowUrls: List<String> = listOf(),
override val packages: List<String> = listOf(), override val packages: List<String> = listOf(),
override val packagesOptional: List<String> = listOf(),
val settings: List<Setting> = listOf(), val settings: List<Setting> = listOf(),
@ -52,6 +53,7 @@ class SourcePluginConfig(
var allowAllHttpHeaderAccess: Boolean = false, var allowAllHttpHeaderAccess: Boolean = false,
var maxDownloadParallelism: Int = 0, var maxDownloadParallelism: Int = 0,
var reduceFunctionsInLimitedVersion: Boolean = false, var reduceFunctionsInLimitedVersion: Boolean = false,
var changelog: HashMap<String, List<String>>? = null
) : IV8PluginConfig { ) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@ -101,6 +103,10 @@ class SourcePluginConfig(
if(!packages.contains(pack)) if(!packages.contains(pack))
return false; return false;
} }
for(pack in newConfig.packagesOptional) {
if(!packagesOptional.contains(pack))
return false;
}
//Developer Submit Url should be same or empty //Developer Submit Url should be same or empty
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl) if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
return false; return false;
@ -129,7 +135,7 @@ class SourcePluginConfig(
val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id); val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id);
if (currentlyInstalledPlugin != null) { if (currentlyInstalledPlugin != null) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey) { if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey && !currentlyInstalledPlugin.config.scriptPublicKey.isNullOrEmpty()) {
list.add(Pair( list.add(Pair(
"Different Author", "Different Author",
"This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor.")); "This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor."));
@ -178,6 +184,19 @@ class SourcePluginConfig(
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) }; return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
} }
fun getChangelogString(version: String): String?{
if(changelog == null || !changelog!!.containsKey(version))
return null;
val changelog = changelog!![version]!!;
if(changelog.size > 1) {
return "Changelog (${version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
}
else if(changelog.size == 1) {
return "Changelog (${version})\n" + changelog[0].trim();
}
return null;
}
companion object { companion object {
fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig { fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig {
val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json); val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json);

View File

@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
@ -17,6 +18,7 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val viewCount: Long; final override val viewCount: Long;
final override val isLive: Boolean; final override val isLive: Boolean;
final override val isShort: Boolean;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformVideo"; val contextName = "PlatformVideo";
@ -26,5 +28,6 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
duration = _content.getOrThrow<Int>(config, "duration", contextName).toLong(); duration = _content.getOrThrow<Int>(config, "duration", contextName).toLong();
viewCount = _content.getOrThrow(config, "viewCount", contextName); viewCount = _content.getOrThrow(config, "viewCount", contextName);
isLive = _content.getOrThrow(config, "isLive", contextName); isLive = _content.getOrThrow(config, "isLive", contextName);
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
} }
} }

View File

@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
@ -14,8 +16,8 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.others.Language import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource { class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String = "application/dash+xml"; override val container : String;
override val name : String; override val name : String;
override val codec: String; override val codec: String;
override val bitrate: Int; override val bitrate: Int;
@ -29,11 +31,14 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override val hasGenerate: Boolean; override val hasGenerate: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource"; val contextName = "DashRawSource";
val config = plugin.config; val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName); name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName); url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrThrow(config, "manifest", contextName); manifest = _obj.getOrThrow(config, "manifest", contextName);
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: ""; codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0; bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
@ -50,15 +55,28 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin(); val plugin = _plugin.getUnderlyingPlugin();
var result: String? = null;
if(_plugin is DevJSClient) if(_plugin is DevJSClient)
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) { result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate"); _obj.invokeString("generate");
} }
} }
else else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate"); _obj.invokeString("generate");
} }
if(result != null){
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
}
return result;
} }
} }

View File

@ -6,6 +6,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
@ -20,8 +22,8 @@ interface IJSDashManifestRawSource {
var manifest: String?; var manifest: String?;
fun generate(): String?; fun generate(): String?;
} }
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource { open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String = "application/dash+xml"; override val container : String;
override val name : String; override val name : String;
override val width: Int; override val width: Int;
override val height: Int; override val height: Int;
@ -36,11 +38,14 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
override val hasGenerate: Boolean; override val hasGenerate: Boolean;
val canMerge: Boolean; val canMerge: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource"; val contextName = "DashRawSource";
val config = plugin.config; val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName); name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName); url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null); manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0; width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0; height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
@ -57,17 +62,30 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
return manifest; return manifest;
if(_obj.isClosed) if(_obj.isClosed)
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
var result: String? = null;
if(_plugin is DevJSClient) { if(_plugin is DevJSClient) {
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") { result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate"); _obj.invokeString("generate");
}); });
} }
} }
else else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate"); _obj.invokeString("generate");
}); });
if(result != null){
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
}
return result;
} }
} }
@ -100,12 +118,16 @@ class JSDashManifestMergingRawSource(
if(videoDash == null) return null; if(videoDash == null) return null;
//TODO: Temporary simple solution..make more reliable version //TODO: Temporary simple solution..make more reliable version
var result: String? = null;
val audioAdaptationSet = adaptationSetRegex.find(audioDash!!); val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
if(audioAdaptationSet != null) { if(audioAdaptationSet != null) {
return videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value) result = videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
} }
else else
return videoDash; result = videoDash;
return result;
} }
companion object { companion object {

View File

@ -64,7 +64,7 @@ class StateCasting {
private val _scopeMain = CoroutineScope(Dispatchers.Main); private val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
private val _castServer = ManagedHttpServer(9999); private val _castServer = ManagedHttpServer();
private var _started = false; private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf(); var devices: HashMap<String, CastingDevice> = hashMapOf();

View File

@ -1,37 +1,24 @@
package com.futo.platformplayer.dialogs package com.futo.platformplayer.dialogs
import android.app.AlertDialog import android.app.AlertDialog
import android.app.PendingIntent.*
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.WindowManager
import android.widget.Button import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.receivers.InstallReceiver
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.InputStream
class ChangelogDialog(context: Context?) : AlertDialog(context) { class ChangelogDialog(context: Context?, val changelogs: Map<Int, String>? = null) : AlertDialog(context) {
companion object { companion object {
private val TAG = "ChangelogDialog"; private val TAG = "ChangelogDialog";
} }
@ -48,7 +35,11 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0; private var _maxVersion: Int = 0;
private var _managedHttpClient = ManagedHttpClient(); private var _managedHttpClient = ManagedHttpClient();
private val _taskDownloadChangelog = TaskHandler<Int, String?>(StateApp.instance.scopeGetter, { version -> StateUpdate.instance.downloadChangelog(_managedHttpClient, version) }) private val _taskDownloadChangelog = TaskHandler<Int, String?>(StateApp.instance.scopeGetter, { version -> if(changelogs == null)
StateUpdate.instance.downloadChangelog(_managedHttpClient, version)
else
changelogs[version]
})
.success { setChangelog(it); } .success { setChangelog(it); }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load changelog.", it); Logger.w(TAG, "Failed to load changelog.", it);
@ -97,7 +88,7 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
setVersion(version); setVersion(version);
val currentVersion = BuildConfig.VERSION_CODE; val currentVersion = BuildConfig.VERSION_CODE;
_buttonUpdate.visibility = if (currentVersion == _maxVersion) View.GONE else View.VISIBLE; _buttonUpdate.visibility = if (currentVersion == _maxVersion || changelogs != null) View.GONE else View.VISIBLE;
} }
private fun setVersion(version: Int) { private fun setVersion(version: Int) {

View File

@ -54,6 +54,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _buttonInstall: LinearLayout; private lateinit var _buttonInstall: LinearLayout;
private lateinit var _textPlugin: TextView; private lateinit var _textPlugin: TextView;
private lateinit var _textChangelog: TextView;
private lateinit var _textProgres: TextView; private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView; private lateinit var _textError: TextView;
private lateinit var _textResult: TextView; private lateinit var _textResult: TextView;
@ -94,6 +95,7 @@ class PluginUpdateDialog : AlertDialog {
_buttonInstall = findViewById(R.id.button_install); _buttonInstall = findViewById(R.id.button_install);
_textPlugin = findViewById(R.id.text_plugin); _textPlugin = findViewById(R.id.text_plugin);
_textChangelog = findViewById(R.id.text_changelog);
_textProgres = findViewById(R.id.text_progress); _textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error); _textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result); _textResult = findViewById(R.id.text_result);
@ -110,6 +112,27 @@ class PluginUpdateDialog : AlertDialog {
_updateSpinner = findViewById(R.id.update_spinner); _updateSpinner = findViewById(R.id.update_spinner);
_iconPlugin = findViewById(R.id.icon_plugin); _iconPlugin = findViewById(R.id.icon_plugin);
try {
var changelogVersion = _newConfig.version.toString();
if (_newConfig.changelog != null && _newConfig.changelog?.containsKey(changelogVersion) == true) {
_textChangelog.movementMethod = ScrollingMovementMethod();
val changelog = _newConfig.changelog!![changelogVersion]!!;
if(changelog.size > 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
}
else if(changelog.size == 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
}
else
_textChangelog.visibility = View.GONE;
} else
_textChangelog.visibility = View.GONE;
}
catch(ex: Throwable) {
_textChangelog.visibility = View.GONE;
Logger.e(TAG, "Invalid changelog? ", ex);
}
_buttonCancel1.setOnClickListener { _buttonCancel1.setOnClickListener {
dismiss(); dismiss();
}; };

View File

@ -141,11 +141,17 @@ class VideoDownload {
var error: String? = null; var error: String? = null;
var videoFilePath: String? = null; var videoFilePath: String? = null;
var videoFileName: String? = null; var videoFileNameBase: String? = null;
var videoFileNameExt: String? = null;
val videoFileName: String? get() = if(videoFileNameBase.isNullOrEmpty()) null else videoFileNameBase + (if(!videoFileNameExt.isNullOrEmpty()) "." + videoFileNameExt else "");
var videoOverrideContainer: String? = null;
var videoFileSize: Long? = null; var videoFileSize: Long? = null;
var audioFilePath: String? = null; var audioFilePath: String? = null;
var audioFileName: String? = null; var audioFileNameBase: String? = null;
var audioFileNameExt: String? = null;
val audioFileName: String? get() = if(audioFileNameBase.isNullOrEmpty()) null else audioFileNameBase + (if(!audioFileNameExt.isNullOrEmpty()) "." + audioFileNameExt else "");
var audioOverrideContainer: String? = null;
var audioFileSize: Long? = null; var audioFileSize: Long? = null;
var subtitleFilePath: String? = null; var subtitleFilePath: String? = null;
@ -235,11 +241,13 @@ class VideoDownload {
videoDetails = null; videoDetails = null;
videoSource = null; videoSource = null;
videoSourceLive = null; videoSourceLive = null;
videoOverrideContainer = null;
} }
if(requiresLiveAudioSource && !isLiveAudioSourceValid) { if(requiresLiveAudioSource && !isLiveAudioSourceValid) {
videoDetails = null; videoDetails = null;
audioSource = null; audioSource = null;
videoSourceLive = null; videoSourceLive = null;
audioOverrideContainer = null;
} }
if(video == null && videoDetails == null) if(video == null && videoDetails == null)
throw IllegalStateException("Missing information for download to complete"); throw IllegalStateException("Missing information for download to complete");
@ -310,6 +318,10 @@ class VideoDownload {
if(vsource == null) if(vsource == null)
vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf()) vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
// ?: throw IllegalStateException("Could not find a valid video source for video"); // ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
}
if(vsource == null) { if(vsource == null) {
videoSource = null; videoSource = null;
@ -361,6 +373,12 @@ class VideoDownload {
asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate) asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
?: if(videoSource != null ) null ?: if(videoSource != null ) null
else throw DownloadException("Could not find a valid video or audio source for download") else throw DownloadException("Could not find a valid video or audio source for download")
if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
}
if(asource == null) { if(asource == null) {
audioSource = null; audioSource = null;
if(!original.video.isUnMuxed || original.video.videoSources.size == 0) if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
@ -400,11 +418,13 @@ class VideoDownload {
else audioSource; else audioSource;
if(actualVideoSource != null) { if(actualVideoSource != null) {
videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource!!.container)}".sanitizeFileName(); videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath; videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
} }
if(actualAudioSource != null) { if(actualAudioSource != null) {
audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName(); audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
audioFileNameExt = audioContainerToExtension(actualAudioSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath; audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
} }
if(subtitleSource != null) { if(subtitleSource != null) {
@ -1052,8 +1072,8 @@ class VideoDownload {
fun complete() { fun complete() {
Logger.i(TAG, "VideoDownload Complete [${name}]"); Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id); val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0) }; val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0) }; val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) }; val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource) if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@ -1082,7 +1102,7 @@ class VideoDownload {
StateDownloads.instance.updateCachedVideo(existing); StateDownloads.instance.updateCachedVideo(existing);
} }
else { else {
val newVideo = VideoLocal(videoDetails!!); val newVideo = VideoLocal(videoDetails!!, OffsetDateTime.now());
if(localVideoSource != null) if(localVideoSource != null)
newVideo.videoSource.add(localVideoSource); newVideo.videoSource.add(localVideoSource);
if(localAudioSource != null) if(localAudioSource != null)
@ -1134,7 +1154,7 @@ class VideoDownload {
else if (container.contains("video/x-matroska")) else if (container.contains("video/x-matroska"))
return "mkv"; return "mkv";
else else
return "video"; return "video";//throw IllegalStateException("Unknown container: " + container)
} }
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
@ -1145,11 +1165,11 @@ class VideoDownload {
else if (container.contains("audio/mp3")) else if (container.contains("audio/mp3"))
return "mp3"; return "mp3";
else if (container.contains("audio/webm")) else if (container.contains("audio/webm"))
return "webma"; return "webm";
else if (container == "application/vnd.apple.mpegurl") else if (container == "application/vnd.apple.mpegurl")
return "mp4"; return "mp4a";
else else
return "audio"; return "audio";// throw IllegalStateException("Unknown container: " + container)
} }
fun subtitleContainerToExtension(container: String?): String { fun subtitleContainerToExtension(container: String?): String {

View File

@ -39,7 +39,7 @@ class VideoExport {
this.subtitleSource = subtitleSource; this.subtitleSource = subtitleSource;
} }
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope { suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null, documentRoot: DocumentFile? = null): DocumentFile = coroutineScope {
val v = videoSource; val v = videoSource;
val a = audioSource; val a = audioSource;
val s = subtitleSource; val s = subtitleSource;
@ -50,7 +50,7 @@ class VideoExport {
if (s != null) sourceCount++; if (s != null) sourceCount++;
val outputFile: DocumentFile?; val outputFile: DocumentFile?;
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set"); val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
if (sourceCount > 1) { if (sourceCount > 1) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile("video/mp4", outputFileName) val f = downloadRoot.createFile("video/mp4", outputFileName)

View File

@ -23,6 +23,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.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.stores.v2.IStoreItem import com.futo.platformplayer.stores.v2.IStoreItem
import java.io.File import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -70,14 +71,21 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val isLive: Boolean get() = videoSerialized.isLive; override val isLive: Boolean get() = videoSerialized.isLive;
override val isShort: Boolean get() = videoSerialized.isShort;
//TODO: Offline subtitles //TODO: Offline subtitles
override val subtitles: List<ISubtitleSource> = listOf(); override val subtitles: List<ISubtitleSource> = listOf();
constructor(video: SerializedPlatformVideoDetails) { @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
var downloadDate: OffsetDateTime? = null;
constructor(video: SerializedPlatformVideoDetails, downloadDate: OffsetDateTime? = null) {
this.videoSerialized = video; this.videoSerialized = video;
this.downloadDate = downloadDate;
} }
constructor(video: IPlatformVideoDetails, subtitleSources: List<SubtitleRawSource>) { constructor(video: IPlatformVideoDetails, subtitleSources: List<SubtitleRawSource>) {
this.videoSerialized = SerializedPlatformVideoDetails.fromVideo(video, subtitleSources); this.videoSerialized = SerializedPlatformVideoDetails.fromVideo(video, subtitleSources);
downloadDate = OffsetDateTime.now();
} }
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null; override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;

View File

@ -32,6 +32,7 @@ import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageDOMParser import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageJSDOM
import com.futo.platformplayer.engine.packages.PackageUtilities import com.futo.platformplayer.engine.packages.PackageUtilities
import com.futo.platformplayer.engine.packages.V8Package import com.futo.platformplayer.engine.packages.V8Package
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@ -94,7 +95,11 @@ class V8Plugin {
withDependency(PackageBridge(this, config)); withDependency(PackageBridge(this, config));
for(pack in config.packages) for(pack in config.packages)
withDependency(getPackage(pack)); withDependency(getPackage(pack)!!);
for(pack in config.packagesOptional)
getPackage(pack, true)?.let {
withDependency(it);
}
} }
fun changeAllowDevSubmit(isAllowed: Boolean) { fun changeAllowDevSubmit(isAllowed: Boolean) {
@ -254,13 +259,14 @@ class V8Plugin {
} }
} }
private fun getPackage(packageName: String): V8Package { private fun getPackage(packageName: String, allowNull: Boolean = false): V8Package? {
//TODO: Auto get all package types? //TODO: Auto get all package types?
return when(packageName) { return when(packageName) {
"DOMParser" -> PackageDOMParser(this) "DOMParser" -> PackageDOMParser(this)
"Http" -> PackageHttp(this, config) "Http" -> PackageHttp(this, config)
"Utilities" -> PackageUtilities(this, config) "Utilities" -> PackageUtilities(this, config)
else -> throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}"); "JSDOM" -> PackageJSDOM(this, config)
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
}; };
} }

View File

@ -5,6 +5,7 @@ interface IV8PluginConfig {
val allowEval: Boolean; val allowEval: Boolean;
val allowUrls: List<String>; val allowUrls: List<String>;
val packages: List<String>; val packages: List<String>;
val packagesOptional: List<String>;
} }
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
@ -13,17 +14,20 @@ class V8PluginConfig : IV8PluginConfig {
override val allowEval: Boolean; override val allowEval: Boolean;
override val allowUrls: List<String>; override val allowUrls: List<String>;
override val packages: List<String>; override val packages: List<String>;
override val packagesOptional: List<String>;
constructor() { constructor() {
name = "Unknown"; name = "Unknown";
allowEval = false; allowEval = false;
allowUrls = listOf(); allowUrls = listOf();
packages = listOf(); packages = listOf();
packagesOptional = listOf();
} }
constructor(name: String, allowEval: Boolean, allowUrls: List<String>, packages: List<String> = listOf()) { constructor(name: String, allowEval: Boolean, allowUrls: List<String>, packages: List<String> = listOf(), packagesOptional: List<String> = listOf()) {
this.name = name; this.name = name;
this.allowEval = allowEval; this.allowEval = allowEval;
this.allowUrls = allowUrls; this.allowUrls = allowUrls;
this.packages = packages; this.packages = packages;
this.packagesOptional = packagesOptional;
} }
} }

View File

@ -1,8 +1,12 @@
package com.futo.platformplayer.engine.packages package com.futo.platformplayer.engine.packages
import android.media.MediaCodec
import android.media.MediaCodecList
import com.caoccao.javet.annotations.V8Function import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.annotations.V8Property import com.caoccao.javet.annotations.V8Property
import com.caoccao.javet.utils.JavetResourceUtils
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.reference.V8ValueFunction
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
@ -16,6 +20,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -37,6 +42,18 @@ class PackageBridge : V8Package {
_config = config; _config = config;
_client = plugin.httpClient; _client = plugin.httpClient;
_clientAuth = plugin.httpClientAuth; _clientAuth = plugin.httpClientAuth;
withScript("""
function setTimeout(func, delay) {
let args = Array.prototype.slice.call(arguments, 2);
return bridge.setTimeout(func.bind(globalThis, ...args), delay || 0);
}
""".trimIndent());
withScript("""
function clearTimeout(id) {
bridge.clearTimeout(id);
}
""".trimIndent());
} }
@ -62,6 +79,48 @@ class PackageBridge : V8Package {
value.close(); value.close();
} }
var timeoutCounter = 0;
var timeoutMap = HashSet<Int>();
@V8Function
fun setTimeout(func: V8ValueFunction, timeout: Long): Int {
val id = timeoutCounter++;
val funcClone = func.toClone<V8ValueFunction>()
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
delay(timeout);
synchronized(timeoutMap) {
if(!timeoutMap.contains(id)) {
JavetResourceUtils.safeClose(funcClone);
return@launch;
}
timeoutMap.remove(id);
}
try {
_plugin.whenNotBusy {
funcClone.callVoid(null, arrayOf<Any>());
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed timeout callback", ex);
}
finally {
JavetResourceUtils.safeClose(funcClone);
}
};
synchronized(timeoutMap) {
timeoutMap.add(id);
}
return id;
}
@V8Function
fun clearTimeout(id: Int) {
synchronized(timeoutMap) {
if(timeoutMap.contains(id))
timeoutMap.remove(id);
}
}
@V8Function @V8Function
fun toast(str: String) { fun toast(str: String) {
Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}"); Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}");
@ -130,7 +189,44 @@ class PackageBridge : V8Package {
return false; return false;
} }
@V8Function
fun getHardwareCodecs(): List<String>{
return getSupportedHardwareMediaCodecs();
}
companion object { companion object {
private const val TAG = "PackageBridge"; private const val TAG = "PackageBridge";
private var _mediaCodecList: MutableList<String> = mutableListOf();
private var _mediaCodecListHardware: MutableList<String> = mutableListOf();
fun getSupportedMediaCodecs(): List<String>{
synchronized(_mediaCodecList) {
if(_mediaCodecList.size <= 0)
updateMediaCodecList();
return _mediaCodecList;
}
}
fun getSupportedHardwareMediaCodecs(): List<String>{
synchronized(_mediaCodecList) {
if(_mediaCodecList.size <= 0)
updateMediaCodecList();
return _mediaCodecListHardware;
}
}
private fun updateMediaCodecList() {
_mediaCodecList.clear();
_mediaCodecListHardware.clear();
for(codec in MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos) {
if(!codec.isEncoder) {
_mediaCodecList.add(codec.canonicalName);
if (codec.isHardwareAccelerated)
_mediaCodecListHardware.add(codec.canonicalName);
}
}
}
} }
} }

View File

@ -21,9 +21,13 @@ import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.concurrent.thread
import kotlin.streams.asSequence import kotlin.streams.asSequence
class PackageHttp: V8Package { class PackageHttp: V8Package {
@ -42,6 +46,9 @@ class PackageHttp: V8Package {
override val name: String get() = "Http"; override val name: String get() = "Http";
override val variableName: String get() = "http"; override val variableName: String get() = "http";
private var _batchPoolLock: Any = Any();
private var _batchPool: ForkJoinPool? = null;
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config; _config = config;
@ -51,6 +58,37 @@ class PackageHttp: V8Package {
_packageClientAuth = PackageHttpClient(this, _clientAuth); _packageClientAuth = PackageHttpClient(this, _clientAuth);
} }
/*
Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
*/
private fun <T, R> autoParallelPool(data: List<T>, parallelism: Int, handle: (T)->R): List<Pair<R?, Throwable?>> {
synchronized(_batchPoolLock) {
val threadsToUse = if (parallelism <= 0) data.size else Math.min(parallelism, data.size);
if(_batchPool == null)
_batchPool = ForkJoinPool(threadsToUse);
var pool = _batchPool ?: return listOf();
if(pool.poolSize < threadsToUse) { //Resize pool
pool.shutdown();
_batchPool = ForkJoinPool(threadsToUse);
pool = _batchPool ?: return listOf();
}
val resultTasks = mutableListOf<ForkJoinTask<Pair<R?, Throwable?>>>();
for(item in data){
resultTasks.add(pool.submit<Pair<R?, Throwable?>> {
try {
return@submit Pair<R?, Throwable?>(handle(item), null);
}
catch(ex: Throwable) {
return@submit Pair<R?, Throwable?>(null, ex);
}
});
}
return resultTasks.map { it.join() };
}
}
@V8Function @V8Function
fun newClient(withAuth: Boolean): PackageHttpClient { fun newClient(withAuth: Boolean): PackageHttpClient {
val httpClient = if(withAuth) _clientAuth.clone() else _client.clone(); val httpClient = if(withAuth) _clientAuth.clone() else _client.clone();
@ -176,8 +214,6 @@ class PackageHttp: V8Package {
obj.set("url", url); obj.set("url", url);
obj.set("code", code); obj.set("code", code);
if(body != null) { if(body != null) {
val buffer = runtime.createV8ValueArrayBuffer(body.size);
buffer.fromBytes(body);
obj.set("body", body); obj.set("body", body);
} }
obj.set("headers", headers); obj.set("headers", headers);
@ -236,16 +272,19 @@ class PackageHttp: V8Package {
//Finalizer //Finalizer
@V8Function @V8Function
fun execute(): List<IBridgeHttpResponse?> { fun execute(): List<IBridgeHttpResponse?> {
return _reqs.parallelStream().map { return _package.autoParallelPool(_reqs, -1) {
if(it.second.method == "DUMMY") if(it.second.method == "DUMMY")
return@map null; return@autoParallelPool null;
if(it.second.body != null) if(it.second.body != null)
return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else else
return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
} }.map {
.asSequence() if(it.second != null)
.toList(); throw it.second!!;
else
return@map it.first;
}.toList();
} }
} }
@ -439,11 +478,8 @@ class PackageHttp: V8Package {
else { else {
headers?.forEach { (header, values) -> headers?.forEach { (header, values) ->
val lowerCaseHeader = header.lowercase() val lowerCaseHeader = header.lowercase()
if(lowerCaseHeader == "set-cookie") { if(lowerCaseHeader == "set-cookie" && !values.any { it.lowercase().contains("httponly") })
result[lowerCaseHeader] = values.filter{ result[lowerCaseHeader] = values;
!it.lowercase().contains("httponly")
};
}
else else
result[lowerCaseHeader] = values; result[lowerCaseHeader] = values;
} }

View File

@ -0,0 +1,20 @@
package com.futo.platformplayer.engine.packages
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.states.StateApp
class PackageJSDOM : V8Package {
@Transient
private val _config: IV8PluginConfig;
override val name: String get() = "JSDOM";
override val variableName: String get() = "packageJSDOM";
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config;
plugin.withDependency(StateApp.instance.contextOrNull ?: return, "scripts/JSDOM.js");
}
}

View File

@ -43,7 +43,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
class ChannelContentsFragment : Fragment(), IChannelTabFragment { class ChannelContentsFragment(private val subType: String? = null) : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null; private var _recyclerResults: RecyclerView? = null;
private var _glmVideo: GridLayoutManager? = null; private var _glmVideo: GridLayoutManager? = null;
private var _loading = false; private var _loading = false;
@ -73,9 +73,12 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if (lastPolycentricProfile != null) if (lastPolycentricProfile != null)
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
if(pager == null) if(pager == null) {
pager = StatePlatform.instance.getChannelContent(channel.url); if(subType != null)
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else
pager = StatePlatform.instance.getChannelContent(channel.url);
}
return pager; return pager;
} }
@ -367,6 +370,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
companion object { companion object {
val TAG = "VideoListFragment"; val TAG = "VideoListFragment";
fun newInstance() = ChannelContentsFragment().apply { } fun newInstance(subType: String? = null) = ChannelContentsFragment(subType).apply { }
} }
} }

View File

@ -25,6 +25,7 @@ import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
@ -237,8 +238,8 @@ class ChannelFragment : MainFragment() {
} }
adapter.onAddToWatchLaterClicked.subscribe { content -> adapter.onAddToWatchLaterClicked.subscribe { content ->
if (content is IPlatformVideo) { if (content is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true) if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]") UIDialogs.toast("Added to watch later\n[${content.name}]")
} }
} }
adapter.onUrlClicked.subscribe { url -> adapter.onUrlClicked.subscribe { url ->
@ -457,6 +458,12 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons) _fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
} }
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
(_viewPager.adapter as ChannelViewPagerAdapter).insert(1, ChannelTab.SHORTS);
}
}
} }
} }
@ -469,8 +476,13 @@ class ChannelFragment : MainFragment() {
R.string.subscribers R.string.subscribers
).lowercase() else "" ).lowercase() else ""
val supportsPlaylists = var supportsPlaylists = false;
StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists try {
supportsPlaylists = StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists
} catch (ex: Throwable) {
//Ignore error
Logger.e(TAG, "Failed to check if supports playlists", ex);
}
val playlistPosition = 1 val playlistPosition = 1
if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.PLAYLISTS.ordinal.toLong() ChannelTab.PLAYLISTS.ordinal.toLong()

View File

@ -82,8 +82,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}; };
adapter.onAddToWatchLaterClicked.subscribe(this) { adapter.onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) { if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true); if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]"); UIDialogs.toast("Added to watch later\n[${it.name}]");
} }
}; };
adapter.onLongPress.subscribe(this) { adapter.onLongPress.subscribe(this) {

View File

@ -17,6 +17,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -222,6 +223,12 @@ class ContentSearchResultsFragment : MainFragment() {
setSortByOptions(null); setSortByOptions(null);
} }
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
if(Settings.instance.search.hidefromSearch)
return super.filterResults(results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) });
return super.filterResults(results)
}
override fun reload() { override fun reload() {
loadResults(); loadResults();
} }

View File

@ -10,6 +10,7 @@ import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.Spinner import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -26,6 +27,7 @@ class CreatorsFragment : MainFragment() {
private var _overlayContainer: FrameLayout? = null; private var _overlayContainer: FrameLayout? = null;
private var _containerSearch: FrameLayout? = null; private var _containerSearch: FrameLayout? = null;
private var _editSearch: EditText? = null; private var _editSearch: EditText? = null;
private var _textMeta: TextView? = null;
private var _buttonClearSearch: ImageButton? = null private var _buttonClearSearch: ImageButton? = null
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -34,6 +36,7 @@ class CreatorsFragment : MainFragment() {
val editSearch: EditText = view.findViewById(R.id.edit_search); val editSearch: EditText = view.findViewById(R.id.edit_search);
val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search) val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search)
_editSearch = editSearch _editSearch = editSearch
_textMeta = view.findViewById(R.id.text_meta);
_buttonClearSearch = buttonClearSearch _buttonClearSearch = buttonClearSearch
buttonClearSearch.setOnClickListener { buttonClearSearch.setOnClickListener {
editSearch.text.clear() editSearch.text.clear()
@ -41,7 +44,11 @@ class CreatorsFragment : MainFragment() {
_buttonClearSearch?.visibility = View.INVISIBLE; _buttonClearSearch?.visibility = View.INVISIBLE;
} }
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs ->
_textMeta?.let {
it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}";
}
};
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) }; adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } } adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }

View File

@ -4,8 +4,13 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
@ -17,6 +22,7 @@ import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder
@ -25,6 +31,7 @@ import com.futo.platformplayer.views.items.PlaylistDownloadItem
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class DownloadsFragment : MainFragment() { class DownloadsFragment : MainFragment() {
private val TAG = "DownloadsFragment"; private val TAG = "DownloadsFragment";
@ -92,8 +99,12 @@ class DownloadsFragment : MainFragment() {
private val _listDownloadedHeader: LinearLayout; private val _listDownloadedHeader: LinearLayout;
private val _listDownloadedMeta: TextView; private val _listDownloadedMeta: TextView;
private val _listDownloadSearch: EditText;
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>; private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null;
private var ordering: String? = "nameAsc";
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this); inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag; _frag = frag;
@ -104,6 +115,7 @@ class DownloadsFragment : MainFragment() {
_listActiveDownloadsContainer = findViewById(R.id.downloads_active_downloads_container); _listActiveDownloadsContainer = findViewById(R.id.downloads_active_downloads_container);
_listActiveDownloadsMeta = findViewById(R.id.downloads_active_downloads_meta); _listActiveDownloadsMeta = findViewById(R.id.downloads_active_downloads_meta);
_listDownloadSearch = findViewById(R.id.downloads_search);
_listActiveDownloads = findViewById(R.id.downloads_active_downloads_list); _listActiveDownloads = findViewById(R.id.downloads_active_downloads_list);
_listPlaylistsContainer = findViewById(R.id.downloads_playlist_container); _listPlaylistsContainer = findViewById(R.id.downloads_playlist_container);
@ -113,6 +125,30 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader = findViewById(R.id.downloads_videos_header); _listDownloadedHeader = findViewById(R.id.downloads_videos_header);
_listDownloadedMeta = findViewById(R.id.downloads_videos_meta); _listDownloadedMeta = findViewById(R.id.downloads_videos_meta);
_listDownloadSearch.addTextChangedListener {
updateContentFilters();
}
val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
spinnerSortBy.setSelection(0);
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> ordering = "nameAsc"
1 -> ordering = "nameDesc"
2 -> ordering = "downloadDateAsc"
3 -> ordering = "downloadDateDesc"
4 -> ordering = "releasedAsc"
5 -> ordering = "releasedDesc"
else -> ordering = null
}
updateContentFilters()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded) _listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) { .asAnyWithTop(findViewById(R.id.downloads_top)) {
it.onClick.subscribe { it.onClick.subscribe {
@ -125,7 +161,6 @@ class DownloadsFragment : MainFragment() {
reloadUI(); reloadUI();
} }
fun reloadUI() { fun reloadUI() {
val usage = StateDownloads.instance.getTotalUsage(true); val usage = StateDownloads.instance.getTotalUsage(true);
_usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used); _usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
@ -181,10 +216,32 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader.visibility = GONE; _listDownloadedHeader.visibility = GONE;
} else { } else {
_listDownloadedHeader.visibility = VISIBLE; _listDownloadedHeader.visibility = VISIBLE;
_listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})"; _listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()}${if(downloaded.size > 0) ", ${downloaded.sumOf { it.duration }.toHumanDuration(false)}" else ""})";
} }
_listDownloaded.setData(downloaded); lastDownloads = downloaded;
_listDownloaded.setData(filterDownloads(downloaded));
}
fun updateContentFilters(){
val toFilter = lastDownloads ?: return;
_listDownloaded.setData(filterDownloads(toFilter));
}
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
var vidsToReturn = vids;
if(!_listDownloadSearch.text.isNullOrEmpty())
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) };
if(!ordering.isNullOrEmpty()) {
vidsToReturn = when(ordering){
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() }
"releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX }
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
else -> vidsToReturn
}
}
return vidsToReturn;
} }
} }
} }

View File

@ -8,6 +8,7 @@ import android.view.ViewGroup
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist 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.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@ -78,6 +79,14 @@ class PlaylistFragment : MainFragment() {
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name)); val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput); val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput);
_buttonExport.setOnClickListener {
_playlist?.let {
val context = StateApp.instance.contextOrNull ?: return@let;
if(context is IWithResultLauncher)
StateDownloads.instance.exportPlaylist(context, it.id);
}
};
_buttonDownload.visibility = View.VISIBLE; _buttonDownload.visibility = View.VISIBLE;
editPlaylistOverlay.onOK.subscribe { editPlaylistOverlay.onOK.subscribe {
val text = nameInput.text; val text = nameInput.text;
@ -146,7 +155,7 @@ class PlaylistFragment : MainFragment() {
setName(it.name); setName(it.name);
//TODO: Implement support for pagination //TODO: Implement support for pagination
setVideos(it.videos, false); setVideos(it.videos, false);
setVideoCount(it.videos.size); setMetadata(it.videos.size, it.videos.sumOf { it.duration });
setLoading(false); setLoading(false);
} }
.exception<Throwable> { .exception<Throwable> {
@ -174,8 +183,9 @@ class PlaylistFragment : MainFragment() {
if (parameter != null) { if (parameter != null) {
setName(parameter.name) setName(parameter.name)
setVideos(parameter.videos, true) setVideos(parameter.videos, true)
setVideoCount(parameter.videos.size) setMetadata(parameter.videos.size, parameter.videos.sumOf { it.duration })
setButtonDownloadVisible(true) setButtonDownloadVisible(true)
setButtonExportVisible(false)
setButtonEditVisible(true) setButtonEditVisible(true)
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
@ -187,7 +197,7 @@ class PlaylistFragment : MainFragment() {
} else { } else {
setName(null) setName(null)
setVideos(null, false) setVideos(null, false)
setVideoCount(-1) setMetadata(-1, -1);
setButtonDownloadVisible(false) setButtonDownloadVisible(false)
setButtonEditVisible(false) setButtonEditVisible(false)
} }
@ -195,7 +205,7 @@ class PlaylistFragment : MainFragment() {
_playlist = null _playlist = null
_url = parameter.url _url = parameter.url
setVideoCount(parameter.videoCount) setMetadata(parameter.videoCount, -1);
setName(parameter.name) setName(parameter.name)
setVideos(null, false) setVideos(null, false)
setButtonDownloadVisible(false) setButtonDownloadVisible(false)
@ -208,7 +218,7 @@ class PlaylistFragment : MainFragment() {
setName(null) setName(null)
setVideos(null, false) setVideos(null, false)
setVideoCount(-1) setMetadata(-1, -1);
setButtonDownloadVisible(false) setButtonDownloadVisible(false)
setButtonEditVisible(false) setButtonEditVisible(false)

View File

@ -237,7 +237,19 @@ class SourceDetailFragment : MainFragment() {
BigButtonGroup(c, context.getString(R.string.update), BigButtonGroup(c, context.getString(R.string.update),
BigButton(c, context.getString(R.string.check_for_updates), context.getString(R.string.checks_for_new_versions_of_the_source), R.drawable.ic_update) { BigButton(c, context.getString(R.string.check_for_updates), context.getString(R.string.checks_for_new_versions_of_the_source), R.drawable.ic_update) {
checkForUpdatesSource(); checkForUpdatesSource();
} },
if(config.changelog?.any() == true)
BigButton(c, context.getString(R.string.changelog), context.getString(R.string.changelog_plugin_description), R.drawable.ic_list) {
UIDialogs.showChangelogDialog(context, config.version, config.changelog!!.filterKeys { it.toIntOrNull() != null }
.mapKeys { it.key.toInt() }
.mapValues { config.getChangelogString(it.key.toString()) ?: "" });
}.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}
else
null
) )
); );

View File

@ -204,8 +204,10 @@ class SubscriptionsFeedFragment : MainFragment() {
val feed = StateSubscriptions.instance.getFeed(group?.id); val feed = StateSubscriptions.instance.getFeed(group?.id);
val currentExs = feed?.exceptions ?: listOf(); val currentExs = feed?.exceptions ?: listOf();
if(currentExs != _lastExceptions && currentExs.any()) if(currentExs != _lastExceptions && currentExs.any()) {
handleExceptions(currentExs); handleExceptions(currentExs)
feed?.exceptions = listOf()
}
return@TaskHandler resp; return@TaskHandler resp;
}) })

View File

@ -151,6 +151,7 @@ class TutorialFragment : MainFragment() {
override val rating: IRating = RatingLikes(-1) override val rating: IRating = RatingLikes(-1)
override val viewCount: Long = -1 override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height) override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
override val isShort: Boolean = false;
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> { override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager() return EmptyPager()
} }

View File

@ -11,12 +11,14 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.OrientationEventListener import android.view.OrientationEventListener
import android.view.Surface
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.view.WindowManager import android.view.WindowManager
import androidx.constraintlayout.motion.widget.MotionLayout import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.ViewCompat.getDisplay
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
@ -197,7 +199,37 @@ class VideoDetailFragment() : MainFragment() {
} else if (rotationLock) { } else if (rotationLock) {
_portraitOrientationListener?.disableListener() _portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener() _landscapeOrientationListener?.disableListener()
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED val display = getDisplay(_viewDetail!!)
val rotation = display!!.rotation
val orientation = resources.configuration.orientation
a.requestedOrientation = when (orientation) {
Configuration.ORIENTATION_PORTRAIT -> {
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) {
if (rotation == Surface.ROTATION_0) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
}
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
Configuration.ORIENTATION_LANDSCAPE -> {
if (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) {
if (rotation == Surface.ROTATION_90) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
} else {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
}
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
} else { } else {
_portraitOrientationListener?.disableListener() _portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener() _landscapeOrientationListener?.disableListener()

View File

@ -641,18 +641,9 @@ class VideoDetailView(fragment: VideoDetailFragment, inflater: LayoutInflater) :
}; };
var hadDevice = false; var hadDevice = false;
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session -> val devicesChanged = { id: String ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice(); val hasDevice = StateSync.instance.hasAuthorizedDevice();
if(hasDevice != hadDevice) { if (hasDevice != hadDevice) {
hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
}
}
};
StateSync.instance.deviceRemoved.subscribe(this) { id ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
if(hasDevice != hadDevice) {
hadDevice = hasDevice; hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons(); updateMoreButtons();
@ -660,6 +651,9 @@ class VideoDetailView(fragment: VideoDetailFragment, inflater: LayoutInflater) :
} }
} }
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, _ -> devicesChanged(id) };
StateSync.instance.deviceRemoved.subscribe(this) { id -> devicesChanged(id) };
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() }; MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() }; MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() }; MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
@ -910,22 +904,20 @@ class VideoDetailView(fragment: VideoDetailFragment, inflater: LayoutInflater) :
} }
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
} else null, } else null,
if(!isLimitedVersion) 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) {
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) { if (!allowBackground) {
if(!allowBackground) { _player.switchToAudioMode();
_player.switchToAudioMode(); allowBackground = true;
allowBackground = true; it.text.text = resources.getString(R.string.background_revert);
it.text.text = resources.getString(R.string.background_revert); } else {
} _player.switchToVideoMode();
else { allowBackground = false;
_player.switchToVideoMode(); it.text.text = resources.getString(R.string.background);
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
} }
_slideUpOverlay?.hide();
}
else null, else null,
if(!isLimitedVersion) if(!isLimitedVersion && !(video?.isLive ?: false))
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let { video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
@ -956,18 +948,25 @@ class VideoDetailView(fragment: VideoDetailFragment, inflater: LayoutInflater) :
}; };
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
}, },
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { if (StateSync.instance.hasAuthorizedDevice()) {
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) { RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
val devices = StateSync.instance.getSessions(); val devices = StateSync.instance.getAuthorizedSessions();
val videoToSend = video ?: return@RoundButton; val videoToSend = video ?: return@RoundButton;
if(devices.size > 1) { if(devices.size > 1) {
//not implemented //not implemented
} } else if(devices.size == 1){
else if(devices.size == 1){
val device = devices.first(); val device = devices.first();
Logger.i(TAG, "Send to device? (public key: ${device.remotePublicKey}): " + videoToSend.url)
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , { UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , {
Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url)
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds/1000).toInt())); try {
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds / 1000).toInt()))
Logger.i(TAG, "Send to device packet sent (public key: ${device.remotePublicKey}): " + videoToSend.url)
} catch (e: Throwable) {
Logger.e(TAG, "Send to device packet failed to send", e)
}
} }
}) })
} }
@ -1930,6 +1929,39 @@ class VideoDetailView(fragment: VideoDetailFragment, inflater: LayoutInflater) :
_overlay_quality_selector?.selectOption("video", _lastVideoSource); _overlay_quality_selector?.selectOption("video", _lastVideoSource);
_overlay_quality_selector?.selectOption("audio", _lastAudioSource); _overlay_quality_selector?.selectOption("audio", _lastAudioSource);
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource); _overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) {
val videoTracks =
_player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
var selectedQuality: Format? = null
if (videoTracks != null) {
for (i in 0 until videoTracks.mediaTrackGroup.length) {
if (videoTracks.mediaTrackGroup.getFormat(i).height == _player.targetTrackVideoHeight) {
selectedQuality = videoTracks.mediaTrackGroup.getFormat(i)
}
}
}
var videoMenuGroup: SlideUpMenuGroup? = null
for (view in _overlay_quality_selector!!.groupItems) {
if (view is SlideUpMenuGroup && view.groupTag == "video") {
videoMenuGroup = view
}
}
if (selectedQuality != null) {
videoMenuGroup?.getItem("auto")?.setSubText("")
_overlay_quality_selector?.selectOption("video", selectedQuality)
} else {
videoMenuGroup?.getItem("auto")
?.setSubText("${_player.exoPlayer?.player?.videoFormat?.width}x${_player.exoPlayer?.player?.videoFormat?.height}")
_overlay_quality_selector?.selectOption("video", "auto")
}
}
val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0 val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0
_overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let { _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let {
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
@ -2103,17 +2135,15 @@ class VideoDetailView(fragment: VideoDetailFragment, inflater: LayoutInflater) :
call = { handleSelectSubtitleTrack(it) }) call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(liveStreamVideoFormats?.isEmpty() == false) if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video", this.context, context.getString(R.string.stream_video), "video", (listOf(
*liveStreamVideoFormats SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { _player.selectVideoTrack(-1) })
.map { ) + (liveStreamVideoFormats.map {
SlideUpMenuItem(this.context, SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label
R.drawable.ic_movie, ?: it.containerMimeType
it.label ?: it.containerMimeType ?: it.bitrate.toString(), ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { _player.selectVideoTrack(it.height) });
"${it.width}x${it.height}", }))
tag = it, )
call = { _player.selectVideoTrack(it.height) });
}.toList().toTypedArray())
else null, else null,
if(liveStreamAudioFormats?.isEmpty() == false) if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio", SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
@ -2586,8 +2616,13 @@ class VideoDetailView(fragment: VideoDetailFragment, inflater: LayoutInflater) :
onAddToWatchLaterClicked.subscribe(this) { onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) { if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true); if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]"); UIDialogs.toast("Added to watch later\n[${it.name}]");
}
}
onAddToQueueClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlayer.instance.addToQueue(it);
} }
} }
}) })

View File

@ -20,6 +20,8 @@ import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
abstract class VideoListEditorView : LinearLayout { abstract class VideoListEditorView : LinearLayout {
@ -32,6 +34,7 @@ abstract class VideoListEditorView : LinearLayout {
protected var overlayContainer: FrameLayout protected var overlayContainer: FrameLayout
private set; private set;
protected var _buttonDownload: ImageButton; protected var _buttonDownload: ImageButton;
protected var _buttonExport: ImageButton;
private var _buttonShare: ImageButton; private var _buttonShare: ImageButton;
private var _buttonEdit: ImageButton; private var _buttonEdit: ImageButton;
@ -52,6 +55,8 @@ abstract class VideoListEditorView : LinearLayout {
_buttonEdit = findViewById(R.id.button_edit); _buttonEdit = findViewById(R.id.button_edit);
_buttonDownload = findViewById(R.id.button_download); _buttonDownload = findViewById(R.id.button_download);
_buttonDownload.visibility = View.GONE; _buttonDownload.visibility = View.GONE;
_buttonExport = findViewById(R.id.button_export);
_buttonExport.visibility = View.GONE;
_buttonShare = findViewById(R.id.button_share); _buttonShare = findViewById(R.id.button_share);
val onShare = _onShare; val onShare = _onShare;
@ -66,6 +71,7 @@ abstract class VideoListEditorView : LinearLayout {
buttonShuffle.setOnClickListener { onShuffleClick(); }; buttonShuffle.setOnClickListener { onShuffleClick(); };
_buttonEdit.setOnClickListener { onEditClick(); }; _buttonEdit.setOnClickListener { onEditClick(); };
setButtonExportVisible(false);
setButtonDownloadVisible(canEdit()); setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
@ -106,6 +112,7 @@ abstract class VideoListEditorView : LinearLayout {
_buttonDownload.setBackgroundResource(R.drawable.background_button_round); _buttonDownload.setBackgroundResource(R.drawable.background_button_round);
if(isDownloading) { if(isDownloading) {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() }; _buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener {
@ -115,6 +122,7 @@ abstract class VideoListEditorView : LinearLayout {
} }
} }
else if(isDownloaded) { else if(isDownloaded) {
setButtonExportVisible(true)
_buttonDownload.setImageResource(R.drawable.ic_download_off); _buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
@ -123,6 +131,7 @@ abstract class VideoListEditorView : LinearLayout {
} }
} }
else { else {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_download); _buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener {
onDownload(); onDownload();
@ -136,8 +145,14 @@ abstract class VideoListEditorView : LinearLayout {
_textName.text = name ?: ""; _textName.text = name ?: "";
} }
protected fun setVideoCount(videoCount: Int = -1) { protected fun setMetadata(videoCount: Int = -1, duration: Long = -1) {
_textMetadata.text = if (videoCount == -1) "" else "${videoCount} " + context.getString(R.string.videos); val parts = mutableListOf<String>()
if(videoCount >= 0)
parts.add("${videoCount} " + context.getString(R.string.videos));
if(duration > 0)
parts.add("${duration.toHumanDuration(false)} ");
_textMetadata.text = parts.joinToString("");
} }
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) { protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
@ -163,6 +178,9 @@ abstract class VideoListEditorView : LinearLayout {
protected fun setButtonDownloadVisible(isVisible: Boolean) { protected fun setButtonDownloadVisible(isVisible: Boolean) {
_buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE;
} }
protected fun setButtonExportVisible(isVisible: Boolean) {
_buttonExport.visibility = if (isVisible) View.VISIBLE else View.GONE;
}
protected fun setButtonEditVisible(isVisible: Boolean) { protected fun setButtonEditVisible(isVisible: Boolean) {
_buttonEdit.visibility = if (isVisible) View.VISIBLE else View.GONE; _buttonEdit.visibility = if (isVisible) View.VISIBLE else View.GONE;

View File

@ -13,16 +13,15 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language import com.futo.platformplayer.others.Language
@ -47,8 +46,8 @@ class VideoHelper {
return false return false
} }
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource; fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IAudioUrlWidevineSource fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? { fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {

View File

@ -1,6 +1,7 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import com.futo.platformplayer.logging.Logger
import kotlin.streams.asSequence import kotlin.streams.asSequence
/*** /***
@ -45,10 +46,16 @@ class StateAssets {
var text: String?; var text: String?;
synchronized(_cache) { synchronized(_cache) {
if (!_cache.containsKey(path)) { if (!_cache.containsKey(path)) {
text = context.assets try {
?.open(path) text = context.assets
?.bufferedReader() ?.open(path)
?.use { it.readText(); }; ?.bufferedReader()
?.use { it.readText(); };
}
catch(ex: Throwable) {
Logger.e("StateAssets", "Could not open asset: " + path, ex);
return null;
}
_cache.put(path, text); _cache.put(path, text);
} else { } else {

View File

@ -3,9 +3,11 @@ package com.futo.platformplayer.states
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
@ -46,6 +48,17 @@ class StateDownloads {
private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath); private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath);
private val _downloaded = FragmentedStorage.storeJson<VideoLocal>("downloaded") private val _downloaded = FragmentedStorage.storeJson<VideoLocal>("downloaded")
.withOnModified({
synchronized(_downloadedSet) {
if(!_downloadedSet.contains(it.id))
_downloadedSet.add(it.id);
}
}, {
synchronized(_downloadedSet) {
if(_downloadedSet.contains(it.id))
_downloadedSet.remove(it.id);
}
})
.load() .load()
.apply { afterLoadingDownloaded(this) }; .apply { afterLoadingDownloaded(this) };
private val _downloading = FragmentedStorage.storeJson<VideoDownload>("downloading") private val _downloading = FragmentedStorage.storeJson<VideoDownload>("downloading")
@ -85,9 +98,6 @@ class StateDownloads {
Logger.i("StateDownloads", "Deleting local video ${id.value}"); Logger.i("StateDownloads", "Deleting local video ${id.value}");
val downloaded = getCachedVideo(id); val downloaded = getCachedVideo(id);
if(downloaded != null) { if(downloaded != null) {
synchronized(_downloadedSet) {
_downloadedSet.remove(id);
}
_downloaded.delete(downloaded); _downloaded.delete(downloaded);
} }
onDownloadedChanged.emit(); onDownloadedChanged.emit();
@ -261,9 +271,6 @@ class StateDownloads {
if(existing.groupID == null) { if(existing.groupID == null) {
existing.groupID = VideoDownload.GROUP_WATCHLATER; existing.groupID = VideoDownload.GROUP_WATCHLATER;
existing.groupType = VideoDownload.GROUP_WATCHLATER; existing.groupType = VideoDownload.GROUP_WATCHLATER;
synchronized(_downloadedSet) {
_downloadedSet.add(existing.id);
}
_downloaded.save(existing); _downloaded.save(existing);
} }
} }
@ -306,9 +313,6 @@ class StateDownloads {
if(existing.groupID == null) { if(existing.groupID == null) {
existing.groupID = playlist.id; existing.groupID = playlist.id;
existing.groupType = VideoDownload.GROUP_PLAYLIST; existing.groupType = VideoDownload.GROUP_PLAYLIST;
synchronized(_downloadedSet) {
_downloadedSet.add(existing.id);
}
_downloaded.save(existing); _downloaded.save(existing);
} }
} }
@ -466,6 +470,65 @@ class StateDownloads {
return _downloadsDirectory; return _downloadsDirectory;
} }
fun exportPlaylist(context: Context, playlistId: String) {
if(context is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(context, "Export Playlist", "To export playlist to directory", null) {
if (it == null)
return@requestDirectoryAccess;
val root = DocumentFile.fromTreeUri(context, it!!);
val playlist = StatePlaylists.instance.getPlaylist(playlistId);
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
if(playlist != null) {
val missing = playlist.videos
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) }
.filterNotNull();
if(missing.size > 0)
localVideos = localVideos + missing;
};
var lastNotifyTime = -1L;
UIDialogs.showDialogProgress(context) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
it.setText("Exporting videos..");
var i = 0;
var success = 0;
for (video in localVideos) {
withContext(Dispatchers.Main) {
it.setText("Exporting videos...(${i}/${localVideos.size})");
//it.setProgress(i.toDouble() / localVideos.size);
}
try {
val export = VideoExport(video, video.videoSource.firstOrNull(), video.audioSource.firstOrNull(), video.subtitlesSources.firstOrNull());
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
val file = export.export(context, { progress ->
val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress);
lastNotifyTime = now;
}
}, root);
success++;
} catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${video.name}]: ${ex.message}", ex);
}
i++;
}
withContext(Dispatchers.Main) {
it.setProgress(1f);
it.dismiss();
UIDialogs.appToast("Finished exporting playlist (${success} videos${if(i < success) ", ${i} errors" else ""})");
}
};
}
}
}
fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) {
var lastNotifyTime = -1L; var lastNotifyTime = -1L;
@ -477,13 +540,13 @@ class StateDownloads {
try { try {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
val file = export.export(context) { progress -> val file = export.export(context, { progress ->
val now = System.currentTimeMillis(); val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) { if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress); it.setProgress(progress);
lastNotifyTime = now; lastNotifyTime = now;
} }
} }, null);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
it.setProgress(100.0f) it.setProgress(100.0f)

View File

@ -8,6 +8,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StatePlaylists.Companion
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.db.types.DBHistory
@ -89,12 +90,14 @@ class StateHistory {
if(isUserAction && _lastHistoryBroadcast != historyBroadcastSig) { if(isUserAction && _lastHistoryBroadcast != historyBroadcastSig) {
_lastHistoryBroadcast = historyBroadcastSig; _lastHistoryBroadcast = historyBroadcastSig;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})"); Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncHistory, GJSyncOpcodes.syncHistory,
listOf(historyVideo) listOf(historyVideo)
); );
} catch (e: Throwable) {
Logger.e(StatePlaylists.TAG, "Failed to broadcast sync history", e)
} }
}; };
} }

View File

@ -13,6 +13,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@ -130,6 +131,12 @@ class StatePlayer {
closeMediaSession(); closeMediaSession();
} }
fun saveQueueAsPlaylist(name: String){
val videos = _queue.toList();
val playlist = Playlist(name, videos.map { SerializedPlatformVideo.fromVideo(it) });
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
}
//Notifications //Notifications
fun hasMediaSession() : Boolean { fun hasMediaSession() : Boolean {
return MediaPlaybackService.getService() != null; return MediaPlaybackService.getService() != null;

View File

@ -5,6 +5,7 @@ import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@ -176,8 +177,11 @@ class StatePlaylists {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
} }
} }
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1) { fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1): Boolean {
var wasNew = false;
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
if(!_watchlistStore.hasItem { it.url == video.url })
wasNew = true;
_watchlistStore.saveAsync(video); _watchlistStore.saveAsync(video);
if(orderPosition == -1) if(orderPosition == -1)
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray()); _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
@ -197,6 +201,7 @@ class StatePlaylists {
} }
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
return wasNew;
} }
fun getLastPlayedPlaylist() : Playlist? { fun getLastPlayedPlaylist() : Playlist? {
@ -227,31 +232,50 @@ class StatePlaylists {
private fun broadcastWatchLater(orderOnly: Boolean = false) { private fun broadcastWatchLater(orderOnly: Boolean = false) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( try {
if(orderOnly) listOf() else getWatchLater(), StateSync.instance.broadcastJsonData(
if(orderOnly) mapOf() else _watchLaterAdds.all(), GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
if(orderOnly) mapOf() else _watchLaterRemovals.all(), if (orderOnly) listOf() else getWatchLater(),
getWatchLaterLastReorderTime().toEpochSecond(), if (orderOnly) mapOf() else _watchLaterAdds.all(),
_watchlistOrderStore.values.toList())); if (orderOnly) mapOf() else _watchLaterRemovals.all(),
getWatchLaterLastReorderTime().toEpochSecond(),
_watchlistOrderStore.values.toList()
)
);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to broadcast watch later", e)
}
}; };
} }
private fun broadcastWatchLaterAddition(video: SerializedPlatformVideo, time: OffsetDateTime) { private fun broadcastWatchLaterAddition(video: SerializedPlatformVideo, time: OffsetDateTime) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( try {
listOf(video), StateSync.instance.broadcastJsonData(
mapOf(Pair(video.url, time.toEpochSecond())), GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
mapOf(), listOf(video),
mapOf(Pair(video.url, time.toEpochSecond())),
mapOf(),
)) )
)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to broadcast watch later addition", e)
}
}; };
} }
private fun broadcastWatchLaterRemoval(url: String, time: OffsetDateTime) { private fun broadcastWatchLaterRemoval(url: String, time: OffsetDateTime) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( try {
listOf(), StateSync.instance.broadcastJsonData(
mapOf(), GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
mapOf(Pair(url, time.toEpochSecond())) listOf(),
)) mapOf(),
mapOf(Pair(url, time.toEpochSecond()))
)
)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to broadcast watch later removal", e)
}
}; };
} }
@ -287,25 +311,32 @@ class StatePlaylists {
broadcastSyncPlaylist(playlist); broadcastSyncPlaylist(playlist);
} }
} }
fun addToPlaylist(id: String, video: IPlatformVideo) { fun addToPlaylist(id: String, video: IPlatformVideo): Boolean {
synchronized(playlistStore) { synchronized(playlistStore) {
val playlist = getPlaylist(id) ?: return; val playlist = getPlaylist(id) ?: return false;
if(!Settings.instance.other.playlistAllowDups && playlist.videos.any { it.url == video.url })
return false;
playlist.videos.add(SerializedPlatformVideo.fromVideo(video)); playlist.videos.add(SerializedPlatformVideo.fromVideo(video));
playlist.dateUpdate = OffsetDateTime.now(); playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true); playlistStore.saveAsync(playlist, true);
broadcastSyncPlaylist(playlist); broadcastSyncPlaylist(playlist);
return true;
} }
} }
private fun broadcastSyncPlaylist(playlist: Playlist){ private fun broadcastSyncPlaylist(playlist: Playlist){
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists, GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(playlist), mapOf()) SyncPlaylistsPackage(listOf(playlist), mapOf())
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to broadcast sync playlist", e)
} }
}; };
} }
@ -319,12 +350,14 @@ class StatePlaylists {
_playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now()); _playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists, GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond()))) SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond())))
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to broadcast sync playlists", e)
} }
}; };
} }

View File

@ -2,6 +2,7 @@ package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.LoginActivity import com.futo.platformplayer.activities.LoginActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@ -101,6 +102,8 @@ class StatePlugins {
if (availableClient !is JSClient) { if (availableClient !is JSClient) {
continue continue
} }
if(!Settings.instance.plugins.checkDisabledPluginsForUpdates && !StatePlatform.instance.isClientEnabled(availableClient.id))
continue;
val newConfig = checkForUpdates(availableClient.config); val newConfig = checkForUpdates(availableClient.config);
if (newConfig != null) { if (newConfig != null) {

View File

@ -79,12 +79,14 @@ class StateSubscriptionGroups {
onGroupsChanged.emit(); onGroupsChanged.emit();
if(!preventSync) { if(!preventSync) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})"); Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups, GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf()) SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf())
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to broadcast update subscription group", e)
} }
}; };
} }
@ -98,12 +100,14 @@ class StateSubscriptionGroups {
if(isUserInteraction) { if(isUserInteraction) {
_groupsRemoved.setAndSave(id, OffsetDateTime.now()); _groupsRemoved.setAndSave(id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})"); Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups, GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond()))) SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond())))
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to delete subscription group", e)
} }
}; };
} }

View File

@ -65,6 +65,12 @@ class StateSync {
val deviceRemoved: Event1<String> = Event1() val deviceRemoved: Event1<String> = Event1()
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2() val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun hasAuthorizedDevice(): Boolean {
synchronized(_sessions) {
return _sessions.any{ it.value.connected && it.value.isAuthorized };
}
}
fun start() { fun start() {
if (_started) { if (_started) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
@ -216,6 +222,11 @@ class StateSync {
return _sessions.values.toList() return _sessions.values.toList()
}; };
} }
fun getAuthorizedSessions(): List<SyncSession> {
return synchronized(_sessions) {
return _sessions.values.filter { it.isAuthorized }.toList()
};
}
fun getSyncSessionData(key: String): SyncSessionData { fun getSyncSessionData(key: String): SyncSessionData {
return _syncSessionData.get(key) ?: SyncSessionData(key); return _syncSessionData.get(key) ?: SyncSessionData(key);
@ -349,8 +360,12 @@ class StateSync {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = { UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
session!!.authorize(s) try {
Logger.i(TAG, "Connection authorized for ${remotePublicKey} by confirmation") session!!.authorize(s)
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize", e)
}
} }
}, cancelAction = { }, cancelAction = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@ -404,11 +419,9 @@ class StateSync {
broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
} }
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) { fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) {
for(session in getSessions()) { for(session in getAuthorizedSessions()) {
try { try {
if (session.isAuthorized && session.connected) { session.send(opcode, subOpcode, data);
session.send(opcode, subOpcode, data);
}
} }
catch(ex: Exception) { catch(ex: Exception) {
Logger.w(TAG, "Failed to broadcast (opcode = ${opcode}, subOpcode = ${subOpcode}) to ${session.remotePublicKey}: ${ex.message}}", ex); Logger.w(TAG, "Failed to broadcast (opcode = ${opcode}, subOpcode = ${subOpcode}) to ${session.remotePublicKey}: ${ex.message}}", ex);
@ -450,17 +463,6 @@ class StateSync {
return session return session
} }
fun hasAtLeastOneDevice(): Boolean {
synchronized(_authorizedDevices) {
return _authorizedDevices.values.isNotEmpty()
}
}
fun hasAtLeastOneOnlineDevice(): Boolean {
synchronized(_sessions) {
return _sessions.any{ it.value.connected && it.value.isAuthorized };
}
}
fun getAll(): List<String> { fun getAll(): List<String> {
synchronized(_authorizedDevices) { synchronized(_authorizedDevices) {
return _authorizedDevices.values.toList() return _authorizedDevices.values.toList()

View File

@ -189,9 +189,9 @@ class StateUpdate {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e); Logger.w(TAG, "Failed to check for updates.", e);
android.util.Log.e(TAG, "Failed to check for updates.", e);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates"); UIDialogs.toast(context, "Failed to check for updates\n" + e.message);
} }
} }
} }

View File

@ -33,6 +33,9 @@ class ManagedStore<T>{
val className: String? get() = _class.classifier?.assume<KClass<*>>()?.simpleName; val className: String? get() = _class.classifier?.assume<KClass<*>>()?.simpleName;
private var _onModificationCreate: ((T) -> Unit)? = null;
private var _onModificationDelete: ((T) -> Unit)? = null;
val name: String; val name: String;
constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer<T>, niceName: String? = null) { constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer<T>, niceName: String? = null) {
@ -62,6 +65,12 @@ class ManagedStore<T>{
return this; return this;
} }
fun withOnModified(created: (T)->Unit, deleted: (T)->Unit): ManagedStore<T> {
_onModificationCreate = created;
_onModificationDelete = deleted;
return this;
}
fun load(): ManagedStore<T> { fun load(): ManagedStore<T> {
synchronized(_files) { synchronized(_files) {
_files.clear(); _files.clear();
@ -265,6 +274,7 @@ class ManagedStore<T>{
file = saveNew(obj); file = saveNew(obj);
if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction)) if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction))
saveReconstruction(file, obj); saveReconstruction(file, obj);
_onModificationCreate?.invoke(obj)
} }
} }
} }
@ -300,6 +310,7 @@ class ManagedStore<T>{
_files.remove(item); _files.remove(item);
Logger.v(TAG, "Deleting file ${logName(file.id)}"); Logger.v(TAG, "Deleting file ${logName(file.id)}");
file.delete(); file.delete();
_onModificationDelete?.invoke(item)
} }
} }
} }

View File

@ -398,7 +398,6 @@ class SyncSession : IAuthorizable {
} }
} }
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) { inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data)); send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data));
} }
@ -409,12 +408,29 @@ class SyncSession : IAuthorizable {
send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
} }
fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) { fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) {
val sock = _socketSessions.firstOrNull(); val socketSessions = synchronized(_socketSessions) {
if(sock != null){ _socketSessions.toList()
sock.send(opcode, subOpcode, ByteBuffer.wrap(data)); }
if (socketSessions.isEmpty()) {
Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets")
return
}
var sent = false
for (socketSession in socketSessions) {
try {
socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data))
sent = true
break
} catch (e: Throwable) {
Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e)
}
}
if (!sent) {
throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates")
} }
else
throw IllegalStateException("Session has no active sockets");
} }
private companion object { private companion object {

View File

@ -300,6 +300,8 @@ class SyncSocketSession {
} }
private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
when (opcode) { when (opcode) {
Opcode.PING.value -> { Opcode.PING.value -> {
send(Opcode.PONG.value) send(Opcode.PONG.value)

View File

@ -55,7 +55,7 @@ class ToastView : LinearLayout {
translationY = 20.dp(context.resources).toFloat(); translationY = 20.dp(context.resources).toFloat();
animate() animate()
.alpha(1f) .alpha(1f)
.setDuration(700) .setDuration(300)
.translationY(0f) .translationY(0f)
.start(); .start();
} }

View File

@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel 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.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@ -21,7 +22,7 @@ import com.google.android.material.tabs.TabLayout
enum class ChannelTab { enum class ChannelTab {
VIDEOS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT VIDEOS, SHORTS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT
} }
class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
@ -91,6 +92,19 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
} }
} }
ChannelTab.SHORTS -> {
fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit)
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit)
onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit)
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit)
}
}
ChannelTab.CHANNELS -> { ChannelTab.CHANNELS -> {
fragment = ChannelListFragment.newInstance() fragment = ChannelListFragment.newInstance()
.apply { onClickChannel.subscribe(onChannelClicked::emit) } .apply { onClickChannel.subscribe(onChannelClicked::emit) }

View File

@ -16,6 +16,7 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
private lateinit var _sortedDataset: List<Subscription>; private lateinit var _sortedDataset: List<Subscription>;
private val _inflater: LayoutInflater; private val _inflater: LayoutInflater;
private val _confirmationMessage: String; private val _confirmationMessage: String;
private val _onDatasetChanged: ((List<Subscription>)->Unit)?;
var onClick = Event1<Subscription>(); var onClick = Event1<Subscription>();
var onSettings = Event1<Subscription>(); var onSettings = Event1<Subscription>();
@ -30,9 +31,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
updateDataset(); updateDataset();
} }
constructor(inflater: LayoutInflater, confirmationMessage: String) : super() { constructor(inflater: LayoutInflater, confirmationMessage: String, onDatasetChanged: ((List<Subscription>)->Unit)? = null) : super() {
_inflater = inflater; _inflater = inflater;
_confirmationMessage = confirmationMessage; _confirmationMessage = confirmationMessage;
_onDatasetChanged = onDatasetChanged;
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() }
@ -78,6 +80,8 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
.filter { (queryLower.isNullOrBlank() || it.channel.name.lowercase().contains(queryLower)) } .filter { (queryLower.isNullOrBlank() || it.channel.name.lowercase().contains(queryLower)) }
.toList(); .toList();
_onDatasetChanged?.invoke(_sortedDataset);
notifyDataSetChanged(); notifyDataSetChanged();
} }

View File

@ -132,7 +132,7 @@ open class PreviewVideoView : LinearLayout {
fun hideAddTo() { fun hideAddTo() {
_button_add_to.visibility = View.GONE _button_add_to.visibility = View.GONE
_button_add_to_queue.visibility = View.GONE //_button_add_to_queue.visibility = View.GONE
} }
protected open fun inflate(feedStyle: FeedStyle) { protected open fun inflate(feedStyle: FeedStyle) {

View File

@ -761,10 +761,6 @@ class GestureControlView : LinearLayout {
} }
} }
if (!Settings.instance.gestureControls.useSystemBrightness) {
_brightnessFactor = 1.0f;
}
if (Settings.instance.gestureControls.useSystemVolume) { if (Settings.instance.gestureControls.useSystemVolume) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)

View File

@ -2,16 +2,26 @@ package com.futo.platformplayer.views.overlays
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
class QueueEditorOverlay : LinearLayout { class QueueEditorOverlay : LinearLayout {
private val _topbar : OverlayTopbar; private val _topbar : OverlayTopbar;
private val _editor : VideoListEditorView; private val _editor : VideoListEditorView;
private val _btnSettings: ImageView;
private val _overlayContainer: FrameLayout;
val onClose = Event0(); val onClose = Event0();
@ -19,6 +29,9 @@ class QueueEditorOverlay : LinearLayout {
inflate(context, R.layout.overlay_queue, this) inflate(context, R.layout.overlay_queue, this)
_topbar = findViewById(R.id.topbar); _topbar = findViewById(R.id.topbar);
_editor = findViewById(R.id.editor); _editor = findViewById(R.id.editor);
_btnSettings = findViewById(R.id.button_settings);
_overlayContainer = findViewById(R.id.overlay_container_queue);
_topbar.onClose.subscribe(this, onClose::emit); _topbar.onClose.subscribe(this, onClose::emit);
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
@ -28,6 +41,10 @@ class QueueEditorOverlay : LinearLayout {
} }
_editor.onVideoClicked.subscribe { v -> StatePlayer.instance.setQueuePosition(v) } _editor.onVideoClicked.subscribe { v -> StatePlayer.instance.setQueuePosition(v) }
_btnSettings.setOnClickListener {
handleSettings();
}
_topbar.setInfo(context.getString(R.string.queue), ""); _topbar.setInfo(context.getString(R.string.queue), "");
} }
@ -40,4 +57,8 @@ class QueueEditorOverlay : LinearLayout {
fun cleanup() { fun cleanup() {
_topbar.onClose.remove(this); _topbar.onClose.remove(this);
} }
fun handleSettings() {
UISlideOverlays.showQueueOptionsOverlay(context, _overlayContainer);
}
} }

View File

@ -61,6 +61,15 @@ class SlideUpMenuGroup : LinearLayout {
return didSelect; return didSelect;
} }
fun getItem(tag: Any?): SlideUpMenuItem? {
for(item in items) {
if(item.itemTag == tag){
return item
}
}
return null
}
private fun addItems(items: List<SlideUpMenuItem>) { private fun addItems(items: List<SlideUpMenuItem>) {
for (item in items) { for (item in items) {
item.setParentClickListener { parentClickListener?.invoke() } item.setParentClickListener { parentClickListener?.invoke() }

View File

@ -82,6 +82,10 @@ class SlideUpMenuItem : ConstraintLayout {
return isSelected; return isSelected;
} }
fun setSubText(subText: String) {
_subtext.text = subText
}
fun setParentClickListener(listener: (()->Unit)?) { fun setParentClickListener(listener: (()->Unit)?) {
_parentClickListener = listener; _parentClickListener = listener;
} }

View File

@ -110,8 +110,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private var _didCallSourceChange = false; private var _didCallSourceChange = false;
private var _lastState: Int = -1; private var _lastState: Int = -1;
private var _targetTrackVideoHeight = -1;
private var _targetTrackAudioBitrate = -1; var targetTrackVideoHeight = -1
private set
private var _targetTrackAudioBitrate = -1
private var _toResume = false; private var _toResume = false;
@ -278,7 +280,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
//TODO: Temporary solution, Implement custom track selector without using constraints //TODO: Temporary solution, Implement custom track selector without using constraints
fun selectVideoTrack(height: Int) { fun selectVideoTrack(height: Int) {
_targetTrackVideoHeight = height; targetTrackVideoHeight = height;
updateTrackSelector(); updateTrackSelector();
} }
fun selectAudioTrack(bitrate: Int) { fun selectAudioTrack(bitrate: Int) {
@ -288,16 +290,22 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun updateTrackSelector() { private fun updateTrackSelector() {
var builder = DefaultTrackSelector.Parameters.Builder(context); var builder = DefaultTrackSelector.Parameters.Builder(context);
if(_targetTrackVideoHeight > 0) { if(targetTrackVideoHeight > 0) {
builder = builder builder = builder
.setMinVideoSize(0, _targetTrackVideoHeight - 10) .setMinVideoSize(0, targetTrackVideoHeight - 10)
.setMaxVideoSize(9999, _targetTrackVideoHeight + 10); .setMaxVideoSize(9999, targetTrackVideoHeight + 10);
} }
if(_targetTrackAudioBitrate > 0) { if(_targetTrackAudioBitrate > 0) {
builder = builder.setMaxAudioBitrate(_targetTrackAudioBitrate); builder = builder.setMaxAudioBitrate(_targetTrackAudioBitrate);
} }
builder = if (isAudioMode) {
builder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
} else {
builder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false)
}
val trackSelector = exoPlayer?.player?.trackSelector; val trackSelector = exoPlayer?.player?.trackSelector;
if(trackSelector != null) { if(trackSelector != null) {
trackSelector.parameters = builder.build(); trackSelector.parameters = builder.build();
@ -737,6 +745,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val sourceAudio = _lastAudioMediaSource; val sourceAudio = _lastAudioMediaSource;
val sourceSubs = _lastSubtitleMediaSource; val sourceSubs = _lastSubtitleMediaSource;
updateTrackSelector()
beforeSourceChanged(); beforeSourceChanged();

View File

@ -93,6 +93,26 @@
android:layout_marginStart="30dp" android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" /> android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/text_changelog"
android:layout_width="match_parent"
android:layout_height="80dp"
android:textAlignment="textStart"
android:text="Changelog"
android:textSize="9.5sp"
android:textColor="@color/white"
android:fontFamily="monospace"
android:scrollbars="vertical"
android:background="@color/black"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:layout_marginTop="10dp"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -16,7 +16,7 @@
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="100dp" android:layout_height="110dp"
android:minHeight="0dp" android:minHeight="0dp"
app:layout_scrollFlags="scroll" app:layout_scrollFlags="scroll"
app:contentInsetStart="0dp" app:contentInsetStart="0dp"
@ -77,7 +77,16 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="20dp" android:paddingStart="20dp"
android:paddingEnd="20dp" /> android:paddingEnd="20dp" />
</LinearLayout> </LinearLayout>
<TextView
android:id="@+id/text_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="9dp"
android:textAlignment="center"
android:textColor="#333333"
android:text="0 creators" />
</LinearLayout> </LinearLayout>
</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>

View File

@ -99,7 +99,6 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<!--Playlists--> <!--Playlists-->
<LinearLayout <LinearLayout
android:id="@+id/downloads_playlist_container" android:id="@+id/downloads_playlist_container"
@ -163,6 +162,37 @@
android:textColor="#ACACAC" android:textColor="#ACACAC"
tools:text="(12 videos)" /> tools:text="(12 videos)" />
</LinearLayout> </LinearLayout>
<EditText
android:id="@+id/downloads_search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/background_button_round"
android:hint="Seach.." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/gray_ac"
android:fontFamily="@font/inter_light"
android:text="@string/sort_by"
android:paddingStart="20dp" />
<Spinner
android:id="@+id/spinner_sortby"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView

View File

@ -54,6 +54,22 @@
<ImageButton
android:id="@+id/button_export"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_download"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="10dp"
android:orientation="horizontal"
app:srcCompat="@drawable/ic_export"
app:layout_constraintRight_toLeftOf="@id/button_share"
app:layout_constraintTop_toTopOf="@id/button_share"
app:tint="@color/white"
android:padding="8dp"
android:layout_marginRight="10dp"
android:scaleType="fitCenter" />
<ImageButton <ImageButton
android:id="@+id/button_share" android:id="@+id/button_share"
android:layout_width="40dp" android:layout_width="40dp"

View File

@ -21,5 +21,20 @@
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/topbar" app:layout_constraintTop_toBottomOf="@id/topbar"
app:layout_constraintBottom_toBottomOf="parent" /> app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/button_settings"
android:background="@drawable/background_pill"
android:padding="5dp"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:srcCompat="@drawable/ic_settings" />
<FrameLayout
android:id="@+id/overlay_container_queue"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -13,6 +13,8 @@
<string name="home">Home</string> <string name="home">Home</string>
<string name="progress_bar">Progress Bar</string> <string name="progress_bar">Progress Bar</string>
<string name="progress_bar_description">If a historical progress bar should be shown</string> <string name="progress_bar_description">If a historical progress bar should be shown</string>
<string name="hide_hidden_from_search">Hide hidden from home in search</string>
<string name="hide_hidden_from_search_description">Hide videos and creators hidden from home also in search results</string>
<string name="recommendations">Recommendations</string> <string name="recommendations">Recommendations</string>
<string name="more">More</string> <string name="more">More</string>
<string name="playlists">Playlists</string> <string name="playlists">Playlists</string>
@ -195,6 +197,7 @@
<string name="connected_to">Connected to</string> <string name="connected_to">Connected to</string>
<string name="volume">Volume</string> <string name="volume">Volume</string>
<string name="changelog">Changelog</string> <string name="changelog">Changelog</string>
<string name="changelog_plugin_description">Shows available changelogs for current and past versions</string>
<string name="some_example_changelog">Some example changelog.</string> <string name="some_example_changelog">Some example changelog.</string>
<string name="previous">Previous</string> <string name="previous">Previous</string>
<string name="next">Next</string> <string name="next">Next</string>
@ -283,6 +286,8 @@
<string name="also_removes_any_data_related_plugin_like_login_or_settings">Also removes any data related plugin like login or settings</string> <string name="also_removes_any_data_related_plugin_like_login_or_settings">Also removes any data related plugin like login or settings</string>
<string name="announcement">Announcement</string> <string name="announcement">Announcement</string>
<string name="notifications">Notifications</string> <string name="notifications">Notifications</string>
<string name="check_disabled_plugin_updates">Check disabled plugins for updates</string>
<string name="check_disabled_plugin_updates_description">Check disabled plugins for updates</string>
<string name="planned_content_notifications">Planned Content Notifications</string> <string name="planned_content_notifications">Planned Content Notifications</string>
<string name="planned_content_notifications_description">Schedules discovered planned content as notifications, resulting in more accurate notifications for this content.</string> <string name="planned_content_notifications_description">Schedules discovered planned content as notifications, resulting in more accurate notifications for this content.</string>
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string> <string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
@ -398,9 +403,9 @@
<string name="prefer_webm_audio_description">If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility.</string> <string name="prefer_webm_audio_description">If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility.</string>
<string name="allow_under_cutout">Allow video under cutout</string> <string name="allow_under_cutout">Allow video under cutout</string>
<string name="allow_under_cutout_description">Allow video to go underneath the screen cutout in full screen.\nMay require restart</string> <string name="allow_under_cutout_description">Allow video to go underneath the screen cutout in full screen.\nMay require restart</string>
<string name="autoplay">Enable autoplay by default</string> <string name="autoplay">Autoplay next video by default</string>
<string name="autoplay_description">Autoplay will be enabled by default whenever you watch a video</string> <string name="autoplay_description">Autoplay next video will be enabled by default whenever you watch a video</string>
<string name="allow_full_screen_portrait">Allow full-screen portrait</string> <string name="allow_full_screen_portrait">Allow full-screen portrait when watching horizontal videos</string>
<string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string> <string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string>
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string> <string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
<string name="background_switch_audio">Switch to Audio in Background</string> <string name="background_switch_audio">Switch to Audio in Background</string>
@ -424,6 +429,8 @@
<string name="bypass_rotation_prevention">Bypass Rotation Prevention</string> <string name="bypass_rotation_prevention">Bypass Rotation Prevention</string>
<string name="playlist_delete_confirmation">Playlist Delete Confirmation</string> <string name="playlist_delete_confirmation">Playlist Delete Confirmation</string>
<string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string> <string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string>
<string name="playlist_allow_dups">Allow duplicate playlist videos</string>
<string name="playlist_allow_dups_description">Allow adding duplicate videos to playlists</string>
<string name="enable_polycentric">Enable Polycentric</string> <string name="enable_polycentric">Enable Polycentric</string>
<string name="polycentric_local_cache">Enable Polycentric Local Caching</string> <string name="polycentric_local_cache">Enable Polycentric Local Caching</string>
<string name="polycentric_local_cache_description">Caches polycentric results on-device to reduce load times, changing requires app reboot</string> <string name="polycentric_local_cache_description">Caches polycentric results on-device to reduce load times, changing requires app reboot</string>
@ -953,6 +960,14 @@
<item>Watchtime Ascending</item> <item>Watchtime Ascending</item>
<item>Watchtime Descending</item> <item>Watchtime Descending</item>
</string-array> </string-array>
<string-array name="downloads_sortby_array">
<item>Name (Ascending)</item>
<item>Name (Descending)</item>
<item>Download Date (Oldest)</item>
<item>Download Date (Newest)</item>
<item>Release Date (Oldest)</item>
<item>Release Date (Newest)</item>
</string-array>
<string-array name="feed_style"> <string-array name="feed_style">
<item>Preview</item> <item>Preview</item>
<item>List</item> <item>List</item>

@ -1 +1 @@
Subproject commit 258c71e4f540c3c202a7ceacce2f77622017582a Subproject commit ce0571bdeaed4e341351ef477ef4b6599aa4d0fb

@ -1 +1 @@
Subproject commit 8d7c0e252738450f2a8bb2a48e9f8bdc24cfea54 Subproject commit 7f869aa4b117214095feb367d38414402cd08417

@ -1 +1 @@
Subproject commit 6dc9ba2b61ed885ec48e06e319ba75fe9bff942c Subproject commit d00c7ff8e557d8b5624c162e4e554f65625c5e29

@ -1 +1 @@
Subproject commit 8ddb2e2f15bf907bd8523aac4326b92b8e3b0e8e Subproject commit 3ee6f3e38a606cf0efba7d9d978e5673af1355f7

@ -1 +1 @@
Subproject commit 9c835e075c66ea014e544d9fe35fbb317d72a196 Subproject commit dae24378823634a9e58533e5c94e26bd3ae70601

@ -1 +1 @@
Subproject commit cfabdc97ab435822c44b0135b3b76519327ba05a Subproject commit 86c55522e5a641e64c69238f5c372179ea99e137

@ -1 +1 @@
Subproject commit 6811ff4b412cfb94ff74c3b2b88f7d87b76e8902 Subproject commit b9e6259f4eedd41d1838506d32d240e177c1feff

@ -1 +1 @@
Subproject commit 9a10cb8e78ae66439e936b634f61dc846e21b306 Subproject commit ac81711ab8c364f645389717f3f4c59fbf23f856

@ -1 +1 @@
Subproject commit eb231adeae7acd0ed8b14e2ebc2b93424ac6811c Subproject commit 05e4387318ce710e86c311022c0a97e009241083

@ -1 +1 @@
Subproject commit c3ee73a3e53c7e0aaafbe326ca28c60bbd2190f9 Subproject commit 1b2833cdf22afec8b1177bf8bc3e5f83bc014e37

@ -1 +1 @@
Subproject commit 1be5025b47126f6f8463b8fc518601a10e7b2f46 Subproject commit 13a3ae3c7a9fe0ca05ae661e74f7f505a69e057a

@ -1 +1 @@
Subproject commit 258c71e4f540c3c202a7ceacce2f77622017582a Subproject commit ce0571bdeaed4e341351ef477ef4b6599aa4d0fb

@ -1 +1 @@
Subproject commit 8d7c0e252738450f2a8bb2a48e9f8bdc24cfea54 Subproject commit 7f869aa4b117214095feb367d38414402cd08417

@ -1 +1 @@
Subproject commit 6dc9ba2b61ed885ec48e06e319ba75fe9bff942c Subproject commit d00c7ff8e557d8b5624c162e4e554f65625c5e29

@ -1 +1 @@
Subproject commit 8ddb2e2f15bf907bd8523aac4326b92b8e3b0e8e Subproject commit 3ee6f3e38a606cf0efba7d9d978e5673af1355f7

@ -1 +1 @@
Subproject commit 9c835e075c66ea014e544d9fe35fbb317d72a196 Subproject commit dae24378823634a9e58533e5c94e26bd3ae70601

@ -1 +1 @@
Subproject commit cfabdc97ab435822c44b0135b3b76519327ba05a Subproject commit 86c55522e5a641e64c69238f5c372179ea99e137

@ -1 +1 @@
Subproject commit 6811ff4b412cfb94ff74c3b2b88f7d87b76e8902 Subproject commit b9e6259f4eedd41d1838506d32d240e177c1feff

@ -1 +1 @@
Subproject commit 9a10cb8e78ae66439e936b634f61dc846e21b306 Subproject commit ac81711ab8c364f645389717f3f4c59fbf23f856

@ -1 +1 @@
Subproject commit eb231adeae7acd0ed8b14e2ebc2b93424ac6811c Subproject commit 05e4387318ce710e86c311022c0a97e009241083

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