WIP Watchlater sync

This commit is contained in:
Kelvin 2024-11-22 20:33:44 +01:00
parent 0034665965
commit 6cee33b449
9 changed files with 207 additions and 10 deletions

View file

@ -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;
}

View file

@ -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),

View file

@ -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();
}
};
}

View file

@ -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) {

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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