Compare commits

...

8 Commits

Author SHA1 Message Date
Kai DeLorenzo 39e7d64d3f remove save icon after saving 2024-06-26 15:03:01 -05:00
Kai DeLorenzo 35d8610c00 Update packageHttp.md 2024-06-26 17:01:25 +00:00
Koen bc550ae8f5 Removed exporting service. 2024-06-26 16:01:08 +02:00
Kai DeLorenzo c76ef7f19b Merge branch 'playlist-fixes' into 'master'
Playlist Fixes

See merge request videostreaming/grayjay!21
2024-06-25 15:35:46 +00:00
Kai DeLorenzo b7781264d3 changed playlist limit to 100
added save button to non-saved local playlists
2024-06-25 10:22:23 -05:00
Kai DeLorenzo 696e03941a pass through actions to local playlist and auto convert playlists with 20 or fewer videos 2024-06-24 13:00:58 -05:00
Kai DeLorenzo 4609a351dc don't save playlists that weren't explicitly copied
fixed exception failed to convert playlist job cancelled
2024-06-24 10:50:40 -05:00
Kelvin K c275415a49 Hide playlist video count if unknown 2024-06-20 11:51:11 +02:00
17 changed files with 209 additions and 436 deletions
-3
View File
@@ -41,9 +41,6 @@
<service android:name=".services.DownloadService"
android:enabled="true"
android:foregroundServiceType="dataSync" />
<service android:name=".services.ExportingService"
android:enabled="true"
android:foregroundServiceType="dataSync" />
<receiver android:name=".receivers.MediaControlReceiver" />
<receiver android:name=".receivers.AudioNoisyReceiver" />
+1 -1
View File
@@ -436,7 +436,7 @@ class PlatformPlaylist extends PlatformContent {
constructor(obj) {
super(obj, 4);
this.plugin_type = "PlatformPlaylist";
this.videoCount = obj.videoCount ?? 0;
this.videoCount = obj.videoCount ?? -1;
this.thumbnail = obj.thumbnail;
}
}
@@ -14,6 +14,6 @@ open class JSPlaylist : JSContent, IPlatformPlaylist {
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Playlist";
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
videoCount = obj.getOrDefault(config, "videoCount", contextName, 0)!!;
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
}
}
@@ -1,47 +1,37 @@
package com.futo.platformplayer.downloads
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import com.arthenica.ffmpegkit.*
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.constructs.Event1
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.LogCallback
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.*
import java.io.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.Executors
import kotlin.coroutines.resumeWithException
@kotlinx.serialization.Serializable
class VideoExport {
var state: State = State.QUEUED;
var videoLocal: VideoLocal;
var videoSource: LocalVideoSource?;
var audioSource: LocalAudioSource?;
var subtitleSource: LocalSubtitleSource?;
var progress: Double = 0.0;
var isCancelled = false;
var error: String? = null;
@kotlinx.serialization.Transient
val onStateChanged = Event1<State>();
@kotlinx.serialization.Transient
val onProgressChanged = Event1<Double>();
fun changeState(newState: State) {
state = newState;
onStateChanged.emit(newState);
}
constructor(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) {
this.videoLocal = videoLocal;
this.videoSource = videoSource;
@@ -50,8 +40,6 @@ class VideoExport {
}
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
if(isCancelled) throw CancellationException("Export got cancelled");
val v = videoSource;
val a = audioSource;
val s = subtitleSource;
@@ -107,7 +95,6 @@ class VideoExport {
throw Exception("Cannot export when no audio or video source is set.");
}
onProgressChanged.emit(100.0);
return@coroutineScope outputFile;
}
@@ -8,7 +8,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.logging.Logger
@@ -16,12 +16,13 @@ import com.futo.platformplayer.models.Playlist
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.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder
import com.futo.platformplayer.views.items.ActiveDownloadItem
import com.futo.platformplayer.views.items.PlaylistDownloadItem
import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -64,16 +65,6 @@ class DownloadsFragment : MainFragment() {
}
}
};
StateDownloads.instance.onExportsChanged.subscribe(this) {
lifecycleScope.launch(Dispatchers.Main) {
try {
Logger.i(TAG, "Reloading UI for exports");
_view?.reloadUI()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to reload UI for exports", e)
}
}
};
}
override fun onPause() {
@@ -81,7 +72,6 @@ class DownloadsFragment : MainFragment() {
StateDownloads.instance.onDownloadsChanged.remove(this);
StateDownloads.instance.onDownloadedChanged.remove(this);
StateDownloads.instance.onExportsChanged.remove(this);
}
private class DownloadsView : LinearLayout {
@@ -14,6 +14,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StateApp
@@ -144,53 +145,62 @@ class PlaylistFragment : MainFragment() {
}
fun onShown(parameter: Any?) {
_taskLoadPlaylist.cancel();
_taskLoadPlaylist.cancel()
if (parameter is Playlist?) {
_playlist = parameter;
_url = null;
_playlist = parameter
_url = null
if(parameter != null) {
setName(parameter.name);
setVideos(parameter.videos, true);
setVideoCount(parameter.videos.size);
setButtonDownloadVisible(true);
setButtonEditVisible(true);
if (parameter != null) {
setName(parameter.name)
setVideos(parameter.videos, true)
setVideoCount(parameter.videos.size)
setButtonDownloadVisible(true)
setButtonEditVisible(true)
if (!StatePlaylists.instance.playlistStore.getItems().contains(parameter)) {
_fragment.topBar?.assume<NavigationTopBarFragment>()
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
StatePlaylists.instance.playlistStore.save(parameter)
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(
arrayListOf()
)
UIDialogs.toast("Playlist saved")
}))
}
} else {
setName(null);
setVideos(null, false);
setVideoCount(-1);
setButtonDownloadVisible(false);
setButtonEditVisible(false);
setName(null)
setVideos(null, false)
setVideoCount(-1)
setButtonDownloadVisible(false)
setButtonEditVisible(false)
}
//TODO: Do I have to remove the showConvertPlaylistButton(); button here?
} else if (parameter is IPlatformPlaylist) {
_playlist = null;
_url = parameter.url;
_playlist = null
_url = parameter.url
setVideoCount(parameter.videoCount);
setName(parameter.name);
setVideos(null, false);
setButtonDownloadVisible(false);
setButtonEditVisible(false);
setVideoCount(parameter.videoCount)
setName(parameter.name)
setVideos(null, false)
setButtonDownloadVisible(false)
setButtonEditVisible(false)
fetchPlaylist();
fetchPlaylist()
} else if (parameter is String) {
_playlist = null;
_url = parameter;
_playlist = null
_url = parameter
setName(null);
setVideos(null, false);
setVideoCount(-1);
setButtonDownloadVisible(false);
setButtonEditVisible(false);
setName(null)
setVideos(null, false)
setVideoCount(-1)
setButtonDownloadVisible(false)
setButtonEditVisible(false)
fetchPlaylist();
fetchPlaylist()
}
_playlist?.let {
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download);
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download)
}
}
@@ -31,12 +31,16 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.VideoListEditorViewHolder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
enum class Action {
PLAY_ALL, SHUFFLE, PLAY, NONE
}
class RemotePlaylistFragment : MainFragment() {
override val isMainView : Boolean = true;
@@ -85,6 +89,8 @@ class RemotePlaylistFragment : MainFragment() {
private val _adapterVideos: InsertedViewAdapterWithLoader<VideoListEditorViewHolder>;
private val _scrollListener: RecyclerView.OnScrollListener
constructor(fragment: RemotePlaylistFragment, inflater: LayoutInflater) : super(inflater.context) {
inflater.inflate(R.layout.fragment_remote_playlist, this);
@@ -97,18 +103,25 @@ class RemotePlaylistFragment : MainFragment() {
_imageLoader = findViewById(R.id.image_loader);
_recyclerPlaylist = findViewById(R.id.recycler_playlist);
_llmPlaylist = LinearLayoutManager(context);
_adapterVideos = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
_adapterVideos = InsertedViewAdapterWithLoader(context,
arrayListOf(),
arrayListOf(),
childCountGetter = { _videos.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_videos[position], false); },
childViewHolderBinder = { viewHolder, position ->
viewHolder.bind(
_videos[position],
false
)
},
childViewHolderFactory = { viewGroup, _ ->
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_playlist, viewGroup, false);
val holder = VideoListEditorViewHolder(view, null);
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.list_playlist, viewGroup, false)
val holder = VideoListEditorViewHolder(view, null)
holder.onClick.subscribe {
showConvertConfirmationModal();
};
return@InsertedViewAdapterWithLoader holder;
}
);
convertPlaylist(false, Action.PLAY, holder.video)
}
return@InsertedViewAdapterWithLoader holder
})
_recyclerPlaylist.adapter = _adapterVideos;
_recyclerPlaylist.layoutManager = _llmPlaylist;
@@ -128,10 +141,10 @@ class RemotePlaylistFragment : MainFragment() {
};
buttonPlayAll.setOnClickListener {
showConvertConfirmationModal();
convertPlaylist(false, Action.PLAY_ALL);
};
buttonShuffle.setOnClickListener {
showConvertConfirmationModal();
convertPlaylist(false, Action.SHUFFLE);
};
_taskLoadPlaylist = TaskHandler<String, IPlatformPlaylistDetails>(
@@ -253,48 +266,76 @@ class RemotePlaylistFragment : MainFragment() {
}
}
private fun showConvertConfirmationModal() {
val remotePlaylist = _remotePlaylist;
private fun convertPlaylist(
savePlaylist: Boolean, action: Action, video: IPlatformVideo? = null
) {
val remotePlaylist = _remotePlaylist
if (remotePlaylist == null) {
UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading));
return;
UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading))
return
}
val c = context ?: return;
UIDialogs.showConfirmationDialog(c, "Conversion to local playlist is required for this action", {
setLoading(true);
val convert = {
setLoading(true)
UIDialogs.showDialogProgress(context) {
it.setText("Converting playlist..");
it.setProgress(0f);
it.setText("Converting playlist..")
it.setProgress(0f)
_fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val playlist = remotePlaylist.toPlaylist() { progress ->
val playlist = remotePlaylist.toPlaylist { progress ->
_fragment.lifecycleScope.launch(Dispatchers.Main) {
it.setProgress(progress.toDouble() / remotePlaylist.videoCount);
it.setProgress(progress.toDouble() / remotePlaylist.videoCount)
}
};
StatePlaylists.instance.playlistStore.save(playlist);
withContext(Dispatchers.Main) {
UIDialogs.toast("Playlist converted");
it.dismiss();
_fragment.navigate<PlaylistFragment>(playlist);
}
}
catch(ex: Throwable) {
UIDialogs.appToast("Failed to convert playlist.\n" + ex.message);
if (savePlaylist) {
StatePlaylists.instance.playlistStore.save(playlist)
}
_fragment.lifecycleScope.launch(Dispatchers.Main) {
UIDialogs.toast("Playlist converted")
it.dismiss()
_fragment.navigate<PlaylistFragment>(playlist)
when (action) {
Action.SHUFFLE -> StatePlayer.instance.setPlaylist(
playlist, focus = true, shuffle = true
)
Action.PLAY_ALL -> StatePlayer.instance.setPlaylist(
playlist, focus = true
)
Action.PLAY -> {
StatePlayer.instance.setPlaylist(
playlist, _videos.indexOf(video), true
)
}
Action.NONE -> {}
}
}
} catch (ex: Throwable) {
UIDialogs.appToast("Failed to convert playlist.\n" + ex.message)
}
}
}
});
}
if (remotePlaylist.videoCount > 100) {
val c = context ?: return
UIDialogs.showConfirmationDialog(
c, "Conversion to local playlist is required for this action", convert
)
} else {
convert()
}
}
private fun showConvertPlaylistButton() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
showConvertConfirmationModal();
convertPlaylist(true, Action.NONE);
}));
}
@@ -1,236 +0,0 @@
package com.futo.platformplayer.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.downloads.VideoExport
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.share
import com.futo.platformplayer.states.Announcement
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.stores.FragmentedStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.OffsetDateTime
import java.util.UUID
class ExportingService : Service() {
private val TAG = "ExportingService";
private val EXPORT_NOTIF_ID = 4;
private val EXPORT_NOTIF_TAG = "export";
private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel";
private val EXPORT_NOTIF_CHANNEL_NAME = "Export";
//Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
private var _notificationManager: NotificationManager? = null;
private var _notificationChannel: NotificationChannel? = null;
private val _client = ManagedHttpClient();
private var _started = false;
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Logger.i(TAG, "onStartCommand");
synchronized(this) {
if(_started)
return START_STICKY;
if(!FragmentedStorage.isInitialized) {
closeExportSession();
return START_NOT_STICKY;
}
_started = true;
}
setupNotificationRequirements();
_callOnStarted?.invoke(this);
_instance = this;
_scope.launch {
try {
doExporting();
}
catch(ex: Throwable) {
try {
StateAnnouncement.instance.registerAnnouncementSession(
Announcement(
"rootExportException",
"An root export service exception happened",
ex.message ?: "",
AnnouncementType.SESSION,
OffsetDateTime.now()
)
);
} catch(_: Throwable){}
}
};
return START_STICKY;
}
fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, EXPORT_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false);
this.setSound(null, null);
};
_notificationManager!!.createNotificationChannel(_notificationChannel!!);
}
override fun onCreate() {
Logger.i(TAG, "onCreate");
super.onCreate()
}
override fun onBind(p0: Intent?): IBinder? {
return null;
}
private suspend fun doExporting() {
Logger.i(TAG, "doExporting - Starting Exports");
val ignore = mutableListOf<VideoExport>();
var currentExport: VideoExport? = StateDownloads.instance.getExporting().firstOrNull();
while (currentExport != null)
{
try{
notifyExport(currentExport);
doExport(applicationContext, currentExport);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex);
currentExport.error = ex.message;
currentExport.changeState(VideoExport.State.ERROR);
ignore.add(currentExport);
//Give it a sec
Thread.sleep(500);
}
currentExport = StateDownloads.instance.getExporting().filter { !ignore.contains(it) }.firstOrNull();
}
Logger.i(TAG, "doExporting - Ending Exports");
stopService(this);
}
private suspend fun doExport(context: Context, export: VideoExport) {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
export.changeState(VideoExport.State.EXPORTING);
var lastNotifyTime: Long = 0L;
val file = export.export(context) { progress ->
export.progress = progress;
val currentTime = System.currentTimeMillis();
if (currentTime - lastNotifyTime > 500) {
notifyExport(export);
lastNotifyTime = currentTime;
}
}
export.changeState(VideoExport.State.COMPLETED);
Logger.i(TAG, "Export [${export.videoLocal.name}] finished");
StateDownloads.instance.removeExport(export);
notifyExport(export);
withContext(Dispatchers.Main) {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
file.share(this@ExportingService);
};
}
}
private fun notifyExport(export: VideoExport) {
val channel = _notificationChannel ?: return;
val bringUpIntent = Intent(this, MainActivity::class.java);
bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
bringUpIntent.action = "TAB";
bringUpIntent.putExtra("TAB", "Exports");
var builder = NotificationCompat.Builder(this, EXPORT_NOTIF_TAG)
.setSmallIcon(R.drawable.ic_export)
.setOngoing(true)
.setSilent(true)
.setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE))
.setContentTitle("${export.state}: ${export.videoLocal.name}")
.setContentText(export.getExportInfo())
.setProgress(100, (export.progress * 100).toInt(), export.progress == 0.0)
.setChannelId(channel.id)
val notif = builder.build();
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(EXPORT_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
} else {
startForeground(EXPORT_NOTIF_ID, notif);
}
}
fun closeExportSession() {
Logger.i(TAG, "closeExportSession");
stopForeground(STOP_FOREGROUND_REMOVE);
_notificationManager?.cancel(EXPORT_NOTIF_ID);
stopService();
_started = false;
super.stopSelf();
}
override fun onDestroy() {
Logger.i(TAG, "onDestroy");
_instance = null;
_scope.cancel("onDestroy");
super.onDestroy();
}
companion object {
private var _instance: ExportingService? = null;
private var _callOnStarted: ((ExportingService)->Unit)? = null;
@Synchronized
fun getOrCreateService(context: Context, handle: ((ExportingService)->Unit)? = null) {
if(!FragmentedStorage.isInitialized)
return;
if(_instance == null) {
_callOnStarted = handle;
val intent = Intent(context, ExportingService::class.java);
context.startForegroundService(intent);
}
else _instance?.let {
if(handle != null)
handle(it);
}
}
@Synchronized
fun getService() : ExportingService? {
return _instance;
}
@Synchronized
fun stopService(service: ExportingService? = null) {
(service ?: _instance)?.let {
if(_instance == it)
_instance = null;
it.closeExportSession();
}
}
}
}
@@ -445,9 +445,6 @@ class StateApp {
DownloadService.getOrCreateService(context);
}
Logger.i(TAG, "MainApp Started: Check [Exports]");
StateDownloads.instance.checkForExportTodos();
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
@@ -1,13 +1,13 @@
package com.futo.platformplayer.states
import android.content.ContentResolver
import android.content.Context
import android.os.StatFs
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
@@ -27,10 +27,14 @@ import com.futo.platformplayer.models.DiskUsage
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.PlaylistDownloaded
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.services.ExportingService
import com.futo.platformplayer.share
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
/***
* Used to maintain downloads
@@ -50,12 +54,8 @@ class StateDownloads {
private val _downloadPlaylists = FragmentedStorage.storeJson<PlaylistDownloadDescriptor>("playlistDownloads")
.load();
private val _exporting = FragmentedStorage.storeJson<VideoExport>("exporting")
.load();
private lateinit var _downloadedSet: HashSet<PlatformID>;
val onExportsChanged = Event0();
val onDownloadsChanged = Event0();
val onDownloadedChanged = Event0();
@@ -457,17 +457,6 @@ class StateDownloads {
}
}
try {
val currentDownloads = _downloaded.getItems().map { it.url }.toHashSet();
val exporting = _exporting.findItems { !currentDownloads.contains(it.videoLocal.url) };
for (export in exporting)
_exporting.delete(export);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to delete dangling export:", ex);
UIDialogs.toast("Failed to delete dangling export:\n" + ex);
}
return Pair(totalDeletedCount, totalDeleted);
}
@@ -475,66 +464,41 @@ class StateDownloads {
return _downloadsDirectory;
}
fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) {
var lastNotifyTime = -1L;
UIDialogs.showDialogProgress(context) {
it.setText("Exporting content..");
it.setProgress(0f);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val export = VideoExport(videoLocal, videoSource, audioSource, subtitleSource);
try {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
//Export
fun getExporting(): List<VideoExport> {
return _exporting.getItems();
}
fun checkForExportTodos() {
if(_exporting.hasItems()) {
StateApp.withContext {
ExportingService.getOrCreateService(it);
val file = export.export(context) { progress ->
val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress);
lastNotifyTime = now;
}
}
withContext(Dispatchers.Main) {
it.setProgress(100.0f)
it.dismiss()
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
file.share(context);
};
}
} catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${export.videoLocal.name}]: ${ex.message}", ex);
}
}
}
}
fun validateExport(export: VideoExport) {
if(_exporting.hasItem { it.videoLocal.url == export.videoLocal.url })
throw AlreadyQueuedException("Video [${export.videoLocal.name}] is already queued for export");
}
fun export(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, notify: Boolean = true) {
val shortName = if(videoLocal.name.length > 23)
videoLocal.name.substring(0, 20) + "...";
else
videoLocal.name;
val videoExport = VideoExport(videoLocal, videoSource, audioSource, subtitleSource);
try {
validateExport(videoExport);
_exporting.save(videoExport);
if(notify) {
UIDialogs.toast("Exporting [${shortName}]");
StateApp.withContext { ExportingService.getOrCreateService(it) };
onExportsChanged.emit();
}
}
catch (ex: AlreadyQueuedException) {
Logger.e(TAG, "File is already queued for export.", ex);
StateApp.withContext { ExportingService.getOrCreateService(it) };
}
catch(ex: Throwable) {
StateApp.withContext {
UIDialogs.showDialog(
it,
R.drawable.ic_error,
"Failed to start export due to:\n${ex.message}", null, null,
0,
UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY)
);
}
}
}
fun removeExport(export: VideoExport) {
_exporting.delete(export);
export.isCancelled = true;
onExportsChanged.emit();
}
companion object {
const val TAG = "StateDownloads";
@@ -1,7 +1,6 @@
package com.futo.platformplayer.views
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.AttributeSet
import android.view.View
@@ -49,6 +48,7 @@ class MonetizationView : LinearLayout {
private val _taskLoadMerchandise = TaskHandler<String, List<StoreItem>>(StateApp.instance.scopeGetter, { url ->
val client = ManagedHttpClient();
Logger.i(TAG, "Loading https://storecache.grayjay.app/StoreData?url=$url")
val result = client.get("https://storecache.grayjay.app/StoreData?url=$url")
if (!result.isOk) {
throw Exception("Failed to retrieve store data.");
@@ -33,6 +33,7 @@ open class PlaylistView : LinearLayout {
protected val _platformIndicator: PlatformIndicator;
protected val _textPlaylistName: TextView
protected val _textVideoCount: TextView
protected val _textVideoCountLabel: TextView;
protected val _textPlaylistItems: TextView
protected val _textChannelName: TextView
protected var _neopassAnimator: ObjectAnimator? = null;
@@ -62,6 +63,7 @@ open class PlaylistView : LinearLayout {
_platformIndicator = findViewById(R.id.thumbnail_platform);
_textPlaylistName = findViewById(R.id.text_playlist_name);
_textVideoCount = findViewById(R.id.text_video_count);
_textVideoCountLabel = findViewById(R.id.text_video_count_label);
_textChannelName = findViewById(R.id.text_channel_name);
_textPlaylistItems = findViewById(R.id.text_playlist_items);
_imageNeopassChannel = findViewById(R.id.image_neopass_channel);
@@ -137,7 +139,15 @@ open class PlaylistView : LinearLayout {
.crossfade()
.into(_imageThumbnail);
_textVideoCount.text = content.videoCount.toString();
if(content.videoCount >= 0) {
_textVideoCount.text = content.videoCount.toString();
_textVideoCount.visibility = View.VISIBLE;
_textVideoCountLabel.visibility = VISIBLE;
}
else {
_textVideoCount.visibility = View.GONE;
_textVideoCountLabel.visibility = GONE;
}
}
else {
currentPlaylist = null;
@@ -1,6 +1,7 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
@@ -46,7 +47,12 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
override fun bind(value: SelectablePlaylist) {
_textName.text = value.playlist.name;
_textMetadata.text = "${value.playlist.videoCount} " + _view.context.getString(R.string.videos);
if(value.playlist.videoCount >= 0) {
_textMetadata.text = "${value.playlist.videoCount} " + _view.context.getString(R.string.videos);
_textMetadata.visibility = View.VISIBLE;
}
else
_textMetadata.visibility = View.GONE;
_checkbox.value = value.selected;
val thumbnail = value.playlist.thumbnail;
@@ -16,6 +16,8 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.adapters.AnyAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<VideoLocal>(
@@ -57,10 +59,14 @@ class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<
return@changeExternalDownloadDirectory;
}
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateDownloads.instance.export(_viewGroup.context, v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
}
};
} else {
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateDownloads.instance.export(_viewGroup.context, v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
}
}
}
}
@@ -68,6 +68,7 @@
android:textColor="@color/gray_7f"/>
<TextView
android:id="@+id/text_video_count_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="8dp"
@@ -66,8 +66,8 @@
android:fontFamily="@font/inter_light"
tools:text="100"
android:textColor="@color/gray_7f"
app:layout_constraintRight_toLeftOf="@id/text_videos"
app:layout_constraintBottom_toBottomOf="@id/text_videos" />
app:layout_constraintRight_toLeftOf="@id/text_video_count_label"
app:layout_constraintBottom_toBottomOf="@id/text_video_count_label" />
<TextView
android:id="@+id/text_playlist"
@@ -80,10 +80,10 @@
android:textColor="@color/gray_e0"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/text_videos"/>
app:layout_constraintBottom_toTopOf="@id/text_video_count_label"/>
<TextView
android:id="@+id/text_videos"
android:id="@+id/text_video_count_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
+2 -2
View File
@@ -2,12 +2,12 @@
Package http is the main way for a plugin to make web requests, and is likely a package you will always need.
It offers several ways to make web requests as well as websocket connections.
Before you can use http you need to register it in your plugin config. See [Packages](_blank).
Before you can use http you need to register it in your plugin config. See [Packages](/app/src/main/java/com/futo/platformplayer/engine/packages).
## Basic Info
Underneath the http package by default exist two web clients. An authenticated client and a unauthenticated client.
The authenticated client has will apply headers and cookies if the user is logged in with your plugin.
See [Plugin Authentication](_blank).
See [Plugin Authentication](/docs/Authentication.md).
These two clients are always available even when the user is not logged in, meaning it behaves similar to the unauthenticated client and can safely use it either way.
>:warning: **Requests are synchronous**