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

@ -233,3 +233,42 @@ fun String.decodeUnicode(): String {
} }
return sb.toString() 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 }, { 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", 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", { UIDialogs.Action("Cancel", {
StateApp.instance.setPrivacyMode(false); StateApp.instance.setPrivacyMode(false);
}, UIDialogs.ActionStyle.NONE), }, UIDialogs.ActionStyle.NONE),

View File

@ -12,6 +12,7 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R 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.adapters.*
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class PlaylistsFragment : MainFragment() { 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)); }; findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) { StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateWatchLater(); updateWatchLater();
}
}; };
} }

View File

@ -96,7 +96,7 @@ class WatchLaterFragment : MainFragment() {
} }
override fun onVideoOrderChanged(videos: List<IPlatformVideo>) { 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) { override fun onVideoRemoved(video: IPlatformVideo) {
if (video is SerializedPlatformVideo) { if (video is SerializedPlatformVideo) {

View File

@ -3,6 +3,7 @@ package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@ -17,22 +18,27 @@ import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringDateMapStorage 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.ManagedStore
import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset
/*** /***
* Used to maintain playlists * Used to maintain playlists
@ -50,6 +56,11 @@ class StatePlaylists {
.load(); .load();
private val _watchlistOrderStore = FragmentedStorage.get<StringArrayStorage>("watchListOrder"); //Temporary workaround to add order.. 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") val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup()) .withRestore(PlaylistBackup())
.load(); .load();
@ -59,6 +70,34 @@ class StatePlaylists {
val onWatchLaterChanged = Event0(); 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<*>> { fun toMigrateCheck(): List<ManagedStore<*>> {
return listOf(playlistStore, _watchlistStore); return listOf(playlistStore, _watchlistStore);
} }
@ -68,12 +107,14 @@ class StatePlaylists {
return _watchlistStore.getItems().sortedBy { order.indexOf(it.url) }; 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) { synchronized(_watchlistStore) {
//_watchlistStore.deleteAll(); //_watchlistStore.deleteAll();
val existing = _watchlistStore.getItems(); val existing = _watchlistStore.getItems();
val toAdd = updated.filter { u -> !existing.any { u.url == it.url } }; val toAdd = updated.filter { u -> !existing.any { u.url == it.url } };
val toRemove = existing.filter { u -> !updated.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" + Logger.i(TAG, "WatchLater changed:\nTo Add:\n" +
(if(toAdd.size == 0) "None" else toAdd.map { " + " + it.name }.joinToString("\n")) + (if(toAdd.size == 0) "None" else toAdd.map { " + " + it.name }.joinToString("\n")) +
"\nTo Remove:\n" + "\nTo Remove:\n" +
@ -86,6 +127,11 @@ class StatePlaylists {
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
if(isUserInteraction) {
setWatchLaterReorderTime();
broadcastWatchLater(!wasModified);
}
if(StateDownloads.instance.getWatchLaterDescriptor() != null) { if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
} }
@ -96,32 +142,56 @@ class StatePlaylists {
return _watchlistStore.getItems().firstOrNull { it.url == url }; return _watchlistStore.getItems().firstOrNull { it.url == url };
} }
} }
fun removeFromWatchLater(url: String) { fun removeFromWatchLater(url: String, isUserInteraction: Boolean = false) {
val item = getWatchLaterFromUrl(url); val item = getWatchLaterFromUrl(url);
if(item != null){ if(item != null){
removeFromWatchLater(item); removeFromWatchLater(item, isUserInteraction);
} }
} }
fun removeFromWatchLater(video: SerializedPlatformVideo) { fun removeFromWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, time: OffsetDateTime? = null) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
_watchlistStore.delete(video); _watchlistStore.delete(video);
_watchlistOrderStore.set(*_watchlistOrderStore.values.filter { it != video.url }.toTypedArray()); _watchlistOrderStore.set(*_watchlistOrderStore.values.filter { it != video.url }.toTypedArray());
_watchlistOrderStore.save(); _watchlistOrderStore.save();
if(time != null)
_watchLaterRemovals.setAndSave(video.url, time);
} }
onWatchLaterChanged.emit(); 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) { if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
} }
} }
fun addToWatchLater(video: SerializedPlatformVideo) { fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
_watchlistStore.saveAsync(video); _watchlistStore.saveAsync(video);
if(orderPosition == -1)
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray()); _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(); _watchlistOrderStore.save();
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
if(isUserInteraction) {
val now = OffsetDateTime.now();
_watchLaterAdds.setAndSave(video.url, now);
broadcastWatchLaterAddition(video, now);
}
StateDownloads.instance.checkForOutdatedPlaylists(); 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 { suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist {
val channel = StatePlatform.instance.getChannel(channelUrl).await(); val channel = StatePlatform.instance.getChannel(channelUrl).await();
return createPlaylistFromChannel(channel, onPage); return createPlaylistFromChannel(channel, onPage);

View File

@ -13,5 +13,6 @@ class GJSyncOpcodes {
val syncHistory: UByte = 203.toUByte(); val syncHistory: UByte = 203.toUByte();
val syncSubscriptionGroups: UByte = 204.toUByte(); val syncSubscriptionGroups: UByte = 204.toUByte();
val syncPlaylists: UByte = 205.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.HistoryVideo
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateHistory 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.SyncPlaylistsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString 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 -> { GJSyncOpcodes.syncHistory -> {
val dataBody = ByteArray(data.remaining()); val dataBody = ByteArray(data.remaining());
data.get(dataBody); 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