mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-19 19:14:51 +00:00
WIP Watchlater sync
This commit is contained in:
parent
0034665965
commit
6cee33b449
9 changed files with 207 additions and 10 deletions
|
@ -232,4 +232,43 @@ fun String.decodeUnicode(): String {
|
|||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun <T> smartMerge(targetArr: List<T>, toMerge: List<T>) : List<T>{
|
||||
val missingToMerge = toMerge.filter { !targetArr.contains(it) }.toList();
|
||||
val newArrResult = targetArr.toMutableList();
|
||||
|
||||
for(missing in missingToMerge) {
|
||||
val newIndex = findNewIndex(toMerge, newArrResult, missing);
|
||||
newArrResult.add(newIndex, missing);
|
||||
}
|
||||
|
||||
return newArrResult;
|
||||
}
|
||||
fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
|
||||
var originalIndex = originalArr.indexOf(item);
|
||||
var newIndex = -1;
|
||||
|
||||
for(i in originalIndex-1 downTo 0) {
|
||||
val previousItem = originalArr[i];
|
||||
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
|
||||
if(indexInNewArr >= 0) {
|
||||
newIndex = indexInNewArr + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(newIndex < 0) {
|
||||
for(i in originalIndex+1 until originalArr.size) {
|
||||
val previousItem = originalArr[i];
|
||||
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
|
||||
if(indexInNewArr >= 0) {
|
||||
newIndex = indexInNewArr - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(newIndex < 0)
|
||||
return originalArr.size;
|
||||
else
|
||||
return newIndex;
|
||||
}
|
|
@ -389,7 +389,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||
}),
|
||||
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
||||
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
||||
"All requests will be processed anonymously (unauthenticated), playback and history tracking will be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
UIDialogs.Action("Cancel", {
|
||||
StateApp.instance.setPrivacyMode(false);
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
|
|
|
@ -12,6 +12,7 @@ import android.widget.LinearLayout
|
|||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -23,6 +24,8 @@ import com.futo.platformplayer.states.StatePlaylists
|
|||
import com.futo.platformplayer.views.adapters.*
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class PlaylistsFragment : MainFragment() {
|
||||
|
@ -119,7 +122,9 @@ class PlaylistsFragment : MainFragment() {
|
|||
|
||||
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
|
||||
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
|
||||
updateWatchLater();
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
updateWatchLater();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -96,7 +96,7 @@ class WatchLaterFragment : MainFragment() {
|
|||
}
|
||||
|
||||
override fun onVideoOrderChanged(videos: List<IPlatformVideo>) {
|
||||
StatePlaylists.instance.updateWatchLater(ArrayList(videos.map { it as SerializedPlatformVideo }));
|
||||
StatePlaylists.instance.updateWatchLater(ArrayList(videos.map { it as SerializedPlatformVideo }), true);
|
||||
}
|
||||
override fun onVideoRemoved(video: IPlatformVideo) {
|
||||
if (video is SerializedPlatformVideo) {
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.states
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
|
@ -17,22 +18,27 @@ import com.futo.platformplayer.exceptions.ReconstructionException
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.smartMerge
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.StringDateMapStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
|
||||
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/***
|
||||
* Used to maintain playlists
|
||||
|
@ -50,6 +56,11 @@ class StatePlaylists {
|
|||
.load();
|
||||
private val _watchlistOrderStore = FragmentedStorage.get<StringArrayStorage>("watchListOrder"); //Temporary workaround to add order..
|
||||
|
||||
private val _watchLaterReorderTime = FragmentedStorage.get<StringStorage>("watchLaterReorderTime");
|
||||
private val _watchLaterAdds = FragmentedStorage.get<StringDateMapStorage>("watchLaterAdds");
|
||||
private val _watchLaterRemovals = FragmentedStorage.get<StringDateMapStorage>("watchLaterRemovals");
|
||||
|
||||
|
||||
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
|
||||
.withRestore(PlaylistBackup())
|
||||
.load();
|
||||
|
@ -59,6 +70,34 @@ class StatePlaylists {
|
|||
|
||||
val onWatchLaterChanged = Event0();
|
||||
|
||||
fun getWatchLaterAddTime(url: String): OffsetDateTime? {
|
||||
return _watchLaterAdds.get(url)
|
||||
}
|
||||
fun setWatchLaterAddTime(url: String, time: OffsetDateTime) {
|
||||
_watchLaterAdds.setAndSave(url, time);
|
||||
}
|
||||
fun getWatchLaterRemovalTime(url: String): OffsetDateTime? {
|
||||
return _watchLaterRemovals.get(url);
|
||||
}
|
||||
fun getWatchLaterLastReorderTime(): OffsetDateTime{
|
||||
val value = _watchLaterReorderTime.value;
|
||||
if(value.isEmpty())
|
||||
return OffsetDateTime.MIN;
|
||||
val tryParse = value.toLongOrNull() ?: 0;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC);
|
||||
}
|
||||
private fun setWatchLaterReorderTime() {
|
||||
val now = OffsetDateTime.now().toEpochSecond();
|
||||
_watchLaterReorderTime.setAndSave(now.toString());
|
||||
}
|
||||
|
||||
fun getWatchLaterOrdering() = _watchlistOrderStore.getAllValues().toList();
|
||||
|
||||
fun updateWatchLaterOrdering(order: List<String>) {
|
||||
_watchlistOrderStore.set(*smartMerge(order, getWatchLaterOrdering()).toTypedArray());
|
||||
_watchlistOrderStore.save();
|
||||
}
|
||||
|
||||
fun toMigrateCheck(): List<ManagedStore<*>> {
|
||||
return listOf(playlistStore, _watchlistStore);
|
||||
}
|
||||
|
@ -68,12 +107,14 @@ class StatePlaylists {
|
|||
return _watchlistStore.getItems().sortedBy { order.indexOf(it.url) };
|
||||
}
|
||||
}
|
||||
fun updateWatchLater(updated: List<SerializedPlatformVideo>) {
|
||||
fun updateWatchLater(updated: List<SerializedPlatformVideo>, isUserInteraction: Boolean = false) {
|
||||
var wasModified = false;
|
||||
synchronized(_watchlistStore) {
|
||||
//_watchlistStore.deleteAll();
|
||||
val existing = _watchlistStore.getItems();
|
||||
val toAdd = updated.filter { u -> !existing.any { u.url == it.url } };
|
||||
val toRemove = existing.filter { u -> !updated.any { u.url == it.url } };
|
||||
wasModified = toAdd.size > 0 || toRemove.size > 0;
|
||||
Logger.i(TAG, "WatchLater changed:\nTo Add:\n" +
|
||||
(if(toAdd.size == 0) "None" else toAdd.map { " + " + it.name }.joinToString("\n")) +
|
||||
"\nTo Remove:\n" +
|
||||
|
@ -86,6 +127,11 @@ class StatePlaylists {
|
|||
}
|
||||
onWatchLaterChanged.emit();
|
||||
|
||||
if(isUserInteraction) {
|
||||
setWatchLaterReorderTime();
|
||||
broadcastWatchLater(!wasModified);
|
||||
}
|
||||
|
||||
if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
|
||||
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
|
||||
}
|
||||
|
@ -96,32 +142,56 @@ class StatePlaylists {
|
|||
return _watchlistStore.getItems().firstOrNull { it.url == url };
|
||||
}
|
||||
}
|
||||
fun removeFromWatchLater(url: String) {
|
||||
fun removeFromWatchLater(url: String, isUserInteraction: Boolean = false) {
|
||||
val item = getWatchLaterFromUrl(url);
|
||||
if(item != null){
|
||||
removeFromWatchLater(item);
|
||||
removeFromWatchLater(item, isUserInteraction);
|
||||
}
|
||||
}
|
||||
fun removeFromWatchLater(video: SerializedPlatformVideo) {
|
||||
fun removeFromWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, time: OffsetDateTime? = null) {
|
||||
synchronized(_watchlistStore) {
|
||||
_watchlistStore.delete(video);
|
||||
_watchlistOrderStore.set(*_watchlistOrderStore.values.filter { it != video.url }.toTypedArray());
|
||||
_watchlistOrderStore.save();
|
||||
if(time != null)
|
||||
_watchLaterRemovals.setAndSave(video.url, time);
|
||||
}
|
||||
onWatchLaterChanged.emit();
|
||||
|
||||
if(isUserInteraction) {
|
||||
val now = OffsetDateTime.now();
|
||||
if(time == null) {
|
||||
_watchLaterRemovals.setAndSave(video.url, now);
|
||||
broadcastWatchLaterRemoval(video.url, now);
|
||||
}
|
||||
else
|
||||
broadcastWatchLaterRemoval(video.url, time);
|
||||
}
|
||||
|
||||
if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
|
||||
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
|
||||
}
|
||||
}
|
||||
fun addToWatchLater(video: SerializedPlatformVideo) {
|
||||
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1) {
|
||||
synchronized(_watchlistStore) {
|
||||
_watchlistStore.saveAsync(video);
|
||||
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
|
||||
if(orderPosition == -1)
|
||||
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
|
||||
else {
|
||||
val existing = _watchlistOrderStore.getAllValues().toMutableList();
|
||||
existing.add(orderPosition, video.url);
|
||||
_watchlistOrderStore.set(*existing.toTypedArray());
|
||||
}
|
||||
_watchlistOrderStore.save();
|
||||
}
|
||||
onWatchLaterChanged.emit();
|
||||
|
||||
if(isUserInteraction) {
|
||||
val now = OffsetDateTime.now();
|
||||
_watchLaterAdds.setAndSave(video.url, now);
|
||||
broadcastWatchLaterAddition(video, now);
|
||||
}
|
||||
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
}
|
||||
|
||||
|
@ -151,6 +221,36 @@ class StatePlaylists {
|
|||
}
|
||||
}
|
||||
|
||||
private fun broadcastWatchLater(orderOnly: Boolean = false) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
if(orderOnly) listOf() else getWatchLater(),
|
||||
if(orderOnly) mapOf() else _watchLaterAdds.all(),
|
||||
if(orderOnly) mapOf() else _watchLaterRemovals.all(),
|
||||
getWatchLaterLastReorderTime().toEpochSecond(),
|
||||
_watchlistOrderStore.values.toList()));
|
||||
};
|
||||
}
|
||||
private fun broadcastWatchLaterAddition(video: SerializedPlatformVideo, time: OffsetDateTime) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
listOf(video),
|
||||
mapOf(Pair(video.url, time.toEpochSecond())),
|
||||
mapOf(),
|
||||
|
||||
))
|
||||
};
|
||||
}
|
||||
private fun broadcastWatchLaterRemoval(url: String, time: OffsetDateTime) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
listOf(),
|
||||
mapOf(Pair(url, time.toEpochSecond())),
|
||||
mapOf()
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist {
|
||||
val channel = StatePlatform.instance.getChannel(channelUrl).await();
|
||||
return createPlaylistFromChannel(channel, onPage);
|
||||
|
|
|
@ -13,5 +13,6 @@ class GJSyncOpcodes {
|
|||
val syncHistory: UByte = 203.toUByte();
|
||||
val syncSubscriptionGroups: UByte = 204.toUByte();
|
||||
val syncPlaylists: UByte = 205.toUByte();
|
||||
val syncWatchLater: UByte = 206.toUByte();
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ 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
|
||||
|
@ -21,6 +22,7 @@ import com.futo.platformplayer.sync.models.SendToDevicePackage
|
|||
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
@ -283,6 +285,38 @@ class SyncSession : IAuthorizable {
|
|||
}
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncWatchLater -> {
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val pack = Serializer.json.decodeFromString<SyncWatchLaterPackage>(json);
|
||||
|
||||
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
|
||||
|
||||
val allExisting = StatePlaylists.instance.getWatchLater();
|
||||
for(video in pack.videos) {
|
||||
val existing = allExisting.firstOrNull { it.url == video.url };
|
||||
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
|
||||
|
||||
if(existing == null) {
|
||||
StatePlaylists.instance.addToWatchLater(video, false);
|
||||
if(time > OffsetDateTime.MIN)
|
||||
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
|
||||
}
|
||||
}
|
||||
for(removal in pack.videoRemovals) {
|
||||
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
|
||||
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
|
||||
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC);
|
||||
if(creation < removalTime)
|
||||
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
|
||||
}
|
||||
|
||||
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC);
|
||||
if(StatePlaylists.instance.getWatchLaterLastReorderTime() < packReorderTime && pack.ordering != null)
|
||||
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()));
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncHistory -> {
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
package com.futo.platformplayer.sync.models
|
||||
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Dictionary
|
||||
|
||||
@Serializable
|
||||
class SyncWatchLaterPackage(
|
||||
var videos: List<SerializedPlatformVideo>,
|
||||
var videoAdds: Map<String, Long>,
|
||||
var videoRemovals: Map<String, Long>,
|
||||
var reorderTime: Long = 0,
|
||||
var ordering: List<String>? = null
|
||||
)
|
|
@ -1 +1 @@
|
|||
Subproject commit 0ce91be276681ab82d26f9471523beab6b2a0a00
|
||||
Subproject commit d6d3b709b8fe02ea203b20192215e888cc84042b
|
Loading…
Add table
Reference in a new issue