mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06478f3e36 | |||
| 40f20002b2 | |||
| 442272f517 | |||
| 88dae8e9c4 | |||
| 1bbfa7d39e | |||
| edc2b3d295 | |||
| 0006da7385 | |||
| b5ac8b3ec6 | |||
| 78f5169880 | |||
| 3361b77aec | |||
| 8b7c9df286 | |||
| 157d5b4c36 | |||
| 44c8800bec | |||
| 2f0ba1b1f7 | |||
| 36c51f1a0c | |||
| 1dfe18aa6f | |||
| b9bbfb44c5 | |||
| 83843f192d | |||
| 8839d9f1c6 |
+1
-1
@@ -83,7 +83,7 @@
|
||||
path = app/src/stable/assets/sources/dailymotion
|
||||
url = ../plugins/dailymotion.git
|
||||
[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
|
||||
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
|
||||
path = app/src/unstable/assets/sources/apple-podcasts
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ dependencies {
|
||||
implementation 'org.jsoup:jsoup:1.15.3'
|
||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.arthenica:ffmpeg-kit-full:5.1'
|
||||
implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
|
||||
@@ -156,7 +156,6 @@
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SettingsActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.DeveloperActivity"
|
||||
|
||||
@@ -263,6 +263,10 @@ class PlatformVideoDetails extends PlatformVideo {
|
||||
this.rating = obj.rating ?? null; //IRating
|
||||
this.subtitles = obj.subtitles ?? [];
|
||||
this.isShort = !!obj.isShort ?? false;
|
||||
|
||||
if (obj.getContentRecommendations) {
|
||||
this.getContentRecommendations = obj.getContentRecommendations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -644,6 +644,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable
|
||||
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)
|
||||
var clearCookiesOnLogout: Boolean = true;
|
||||
|
||||
|
||||
@@ -79,6 +79,36 @@ class UISlideOverlays {
|
||||
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 {
|
||||
val items = arrayListOf<View>();
|
||||
|
||||
@@ -1045,8 +1075,9 @@ class UISlideOverlays {
|
||||
StatePlayer.TYPE_WATCHLATER,
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
tag = "watch later",
|
||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true);
|
||||
UIDialogs.appToast("Added to watch later", false);
|
||||
call = {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
|
||||
UIDialogs.appToast("Added to watch later", false);
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -101,7 +101,8 @@ class SyncHomeActivity : AppCompatActivity() {
|
||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||
val connected = session?.connected ?: false
|
||||
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None)
|
||||
.setName(publicKey)
|
||||
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||
//TODO: also display public key?
|
||||
.setStatus(if (connected) "Connected" else "Disconnected")
|
||||
return syncDeviceView
|
||||
}
|
||||
|
||||
+2
-2
@@ -238,8 +238,8 @@ class ChannelFragment : MainFragment() {
|
||||
}
|
||||
adapter.onAddToWatchLaterClicked.subscribe { content ->
|
||||
if (content is IPlatformVideo) {
|
||||
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)
|
||||
UIDialogs.toast("Added to watch later\n[${content.name}]")
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
|
||||
UIDialogs.toast("Added to watch later\n[${content.name}]")
|
||||
}
|
||||
}
|
||||
adapter.onUrlClicked.subscribe { url ->
|
||||
|
||||
+2
-2
@@ -82,8 +82,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
};
|
||||
adapter.onAddToWatchLaterClicked.subscribe(this) {
|
||||
if(it is IPlatformVideo) {
|
||||
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
|
||||
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
|
||||
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||
}
|
||||
};
|
||||
adapter.onLongPress.subscribe(this) {
|
||||
|
||||
+8
-1
@@ -10,6 +10,7 @@ import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -26,6 +27,7 @@ class CreatorsFragment : MainFragment() {
|
||||
private var _overlayContainer: FrameLayout? = null;
|
||||
private var _containerSearch: FrameLayout? = null;
|
||||
private var _editSearch: EditText? = null;
|
||||
private var _textMeta: TextView? = null;
|
||||
private var _buttonClearSearch: ImageButton? = null
|
||||
|
||||
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 buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search)
|
||||
_editSearch = editSearch
|
||||
_textMeta = view.findViewById(R.id.text_meta);
|
||||
_buttonClearSearch = buttonClearSearch
|
||||
buttonClearSearch.setOnClickListener {
|
||||
editSearch.text.clear()
|
||||
@@ -41,7 +44,11 @@ class CreatorsFragment : MainFragment() {
|
||||
_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.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
|
||||
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import com.futo.platformplayer.toHumanDuration
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder
|
||||
@@ -215,7 +216,7 @@ class DownloadsFragment : MainFragment() {
|
||||
_listDownloadedHeader.visibility = GONE;
|
||||
} else {
|
||||
_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 ""})";
|
||||
}
|
||||
|
||||
lastDownloads = downloaded;
|
||||
|
||||
+39
-4
@@ -23,6 +23,7 @@ import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
@@ -94,6 +95,8 @@ class HomeFragment : MainFragment() {
|
||||
class HomeView : ContentFeedView<HomeFragment> {
|
||||
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
|
||||
|
||||
private var _toggleBar: ToggleBar? = null;
|
||||
|
||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
||||
|
||||
@@ -127,6 +130,8 @@ class HomeFragment : MainFragment() {
|
||||
}, fragment);
|
||||
};
|
||||
|
||||
initializeToolbarContent();
|
||||
|
||||
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
|
||||
showAnnouncementView()
|
||||
}
|
||||
@@ -201,13 +206,43 @@ class HomeFragment : MainFragment() {
|
||||
loadResults();
|
||||
}
|
||||
|
||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
|
||||
private val _filterLock = Object();
|
||||
private var _toggleRecent = false;
|
||||
fun initializeToolbarContent() {
|
||||
//Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing
|
||||
/*
|
||||
_toggleBar = ToggleBar(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
synchronized(_filterLock) {
|
||||
_toggleBar?.setToggles(
|
||||
//TODO: loadResults needs to be replaced with an internal reload of the current content
|
||||
ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) }
|
||||
)
|
||||
}
|
||||
|
||||
_toolbarContentView.addView(_toggleBar, 0);
|
||||
*/
|
||||
}
|
||||
|
||||
private fun loadResults() {
|
||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return results.filter {
|
||||
if(StateMeta.instance.isVideoHidden(it.url))
|
||||
return@filter false;
|
||||
if(StateMeta.instance.isCreatorHidden(it.author.url))
|
||||
return@filter false;
|
||||
|
||||
if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) {
|
||||
return@filter false;
|
||||
}
|
||||
|
||||
return@filter true;
|
||||
};
|
||||
}
|
||||
|
||||
private fun loadResults(withRefetch: Boolean = true) {
|
||||
setLoading(true);
|
||||
_taskGetPager.run(true);
|
||||
_taskGetPager.run(withRefetch);
|
||||
}
|
||||
private fun loadedResult(pager : IPager<IPlatformContent>) {
|
||||
if (pager is EmptyPager<IPlatformContent>) {
|
||||
|
||||
+1
-1
@@ -556,7 +556,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
|
||||
|
||||
val config = SourcePluginConfig.fromJson(configJson);
|
||||
if (config.version <= c.version && config.name != "Youtube") {
|
||||
if (config.version <= c.version) {
|
||||
Logger.i(TAG, "Plugin is up to date.");
|
||||
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.plugin_is_fully_up_to_date)); };
|
||||
return@launch;
|
||||
|
||||
+60
-23
@@ -579,6 +579,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
_minimize_title.setOnClickListener { onMaximize.emit(false) };
|
||||
_minimize_meta.setOnClickListener { onMaximize.emit(false) };
|
||||
|
||||
_player.onStateChange.subscribe {
|
||||
if (_player.activelyPlaying) {
|
||||
Logger.i(TAG, "Play changed, resetting error counter _didTriggerDatasourceErrorCount = 0 (_player.activelyPlaying: ${_player.activelyPlaying})")
|
||||
_didTriggerDatasourceErrorCount = 0;
|
||||
_didTriggerDatasourceError = false;
|
||||
}
|
||||
}
|
||||
|
||||
_player.onPlayChanged.subscribe {
|
||||
if (StateCasting.instance.activeDevice == null) {
|
||||
handlePlayChanged(it);
|
||||
@@ -922,7 +930,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
} else if(devices.size == 1){
|
||||
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.displayName}'" , {
|
||||
Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -963,6 +971,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
throw IllegalStateException("Expected media content, found ${video.contentType}");
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_videoResumePositionMilliseconds = _player.position
|
||||
setVideoDetails(video);
|
||||
}
|
||||
}
|
||||
@@ -1265,8 +1274,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
|
||||
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
|
||||
_didTriggerDatasourceErrroCount = 0;
|
||||
_didTriggerDatasourceError = false;
|
||||
_autoplayVideo = null
|
||||
Logger.i(TAG, "Autoplay video cleared (setVideoDetails)")
|
||||
|
||||
@@ -1277,6 +1284,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
_lastVideoSource = null;
|
||||
_lastAudioSource = null;
|
||||
_lastSubtitleSource = null;
|
||||
|
||||
Logger.i(TAG, "_didTriggerDatasourceErrorCount reset to 0 because new video")
|
||||
_didTriggerDatasourceErrorCount = 0;
|
||||
_didTriggerDatasourceError = false;
|
||||
}
|
||||
|
||||
if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
|
||||
@@ -1831,7 +1842,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private var _didTriggerDatasourceErrroCount = 0;
|
||||
private var _didTriggerDatasourceErrorCount = 0;
|
||||
private var _didTriggerDatasourceError = false;
|
||||
private fun onDataSourceError(exception: Throwable) {
|
||||
Logger.e(TAG, "onDataSourceError", exception);
|
||||
@@ -1841,32 +1852,53 @@ class VideoDetailView : ConstraintLayout {
|
||||
return;
|
||||
val config = currentVideo.sourceConfig;
|
||||
|
||||
if(_didTriggerDatasourceErrroCount <= 3) {
|
||||
if(_didTriggerDatasourceErrorCount <= 3) {
|
||||
_didTriggerDatasourceError = true;
|
||||
_didTriggerDatasourceErrroCount++;
|
||||
_didTriggerDatasourceErrorCount++;
|
||||
|
||||
UIDialogs.toast("Detected video error, attempting automatic reload (${_didTriggerDatasourceErrorCount})");
|
||||
Logger.i(TAG, "Block detected, attempting bypass (_didTriggerDatasourceErrorCount = ${_didTriggerDatasourceErrorCount})");
|
||||
|
||||
UIDialogs.toast("Block detected, attempting bypass");
|
||||
//return;
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
|
||||
val previousVideoSource = _lastVideoSource;
|
||||
val previousAudioSource = _lastAudioSource;
|
||||
try {
|
||||
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
|
||||
val previousVideoSource = _lastVideoSource;
|
||||
val previousAudioSource = _lastAudioSource;
|
||||
|
||||
if(newDetails is IPlatformVideoDetails) {
|
||||
val newVideoSource = if(previousVideoSource != null)
|
||||
VideoHelper.selectBestVideoSource(newDetails.video, previousVideoSource.height * previousVideoSource.width, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS);
|
||||
else null;
|
||||
val newAudioSource = if(previousAudioSource != null)
|
||||
VideoHelper.selectBestAudioSource(newDetails.video, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, previousAudioSource.language, previousAudioSource.bitrate.toLong());
|
||||
else null;
|
||||
withContext(Dispatchers.Main) {
|
||||
video = newDetails;
|
||||
_player.setSource(newVideoSource, newAudioSource, true, true);
|
||||
if (newDetails is IPlatformVideoDetails) {
|
||||
val newVideoSource = if (previousVideoSource != null)
|
||||
VideoHelper.selectBestVideoSource(
|
||||
newDetails.video,
|
||||
previousVideoSource.height * previousVideoSource.width,
|
||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||
);
|
||||
else null;
|
||||
val newAudioSource = if (previousAudioSource != null)
|
||||
VideoHelper.selectBestAudioSource(
|
||||
newDetails.video,
|
||||
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||
previousAudioSource.language,
|
||||
previousAudioSource.bitrate.toLong()
|
||||
);
|
||||
else null;
|
||||
withContext(Dispatchers.Main) {
|
||||
video = newDetails;
|
||||
_player.setSource(newVideoSource, newAudioSource, true, true);
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get video details, attempting retrying without reloading.", e)
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
video?.let {
|
||||
_videoResumePositionMilliseconds = _player.position
|
||||
setVideoDetails(it, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(_didTriggerDatasourceErrroCount > 3) {
|
||||
else if(_didTriggerDatasourceErrorCount > 3) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_error_pred,
|
||||
context.getString(R.string.media_error),
|
||||
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
|
||||
@@ -2595,8 +2627,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
onAddToWatchLaterClicked.subscribe(this) {
|
||||
if(it is IPlatformVideo) {
|
||||
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
|
||||
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
|
||||
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||
}
|
||||
}
|
||||
onAddToQueueClicked.subscribe(this) {
|
||||
if(it is IPlatformVideo) {
|
||||
StatePlayer.instance.addToQueue(it);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,6 +48,17 @@ class StateDownloads {
|
||||
private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath);
|
||||
|
||||
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()
|
||||
.apply { afterLoadingDownloaded(this) };
|
||||
private val _downloading = FragmentedStorage.storeJson<VideoDownload>("downloading")
|
||||
@@ -87,9 +98,6 @@ class StateDownloads {
|
||||
Logger.i("StateDownloads", "Deleting local video ${id.value}");
|
||||
val downloaded = getCachedVideo(id);
|
||||
if(downloaded != null) {
|
||||
synchronized(_downloadedSet) {
|
||||
_downloadedSet.remove(id);
|
||||
}
|
||||
_downloaded.delete(downloaded);
|
||||
}
|
||||
onDownloadedChanged.emit();
|
||||
@@ -263,9 +271,6 @@ class StateDownloads {
|
||||
if(existing.groupID == null) {
|
||||
existing.groupID = VideoDownload.GROUP_WATCHLATER;
|
||||
existing.groupType = VideoDownload.GROUP_WATCHLATER;
|
||||
synchronized(_downloadedSet) {
|
||||
_downloadedSet.add(existing.id);
|
||||
}
|
||||
_downloaded.save(existing);
|
||||
}
|
||||
}
|
||||
@@ -308,9 +313,6 @@ class StateDownloads {
|
||||
if(existing.groupID == null) {
|
||||
existing.groupID = playlist.id;
|
||||
existing.groupType = VideoDownload.GROUP_PLAYLIST;
|
||||
synchronized(_downloadedSet) {
|
||||
_downloadedSet.add(existing.id);
|
||||
}
|
||||
_downloaded.save(existing);
|
||||
}
|
||||
}
|
||||
@@ -476,7 +478,16 @@ class StateDownloads {
|
||||
|
||||
val root = DocumentFile.fromTreeUri(context, it!!);
|
||||
|
||||
val localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId)
|
||||
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;
|
||||
|
||||
@@ -484,6 +495,7 @@ class StateDownloads {
|
||||
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})");
|
||||
@@ -501,6 +513,7 @@ class StateDownloads {
|
||||
lastNotifyTime = now;
|
||||
}
|
||||
}, root);
|
||||
success++;
|
||||
} catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed export [${video.name}]: ${ex.message}", ex);
|
||||
}
|
||||
@@ -509,7 +522,7 @@ class StateDownloads {
|
||||
withContext(Dispatchers.Main) {
|
||||
it.setProgress(1f);
|
||||
it.dismiss();
|
||||
UIDialogs.appToast("Finished exporting playlist");
|
||||
UIDialogs.appToast("Finished exporting playlist (${success} videos${if(i < success) ", ${i} errors" else ""})");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.video.IPlatformVideo
|
||||
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.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -130,6 +131,12 @@ class StatePlayer {
|
||||
closeMediaSession();
|
||||
}
|
||||
|
||||
fun saveQueueAsPlaylist(name: String){
|
||||
val videos = _queue.toList();
|
||||
val playlist = Playlist(name, videos.map { SerializedPlatformVideo.fromVideo(it) });
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
}
|
||||
|
||||
//Notifications
|
||||
fun hasMediaSession() : Boolean {
|
||||
return MediaPlaybackService.getService() != null;
|
||||
|
||||
@@ -177,8 +177,11 @@ class StatePlaylists {
|
||||
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) {
|
||||
if(!_watchlistStore.hasItem { it.url == video.url })
|
||||
wasNew = true;
|
||||
_watchlistStore.saveAsync(video);
|
||||
if(orderPosition == -1)
|
||||
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
|
||||
@@ -198,6 +201,7 @@ class StatePlaylists {
|
||||
}
|
||||
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
return wasNew;
|
||||
}
|
||||
|
||||
fun getLastPlayedPlaylist() : Playlist? {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.states
|
||||
|
||||
import android.content.Context
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.LoginActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
@@ -101,6 +102,8 @@ class StatePlugins {
|
||||
if (availableClient !is JSClient) {
|
||||
continue
|
||||
}
|
||||
if(!Settings.instance.plugins.checkDisabledPluginsForUpdates && !StatePlatform.instance.isClientEnabled(availableClient.id))
|
||||
continue;
|
||||
|
||||
val newConfig = checkForUpdates(availableClient.config);
|
||||
if (newConfig != null) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import kotlin.system.measureTimeMillis
|
||||
|
||||
class StateSync {
|
||||
private val _authorizedDevices = FragmentedStorage.get<StringArrayStorage>("authorized_devices")
|
||||
private val _nameStorage = FragmentedStorage.get<StringStringMapStorage>("sync_remembered_name_storage")
|
||||
private val _syncKeyPair = FragmentedStorage.get<StringStorage>("sync_key_pair")
|
||||
private val _lastAddressStorage = FragmentedStorage.get<StringStringMapStorage>("sync_last_address_storage")
|
||||
private val _syncSessionData = FragmentedStorage.get<StringTMapStorage<SyncSessionData>>("syncSessionData")
|
||||
@@ -305,12 +306,22 @@ class StateSync {
|
||||
synchronized(_sessions) {
|
||||
session = _sessions[s.remotePublicKey]
|
||||
if (session == null) {
|
||||
val remoteDeviceName = synchronized(_nameStorage) {
|
||||
_nameStorage.get(remotePublicKey)
|
||||
}
|
||||
|
||||
session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession ->
|
||||
if (!isNewSession) {
|
||||
return@SyncSession
|
||||
}
|
||||
|
||||
Logger.i(TAG, "${s.remotePublicKey} authorized")
|
||||
it.remoteDeviceName?.let { remoteDeviceName ->
|
||||
synchronized(_nameStorage) {
|
||||
_nameStorage.setAndSave(remotePublicKey, remoteDeviceName)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})")
|
||||
synchronized(_lastAddressStorage) {
|
||||
_lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress)
|
||||
}
|
||||
@@ -341,7 +352,7 @@ class StateSync {
|
||||
|
||||
deviceRemoved.emit(it.remotePublicKey)
|
||||
|
||||
})
|
||||
}, remoteDeviceName)
|
||||
_sessions[remotePublicKey] = session!!
|
||||
}
|
||||
session!!.addSocketSession(s)
|
||||
@@ -469,6 +480,12 @@ class StateSync {
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedName(publicKey: String): String? {
|
||||
return synchronized(_nameStorage) {
|
||||
_nameStorage.get(publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(publicKey: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,9 @@ class ManagedStore<T>{
|
||||
|
||||
val className: String? get() = _class.classifier?.assume<KClass<*>>()?.simpleName;
|
||||
|
||||
private var _onModificationCreate: ((T) -> Unit)? = null;
|
||||
private var _onModificationDelete: ((T) -> Unit)? = null;
|
||||
|
||||
val name: String;
|
||||
|
||||
constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer<T>, niceName: String? = null) {
|
||||
@@ -62,6 +65,12 @@ class ManagedStore<T>{
|
||||
return this;
|
||||
}
|
||||
|
||||
fun withOnModified(created: (T)->Unit, deleted: (T)->Unit): ManagedStore<T> {
|
||||
_onModificationCreate = created;
|
||||
_onModificationDelete = deleted;
|
||||
return this;
|
||||
}
|
||||
|
||||
fun load(): ManagedStore<T> {
|
||||
synchronized(_files) {
|
||||
_files.clear();
|
||||
@@ -265,6 +274,7 @@ class ManagedStore<T>{
|
||||
file = saveNew(obj);
|
||||
if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction))
|
||||
saveReconstruction(file, obj);
|
||||
_onModificationCreate?.invoke(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,6 +310,7 @@ class ManagedStore<T>{
|
||||
_files.remove(item);
|
||||
Logger.v(TAG, "Deleting file ${logName(file.id)}");
|
||||
file.delete();
|
||||
_onModificationDelete?.invoke(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@ import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.smartMerge
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
@@ -30,6 +28,7 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
@@ -53,6 +52,9 @@ class SyncSession : IAuthorizable {
|
||||
private val _id = UUID.randomUUID()
|
||||
private var _remoteId: UUID? = null
|
||||
private var _lastAuthorizedRemoteId: UUID? = null
|
||||
var remoteDeviceName: String? = null
|
||||
private set
|
||||
val displayName: String get() = remoteDeviceName ?: remotePublicKey
|
||||
|
||||
var connected: Boolean = false
|
||||
private set(v) {
|
||||
@@ -62,7 +64,7 @@ class SyncSession : IAuthorizable {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) {
|
||||
constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) {
|
||||
this.remotePublicKey = remotePublicKey
|
||||
_onAuthorized = onAuthorized
|
||||
_onUnauthorized = onUnauthorized
|
||||
@@ -85,7 +87,20 @@ class SyncSession : IAuthorizable {
|
||||
|
||||
fun authorize(socketSession: SyncSocketSession) {
|
||||
Logger.i(TAG, "Sent AUTHORIZED with session id $_id")
|
||||
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
|
||||
|
||||
if (socketSession.remoteVersion >= 3) {
|
||||
val idStringBytes = _id.toString().toByteArray()
|
||||
val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray()
|
||||
val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size)
|
||||
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
put(idStringBytes.size.toByte())
|
||||
put(idStringBytes)
|
||||
put(nameBytes.size.toByte())
|
||||
put(nameBytes)
|
||||
}.apply { flip() })
|
||||
} else {
|
||||
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
|
||||
}
|
||||
_authorized = true
|
||||
checkAuthorized()
|
||||
}
|
||||
@@ -138,15 +153,37 @@ class SyncSession : IAuthorizable {
|
||||
|
||||
when (opcode) {
|
||||
Opcode.NOTIFY_AUTHORIZED.value -> {
|
||||
val str = data.toUtf8String()
|
||||
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000")
|
||||
if (socketSession.remoteVersion >= 3) {
|
||||
val idByteCount = data.get().toInt()
|
||||
if (idByteCount > 64)
|
||||
throw Exception("Id should always be smaller than 64 bytes")
|
||||
|
||||
val idBytes = ByteArray(idByteCount)
|
||||
data.get(idBytes)
|
||||
|
||||
val nameByteCount = data.get().toInt()
|
||||
if (nameByteCount > 64)
|
||||
throw Exception("Name should always be smaller than 64 bytes")
|
||||
|
||||
val nameBytes = ByteArray(nameByteCount)
|
||||
data.get(nameBytes)
|
||||
|
||||
_remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8))
|
||||
remoteDeviceName = nameBytes.toString(Charsets.UTF_8)
|
||||
} else {
|
||||
val str = data.toUtf8String()
|
||||
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000")
|
||||
remoteDeviceName = null
|
||||
}
|
||||
|
||||
_remoteAuthorized = true
|
||||
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId")
|
||||
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')")
|
||||
checkAuthorized()
|
||||
return
|
||||
}
|
||||
Opcode.NOTIFY_UNAUTHORIZED.value -> {
|
||||
_remoteId = null
|
||||
remoteDeviceName = null
|
||||
_lastAuthorizedRemoteId = null
|
||||
_remoteAuthorized = false
|
||||
_onUnauthorized(this)
|
||||
|
||||
@@ -46,6 +46,8 @@ class SyncSocketSession {
|
||||
val localPublicKey: String get() = _localPublicKey
|
||||
private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit
|
||||
var authorizable: IAuthorizable? = null
|
||||
var remoteVersion: Int = -1
|
||||
private set
|
||||
|
||||
val remoteAddress: String
|
||||
|
||||
@@ -162,11 +164,12 @@ class SyncSocketSession {
|
||||
}
|
||||
|
||||
private fun performVersionCheck() {
|
||||
val CURRENT_VERSION = 2
|
||||
val CURRENT_VERSION = 3
|
||||
val MINIMUM_VERSION = 2
|
||||
_outputStream.writeInt(CURRENT_VERSION)
|
||||
val version = _inputStream.readInt()
|
||||
Logger.i(TAG, "performVersionCheck (version = $version)")
|
||||
if (version != CURRENT_VERSION)
|
||||
remoteVersion = _inputStream.readInt()
|
||||
Logger.i(TAG, "performVersionCheck (version = $remoteVersion)")
|
||||
if (remoteVersion < MINIMUM_VERSION)
|
||||
throw Exception("Invalid version")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.futo.platformplayer.views
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.others.ToggleTagView
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionBarViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder
|
||||
import com.futo.platformplayer.views.subscriptions.SubscriptionExploreButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ToggleBar : LinearLayout {
|
||||
private val _tagsContainer: LinearLayout;
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
StateSubscriptionGroups.instance.onGroupsChanged.remove(this);
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.view_toggle_bar, this);
|
||||
|
||||
_tagsContainer = findViewById(R.id.container_tags);
|
||||
}
|
||||
|
||||
fun setToggles(vararg buttons: Toggle) {
|
||||
_tagsContainer.removeAllViews();
|
||||
for(button in buttons) {
|
||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||
this.setInfo(button.name, button.isActive);
|
||||
this.onClick.subscribe { button.action(it); };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Toggle {
|
||||
val name: String;
|
||||
val icon: Int;
|
||||
val action: (Boolean)->Unit;
|
||||
val isActive: Boolean;
|
||||
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = icon;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
||||
private lateinit var _sortedDataset: List<Subscription>;
|
||||
private val _inflater: LayoutInflater;
|
||||
private val _confirmationMessage: String;
|
||||
private val _onDatasetChanged: ((List<Subscription>)->Unit)?;
|
||||
|
||||
var onClick = Event1<Subscription>();
|
||||
var onSettings = Event1<Subscription>();
|
||||
@@ -30,9 +31,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
||||
updateDataset();
|
||||
}
|
||||
|
||||
constructor(inflater: LayoutInflater, confirmationMessage: String) : super() {
|
||||
constructor(inflater: LayoutInflater, confirmationMessage: String, onDatasetChanged: ((List<Subscription>)->Unit)? = null) : super() {
|
||||
_inflater = inflater;
|
||||
_confirmationMessage = confirmationMessage;
|
||||
_onDatasetChanged = onDatasetChanged;
|
||||
|
||||
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
|
||||
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)) }
|
||||
.toList();
|
||||
|
||||
_onDatasetChanged?.invoke(_sortedDataset);
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ open class PreviewVideoView : LinearLayout {
|
||||
|
||||
fun hideAddTo() {
|
||||
_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) {
|
||||
|
||||
@@ -2,16 +2,26 @@ package com.futo.platformplayer.views.overlays
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
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 {
|
||||
|
||||
private val _topbar : OverlayTopbar;
|
||||
private val _editor : VideoListEditorView;
|
||||
private val _btnSettings: ImageView;
|
||||
|
||||
private val _overlayContainer: FrameLayout;
|
||||
|
||||
|
||||
val onClose = Event0();
|
||||
|
||||
@@ -19,6 +29,9 @@ class QueueEditorOverlay : LinearLayout {
|
||||
inflate(context, R.layout.overlay_queue, this)
|
||||
_topbar = findViewById(R.id.topbar);
|
||||
_editor = findViewById(R.id.editor);
|
||||
_btnSettings = findViewById(R.id.button_settings);
|
||||
_overlayContainer = findViewById(R.id.overlay_container_queue);
|
||||
|
||||
|
||||
_topbar.onClose.subscribe(this, onClose::emit);
|
||||
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
|
||||
@@ -28,6 +41,10 @@ class QueueEditorOverlay : LinearLayout {
|
||||
}
|
||||
_editor.onVideoClicked.subscribe { v -> StatePlayer.instance.setQueuePosition(v) }
|
||||
|
||||
_btnSettings.setOnClickListener {
|
||||
handleSettings();
|
||||
}
|
||||
|
||||
_topbar.setInfo(context.getString(R.string.queue), "");
|
||||
}
|
||||
|
||||
@@ -40,4 +57,8 @@ class QueueEditorOverlay : LinearLayout {
|
||||
fun cleanup() {
|
||||
_topbar.onClose.remove(this);
|
||||
}
|
||||
|
||||
fun handleSettings() {
|
||||
UISlideOverlays.showQueueOptionsOverlay(context, _overlayContainer);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
val exoPlayerStateName: String;
|
||||
|
||||
var playing: Boolean = false;
|
||||
val activelyPlaying: Boolean get() = (exoPlayer?.player?.playbackState == Player.STATE_READY) && (exoPlayer?.player?.playWhenReady ?: false)
|
||||
val position: Long get() = exoPlayer?.player?.currentPosition ?: 0;
|
||||
val duration: Long get() = exoPlayer?.player?.duration ?: 0;
|
||||
|
||||
@@ -829,7 +830,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss");
|
||||
|
||||
when (error.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
||||
Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}");
|
||||
if(error.cause is HttpDataSource.InvalidResponseCodeException) {
|
||||
val cause = error.cause as HttpDataSource.InvalidResponseCodeException
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
android:layout_height="110dp"
|
||||
android:minHeight="0dp"
|
||||
app:layout_scrollFlags="scroll"
|
||||
app:contentInsetStart="0dp"
|
||||
@@ -77,7 +77,16 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp" />
|
||||
|
||||
</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>
|
||||
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
@@ -21,5 +21,20 @@
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/topbar"
|
||||
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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollbars="horizontal">
|
||||
<LinearLayout
|
||||
android:id="@+id/container_tags"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -286,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="announcement">Announcement</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_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>
|
||||
@@ -416,7 +418,7 @@
|
||||
<string name="log_level">Log Level</string>
|
||||
<string name="logging">Logging</string>
|
||||
<string name="sync_grayjay">Sync Grayjay</string>
|
||||
<string name="sync_grayjay_description">Sync your settings across multiple devices</string>
|
||||
<string name="sync_grayjay_description">Sync your data across multiple devices</string>
|
||||
<string name="manage_polycentric_identity">Manage Polycentric identity</string>
|
||||
<string name="manage_your_polycentric_identity">Manage your Polycentric identity</string>
|
||||
<string name="manual_check">Manual check</string>
|
||||
|
||||
Submodule app/src/stable/assets/sources/apple-podcast deleted from f79c7141bc
+1
Submodule app/src/stable/assets/sources/apple-podcasts added at 07e39f9df7
Submodule app/src/stable/assets/sources/bilibili updated: 13b30fd76e...ce0571bdea
Submodule app/src/stable/assets/sources/bitchute updated: 8d7c0e2527...3fbd872ad8
Submodule app/src/stable/assets/sources/dailymotion updated: d00c7ff8e5...b34134ca2d
Submodule app/src/stable/assets/sources/kick updated: 8d957b6fc4...2046944c18
Submodule app/src/stable/assets/sources/nebula updated: 9e6dcf0935...f30a3bfc0f
Submodule app/src/stable/assets/sources/odysee updated: 04b4d8ed31...f2f83344eb
Submodule app/src/stable/assets/sources/patreon updated: 9c835e075c...e5dce87c9d
Submodule app/src/stable/assets/sources/peertube updated: cfabdc97ab...2bcab14d01
Submodule app/src/stable/assets/sources/rumble updated: 670cbc043e...a32dbb626a
Submodule app/src/stable/assets/sources/soundcloud updated: a72aeb85d0...ae47f2eaac
Submodule app/src/stable/assets/sources/spotify updated: eb231adeae...0d05e35cfc
Submodule app/src/stable/assets/sources/twitch updated: 1b2833cdf2...a75e846045
Submodule app/src/stable/assets/sources/youtube updated: 15d3391a5d...857c147b3a
@@ -12,7 +12,8 @@
|
||||
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
|
||||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
|
||||
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json"
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
||||
Submodule app/src/unstable/assets/sources/apple-podcasts updated: f79c7141bc...07e39f9df7
Submodule app/src/unstable/assets/sources/bilibili updated: 13b30fd76e...ce0571bdea
Submodule app/src/unstable/assets/sources/bitchute updated: 8d7c0e2527...3fbd872ad8
Submodule app/src/unstable/assets/sources/dailymotion updated: d00c7ff8e5...b34134ca2d
Submodule app/src/unstable/assets/sources/kick updated: 8d957b6fc4...2046944c18
Submodule app/src/unstable/assets/sources/nebula updated: 9e6dcf0935...f30a3bfc0f
Submodule app/src/unstable/assets/sources/odysee updated: 04b4d8ed31...f2f83344eb
Submodule app/src/unstable/assets/sources/patreon updated: 9c835e075c...e5dce87c9d
Submodule app/src/unstable/assets/sources/peertube updated: cfabdc97ab...2bcab14d01
Submodule app/src/unstable/assets/sources/rumble updated: 670cbc043e...a32dbb626a
Submodule app/src/unstable/assets/sources/soundcloud updated: a72aeb85d0...ae47f2eaac
Submodule app/src/unstable/assets/sources/spotify updated: eb231adeae...0d05e35cfc
Submodule app/src/unstable/assets/sources/twitch updated: 1b2833cdf2...a75e846045
Submodule app/src/unstable/assets/sources/youtube updated: 2c816009f7...857c147b3a
@@ -12,7 +12,8 @@
|
||||
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
|
||||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
|
||||
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json"
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
||||
@@ -8,7 +8,7 @@ The goal of the authentication system is to provide plugins the ability to make
|
||||
>
|
||||
>You should always only login (and install for that matter) plugins you trust.
|
||||
|
||||
How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](_blank)).
|
||||
How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](docs/packages/packageHttp.md)).
|
||||
This documentation will exclusively focus on configuring authentication and how it behaves.
|
||||
|
||||
## How it works
|
||||
@@ -58,5 +58,5 @@ Headers are exclusively applied to the domains they are retrieved from. A plugin
|
||||
By default, when authentication requests are made, the authenticated client will behave similar to that of a normal browser. Meaning that if the server you are communicating with sets new cookies, the client will use those cookies instead. These new cookies are NOT saved to disk, meaning that whenever that plugin reloads the cookies will revert to those assigned at login.
|
||||
|
||||
This behavior can be modified by using custom http clients as described in the http package documentation.
|
||||
(See [Package: Http](_blank))
|
||||
(See [Package: Http](docs/packages/packageHttp.md))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user