diff --git a/playlet-lib/src/components/ContentNode/FeedContentNode.bs b/playlet-lib/src/components/ContentNode/FeedContentNode.bs
new file mode 100644
index 00000000..ff7465e9
--- /dev/null
+++ b/playlet-lib/src/components/ContentNode/FeedContentNode.bs
@@ -0,0 +1,21 @@
+import "pkg:/components/VideoFeed/VideoRowCell/FeedContentNodeUtils.bs"
+import "pkg:/source/utils/Types.bs"
+
+function OnReactivity()
+ if m.reactivity <> invalid
+ for each item in m.reactivity
+ item.node.unobserveFieldScoped(item.field)
+ end for
+ end if
+
+ m.reactivity = m.top.reactivity
+ if m.reactivity <> invalid
+ for each item in m.reactivity
+ item.node.observeFieldScoped(item.field, FuncName(ResetNode))
+ end for
+ end if
+end function
+
+function ResetNode()
+ FeedContentNodeUtils.ResetSelf(m.top)
+end function
diff --git a/playlet-lib/src/components/ContentNode/FeedContentNode.xml b/playlet-lib/src/components/ContentNode/FeedContentNode.xml
index 88ae1c5e..e86f83c9 100644
--- a/playlet-lib/src/components/ContentNode/FeedContentNode.xml
+++ b/playlet-lib/src/components/ContentNode/FeedContentNode.xml
@@ -3,5 +3,6 @@
+
\ No newline at end of file
diff --git a/playlet-lib/src/components/ContentNode/VideoContentNode.xml b/playlet-lib/src/components/ContentNode/VideoContentNode.xml
index fe82985b..f4215916 100644
--- a/playlet-lib/src/components/ContentNode/VideoContentNode.xml
+++ b/playlet-lib/src/components/ContentNode/VideoContentNode.xml
@@ -5,6 +5,7 @@
+
diff --git a/playlet-lib/src/components/ContentNode/VideoProgressContentNode.xml b/playlet-lib/src/components/ContentNode/VideoProgressContentNode.xml
new file mode 100644
index 00000000..80cc620a
--- /dev/null
+++ b/playlet-lib/src/components/ContentNode/VideoProgressContentNode.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playlet-lib/src/components/MainScene.transpiled.xml b/playlet-lib/src/components/MainScene.transpiled.xml
index be0af11e..c641d430 100644
--- a/playlet-lib/src/components/MainScene.transpiled.xml
+++ b/playlet-lib/src/components/MainScene.transpiled.xml
@@ -44,6 +44,7 @@
+
diff --git a/playlet-lib/src/components/MainScene.xml b/playlet-lib/src/components/MainScene.xml
index 8b992195..e213ca17 100644
--- a/playlet-lib/src/components/MainScene.xml
+++ b/playlet-lib/src/components/MainScene.xml
@@ -52,12 +52,15 @@
+ preferences="bind:../Preferences"
+ continueWatching="bind:../ContinueWatching" />
+
+ bookmarks="bind:../Bookmarks"
+ continueWatching="bind:../ContinueWatching" />
\ No newline at end of file
diff --git a/playlet-lib/src/components/MainScene_bindings.transpiled.brs b/playlet-lib/src/components/MainScene_bindings.transpiled.brs
index 63f05726..ad66de5d 100644
--- a/playlet-lib/src/components/MainScene_bindings.transpiled.brs
+++ b/playlet-lib/src/components/MainScene_bindings.transpiled.brs
@@ -16,11 +16,15 @@ function InitializeBindings()
"PlayQueue": {
"invidious": "../Invidious",
"notifications": "../Notifications",
- "preferences": "../Preferences"
+ "preferences": "../Preferences",
+ "continueWatching": "../ContinueWatching"
},
"SearchHistory": {
"preferences": "../Preferences"
},
+ "ContinueWatching": {
+ "preferences": "../Preferences"
+ },
"Invidious": {
"webServer": "../WebServer",
"applicationInfo": "../ApplicationInfo",
@@ -32,7 +36,8 @@ function InitializeBindings()
"invidious": "../Invidious",
"preferences": "../Preferences",
"playQueue": "../PlayQueue",
- "bookmarks": "../Bookmarks"
+ "bookmarks": "../Bookmarks",
+ "continueWatching": "../ContinueWatching"
}
}
}
diff --git a/playlet-lib/src/components/PlayQueue/PlayQueue.bs b/playlet-lib/src/components/PlayQueue/PlayQueue.bs
index 01fbbfc6..a55a133f 100644
--- a/playlet-lib/src/components/PlayQueue/PlayQueue.bs
+++ b/playlet-lib/src/components/PlayQueue/PlayQueue.bs
@@ -192,6 +192,7 @@ function LoadPlaylist(playlist as object) as void
task = AsyncTask.Start(Tasks.PlaylistContentTask, {
content: playlist
invidious: m.top.invidious
+ continueWatchingContent: m.top.continueWatching.content
}, OnPlaylistContentTaskResult)
m.pendingLoadTasks[task.id] = task
end function
diff --git a/playlet-lib/src/components/PlayQueue/PlayQueue.xml b/playlet-lib/src/components/PlayQueue/PlayQueue.xml
index 1d3f7afd..aee219ac 100644
--- a/playlet-lib/src/components/PlayQueue/PlayQueue.xml
+++ b/playlet-lib/src/components/PlayQueue/PlayQueue.xml
@@ -4,6 +4,7 @@
+
diff --git a/playlet-lib/src/components/PlaylistView/PlaylistContentTask.bs b/playlet-lib/src/components/PlaylistView/PlaylistContentTask.bs
index 6c69a0ad..f3bcadc2 100644
--- a/playlet-lib/src/components/PlaylistView/PlaylistContentTask.bs
+++ b/playlet-lib/src/components/PlaylistView/PlaylistContentTask.bs
@@ -6,6 +6,7 @@ import "pkg:/components/Services/Invidious/InvidiousToContentNode.bs"
function PlaylistContentTask(input as object) as object
contentNode = input.content
invidiousNode = input.invidious
+ continueWatchingContent = input.continueWatchingContent
if m.top.cancel
contentNode.loadState = FeedLoadState.None
@@ -33,7 +34,7 @@ function PlaylistContentTask(input as object) as object
end if
instance = service.GetInstance()
- InvidiousContent.ToPlaylistContentNode(contentNode, metadata, instance)
+ InvidiousContent.ToPlaylistContentNode(contentNode, metadata, instance, continueWatchingContent)
childCount = contentNode.getChildCount()
if metadata.videos.Count() > 0 or childCount < metadata.videoCount
diff --git a/playlet-lib/src/components/PlaylistView/PlaylistView.bs b/playlet-lib/src/components/PlaylistView/PlaylistView.bs
index 1157ef60..c41460d7 100644
--- a/playlet-lib/src/components/PlaylistView/PlaylistView.bs
+++ b/playlet-lib/src/components/PlaylistView/PlaylistView.bs
@@ -226,6 +226,7 @@ function LoadPlaylist() as void
m.playlistLoadTask = AsyncTask.Start(Tasks.PlaylistContentTask, {
content: m.top.content
invidious: m.top.invidious
+ continueWatchingContent: m.top.continueWatching.content
}, OnPlaylistContentTaskResult)
end function
diff --git a/playlet-lib/src/components/PlaylistView/PlaylistView.xml b/playlet-lib/src/components/PlaylistView/PlaylistView.xml
index ddc6f216..0e167304 100644
--- a/playlet-lib/src/components/PlaylistView/PlaylistView.xml
+++ b/playlet-lib/src/components/PlaylistView/PlaylistView.xml
@@ -5,6 +5,7 @@
+
diff --git a/playlet-lib/src/components/PlaylistView/PlaylistViewCell.bs b/playlet-lib/src/components/PlaylistView/PlaylistViewCell.bs
index b478ace9..17cbc4c9 100644
--- a/playlet-lib/src/components/PlaylistView/PlaylistViewCell.bs
+++ b/playlet-lib/src/components/PlaylistView/PlaylistViewCell.bs
@@ -4,6 +4,7 @@ import "pkg:/source/utils/Types.bs"
function Init()
m.durationRect = m.top.FindNode("durationRect")
m.durationLabel = m.top.FindNode("durationLabel")
+ m.progressRect = m.top.FindNode("progressRect")
end function
function OnContentSet() as void
@@ -23,6 +24,8 @@ function OnContentSet() as void
else
m.top.durationRectVisible = false
end if
+
+ SetupProgress(content)
end function
function SetDurationText(text as string) as void
@@ -41,3 +44,31 @@ function SetDurationText(text as string) as void
rect.width = size.width + 16
rect.translation = [rectParent.width - rect.width, rect.translation[1]]
end function
+
+function SetupProgress(content as object)
+ if m.progressNode <> invalid
+ m.progressNode.unobserveFieldScoped("timestamp")
+ m.progressNode = invalid
+ end if
+
+ m.progressNode = content.progressNode
+ if m.progressNode <> invalid
+ m.progressNode.observeFieldScoped("timestamp", FuncName(SetProgress))
+ end if
+ SetProgress()
+end function
+
+function SetProgress() as void
+ if m.progressNode = invalid
+ m.progressRect.scale = [0, 1]
+ return
+ end if
+
+ duration = m.progressNode.duration
+ timestamp = m.progressNode.timestamp
+ progress = 0
+ if duration > 0
+ progress = timestamp / duration
+ end if
+ m.progressRect.scale = [progress, 1]
+end function
diff --git a/playlet-lib/src/components/PlaylistView/PlaylistViewCell.xml b/playlet-lib/src/components/PlaylistView/PlaylistViewCell.xml
index a42793c5..05851ebc 100644
--- a/playlet-lib/src/components/PlaylistView/PlaylistViewCell.xml
+++ b/playlet-lib/src/components/PlaylistView/PlaylistViewCell.xml
@@ -29,7 +29,13 @@
font="font:SmallestSystemFont"
translation="[8, 0]" />
-
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.xml b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.xml
index f50e4961..39ffae0e 100644
--- a/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.xml
+++ b/playlet-lib/src/components/Screens/SettingsScreen/HomeScreenEditor/HomeScreenEditor.xml
@@ -19,7 +19,7 @@
+ translation="[165,40]">
diff --git a/playlet-lib/src/components/Screens/SettingsScreen/InvidiousInstance/InvidiousInstanceTesting/InvidiousInstanceTestingTask.bs b/playlet-lib/src/components/Screens/SettingsScreen/InvidiousInstance/InvidiousInstanceTesting/InvidiousInstanceTestingTask.bs
index 402de4ce..9d5b74c6 100644
--- a/playlet-lib/src/components/Screens/SettingsScreen/InvidiousInstance/InvidiousInstanceTesting/InvidiousInstanceTestingTask.bs
+++ b/playlet-lib/src/components/Screens/SettingsScreen/InvidiousInstance/InvidiousInstanceTesting/InvidiousInstanceTestingTask.bs
@@ -280,7 +280,8 @@ function CanFetchVideoThumbails(instance as string, testNode as object) as void
return
end if
- contentNode = InvidiousContent.ToVideoContentNode(invalid, json, instance)
+ continueWatchingContentMock = CreateObject("roSGNode", "ContentNode")
+ contentNode = InvidiousContent.ToVideoContentNode(invalid, json, instance, continueWatchingContentMock)
thumbnail = contentNode.thumbnail
if StringUtils.IsNullOrEmpty(thumbnail)
testNode.state = "failed"
diff --git a/playlet-lib/src/components/Services/ContinueWatching/ContinueWatching.bs b/playlet-lib/src/components/Services/ContinueWatching/ContinueWatching.bs
new file mode 100644
index 00000000..757db0ca
--- /dev/null
+++ b/playlet-lib/src/components/Services/ContinueWatching/ContinueWatching.bs
@@ -0,0 +1,128 @@
+import "pkg:/components/Services/ContinueWatching/ContinueWatchingUtils.bs"
+import "pkg:/source/utils/Logging.bs"
+import "pkg:/source/utils/MathUtils.bs"
+import "pkg:/source/utils/RegistryUtils.bs"
+import "pkg:/source/utils/Types.bs"
+
+function Init()
+ m.top.content = m.top.findNode("content")
+ m.continueWatchingString = ""
+ ' For content changes, use a timer of one second to avoid rapid fire of events
+ m.changedEventTimer = CreateObject("roSGNode", "Timer")
+ m.changedEventTimer.duration = 1
+ m.changedEventTimer.ObserveField("fire", FuncName(TriggerChangedEvent))
+
+ m.top.content.observeField("change", FuncName(OnContentChange))
+end function
+
+function OnNodeReady()
+ Load()
+end function
+
+function Load() as void
+ isEnabled = m.preferences["continue_watching.enabled"] = true
+ if not isEnabled
+ return
+ end if
+
+ continueWatchingString = RegistryUtils.Read(RegistryUtils.CONTINUE_WATCHING)
+ if continueWatchingString = invalid
+ return
+ end if
+
+ m.continueWatchingString = continueWatchingString
+ continueWatching = ParseJson(continueWatchingString)
+ if continueWatching = invalid
+ LogWarn("Failed to parse continue watching json")
+ return
+ end if
+
+ expirationDays = ValidInt(m.preferences["continue_watching.expiration"])
+ maxVideos = MathUtils.Min(ValidInt(m.preferences["continue_watching.max_videos"]), ContinueWatchingUtils.MAX_VIDEOS)
+
+ nodes = []
+ for each video in continueWatching.videos
+ date = ValidInt(video.date)
+ if IsVideoExpired(date, expirationDays)
+ continue for
+ end if
+
+ node = CreateObject("roSGNode", "VideoProgressContentNode")
+ node.id = video.id
+ node.videoId = video.id
+ node.date = date
+ node.timestamp = ValidInt(video.timestamp)
+ node.duration = ValidInt(video.duration)
+ nodes.push(node)
+
+ if nodes.Count() >= maxVideos
+ exit for
+ end if
+ end for
+
+ content = m.top.content
+ content.removeChildrenIndex(content.getChildCount(), 0)
+ content.appendChildren(nodes)
+end function
+
+function Save() as void
+ nodes = m.top.content.getChildren(-1, 0)
+ if nodes.Count() = 0
+ RegistryUtils.Delete(RegistryUtils.CONTINUE_WATCHING)
+ return
+ end if
+
+ videos = []
+ for each node in nodes
+ video = {}
+ video.id = node.videoId
+ video.date = node.date
+ video.timestamp = node.timestamp
+ video.duration = node.duration
+ videos.push(video)
+ end for
+
+ videos.SortBy("date", "r")
+
+ ' The registry has a limit of 32KB, so we need to limit the number of videos
+ maxVideos = MathUtils.Min(ValidInt(m.preferences["continue_watching.max_videos"]), ContinueWatchingUtils.MAX_VIDEOS)
+ while videos.Count() > maxVideos
+ videos.pop()
+ end while
+
+ continueWatchingString = FormatJson({
+ "__version": m.top.__version
+ "videos": videos
+ })
+
+ if m.continueWatchingString = continueWatchingString
+ return
+ end if
+
+ RegistryUtils.Write(RegistryUtils.CONTINUE_WATCHING, continueWatchingString)
+ m.continueWatchingString = continueWatchingString
+end function
+
+function IsVideoExpired(watchDateInSeconds as integer, expirationDays as integer) as boolean
+ if watchDateInSeconds = 0 or expirationDays = 0
+ LogWarn("Invalid watch date:", watchDateInSeconds, "or expiration days:", expirationDays)
+ return false
+ end if
+
+ now = CreateObject("roDateTime")
+ nowSeconds = now.AsSeconds()
+
+ return nowSeconds - watchDateInSeconds > expirationDays * 24 * 60 * 60
+end function
+
+function OnContentChange(event as object)
+ change = event.getData()
+ if change.Operation <> "modify"
+ m.changedEventTimer.control = "stop"
+ m.changedEventTimer.control = "start"
+ end if
+end function
+
+function TriggerChangedEvent()
+ m.top.changed = true
+end function
diff --git a/playlet-lib/src/components/Services/ContinueWatching/ContinueWatching.xml b/playlet-lib/src/components/Services/ContinueWatching/ContinueWatching.xml
new file mode 100644
index 00000000..c3fb6b0c
--- /dev/null
+++ b/playlet-lib/src/components/Services/ContinueWatching/ContinueWatching.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playlet-lib/src/components/Services/ContinueWatching/ContinueWatchingUtils.bs b/playlet-lib/src/components/Services/ContinueWatching/ContinueWatchingUtils.bs
new file mode 100644
index 00000000..7ace86a3
--- /dev/null
+++ b/playlet-lib/src/components/Services/ContinueWatching/ContinueWatchingUtils.bs
@@ -0,0 +1,53 @@
+namespace ContinueWatchingUtils
+
+ const MAX_VIDEOS = 100
+
+ function GetOrCreateNodeForVideo(continueWatchingNode as object, videoId as string, maxVideos = 100 as integer) as object
+ content = continueWatchingNode.content
+ node = content.findNode(videoId)
+ if node <> invalid
+ return node
+ end if
+
+ node = CreateObject("roSGNode", "VideoProgressContentNode")
+ node.id = videoId
+ node.videoId = videoId
+ content.insertChild(node, 0)
+
+ nodeCount = content.getChildCount()
+ if nodeCount > maxVideos
+ excess = nodeCount - maxVideos
+ content.removeChildrenIndex(excess, maxVideos)
+ end if
+
+ return node
+ end function
+
+ function MarkWatchDate(progressNode as object)
+ date = CreateObject("roDateTime")
+ progressNode.date = date.asSeconds()
+ parent = progressNode.getParent()
+ if parent <> invalid
+ parent.removeChild(progressNode)
+ parent.insertChild(progressNode, 0)
+ end if
+ end function
+
+ function RemoveNodeForVideo(continueWatchingNode as object, videoId as string)
+ content = continueWatchingNode.content
+ node = content.findNode(videoId)
+ if node <> invalid
+ content.removeChild(node)
+ end if
+ end function
+
+ function Clear(continueWatchingNode as object)
+ content = continueWatchingNode.content
+ nodeCount = content.getChildCount()
+ if nodeCount > 0
+ content.removeChildrenIndex(nodeCount, 0)
+ end if
+
+ continueWatchingNode.save = true
+ end function
+end namespace
diff --git a/playlet-lib/src/components/Services/Invidious/Invidious.bs b/playlet-lib/src/components/Services/Invidious/Invidious.bs
index 08da3450..f5981050 100644
--- a/playlet-lib/src/components/Services/Invidious/Invidious.bs
+++ b/playlet-lib/src/components/Services/Invidious/Invidious.bs
@@ -3,11 +3,21 @@ import "pkg:/source/AsyncTask/AsyncTask.bs"
import "pkg:/source/AsyncTask/Tasks.bs"
function Init()
- m.top.apiDefinitions = ParseJson(ReadAsciiFile("libpkg:/config/invidious_video_api.yaml"))
+ apiDefinitions = LoadApiDefinition("libpkg:/config/invidious_video_api.yaml", "Invidious")
+ apiDefinitions.Append(LoadApiDefinition("libpkg:/config/local_video_api.yaml", "Local"))
+ m.top.apiDefinitions = apiDefinitions
m.service = new Invidious.InvidiousService(m.top)
m.top.authToken = m.service.GetAuthToken()
end function
+function LoadApiDefinition(path as string, apiType as string) as object
+ apiDefinitions = ParseJson(ReadAsciiFile(path))
+ for each key in apiDefinitions
+ apiDefinitions[key].apiType = apiType
+ end for
+ return apiDefinitions
+end function
+
function GetCurrentInstance(_unused as dynamic) as string
return m.service.GetInstance()
end function
diff --git a/playlet-lib/src/components/Services/Invidious/InvidiousService.bs b/playlet-lib/src/components/Services/Invidious/InvidiousService.bs
index 3f2f2f74..c05a7e57 100644
--- a/playlet-lib/src/components/Services/Invidious/InvidiousService.bs
+++ b/playlet-lib/src/components/Services/Invidious/InvidiousService.bs
@@ -228,7 +228,11 @@ namespace Invidious
}
end if
- instance = m.GetInstance()
+ if feedSource.apiType = "Local"
+ instance = "http://127.0.0.1:8888"
+ else
+ instance = m.GetInstance()
+ end if
request = HttpClient.Get(instance + endpoint.url)
@@ -240,8 +244,11 @@ namespace Invidious
error: ERROR_NOT_AUTHENTICATED
}
end if
- request.Url(authToken.instance + endpoint.url)
- request.Headers(m.GetAuthenticationHeaders(authToken.token))
+
+ if feedSource.apiType <> "Local"
+ request.Url(authToken.instance + endpoint.url)
+ request.Headers(m.GetAuthenticationHeaders(authToken.token))
+ end if
end if
if endpoint.queryParams <> invalid
@@ -607,6 +614,20 @@ We apologize for the inconvenience.`
return request.Await()
end function
+
+ function GetWatchHistory(page as integer, max_results as integer, cancellation = invalid as object) as object
+ authToken = m.node.authToken
+ if authToken = invalid
+ return invalid
+ end if
+
+ url = `${authToken.instance}${Invidious.HISTORY_ENDPOINT}?page=${page}&max_results=${max_results}`
+ request = HttpClient.Get(url)
+ request.Headers(m.GetAuthenticationHeaders(authToken.token))
+ request.Cancellation(cancellation)
+
+ return request.Await()
+ end function
end class
end namespace
diff --git a/playlet-lib/src/components/Services/Invidious/InvidiousToContentNode.bs b/playlet-lib/src/components/Services/Invidious/InvidiousToContentNode.bs
index 6c921978..d072a73f 100644
--- a/playlet-lib/src/components/Services/Invidious/InvidiousToContentNode.bs
+++ b/playlet-lib/src/components/Services/Invidious/InvidiousToContentNode.bs
@@ -3,7 +3,7 @@ import "pkg:/source/utils/TimeUtils.bs"
namespace InvidiousContent
- function ToRowCellContentNode(item as object, instance as dynamic) as object
+ function ToRowCellContentNode(item as object, instance as dynamic, continueWatchingContent as object) as object
if item.videoId <> invalid and (item.type = invalid or item.type = "shortVideo")
item.type = "video"
end if
@@ -17,9 +17,9 @@ namespace InvidiousContent
end if
if item.type = "video"
- return ToVideoContentNode(invalid, item, instance)
+ return ToVideoContentNode(invalid, item, instance, continueWatchingContent)
else if item.type = "playlist"
- return ToPlaylistContentNode(invalid, item, instance)
+ return ToPlaylistContentNode(invalid, item, instance, continueWatchingContent)
else if item.type = "channel"
return ToChannelContentNode(invalid, item, instance)
else
@@ -27,7 +27,7 @@ namespace InvidiousContent
end if
end function
- function ToVideoContentNode(node as object, item as object, instance as dynamic) as object
+ function ToVideoContentNode(node as object, item as object, instance as dynamic, continueWatchingContent as object) as object
if node = invalid
node = CreateObject("roSGNode", "VideoContentNode")
end if
@@ -47,10 +47,17 @@ namespace InvidiousContent
node.viewCountText = VideoGetViewCountText(item)
SetIfExists(node, "index", item, "index")
+ if not StringUtils.IsNullOrEmpty(item.videoId)
+ progressNode = continueWatchingContent.findNode(item.videoId)
+ if progressNode <> invalid
+ node.progressNode = progressNode
+ end if
+ end if
+
return node
end function
- function ToPlaylistContentNode(node as object, item as object, instance as dynamic) as object
+ function ToPlaylistContentNode(node as object, item as object, instance as dynamic, continueWatchingContent as object) as object
if node = invalid
node = CreateObject("roSGNode", "PlaylistContentNode")
node.loadState = FeedLoadState.None
@@ -75,7 +82,7 @@ namespace InvidiousContent
newNodes = []
for each video in item.videos
video.type = "video"
- videoNode = ToVideoContentNode(invalid, video, instance)
+ videoNode = ToVideoContentNode(invalid, video, instance, continueWatchingContent)
if videoNode <> invalid
index = video.index
if index <> invalid and index > -1 and index < childCount
diff --git a/playlet-lib/src/components/Services/Preferences/Preferences.bs b/playlet-lib/src/components/Services/Preferences/Preferences.bs
index 9e58c559..a6bf5bac 100644
--- a/playlet-lib/src/components/Services/Preferences/Preferences.bs
+++ b/playlet-lib/src/components/Services/Preferences/Preferences.bs
@@ -93,28 +93,52 @@ function MigrateExistingPreferences(userPrefs as object) as boolean
isDirty = false
' v0 -> v1
- instances = RegistryUtils.Read(RegistryUtils.INVIDIOUS_INSTANCES)
- if instances <> invalid
- currentInstance = userPrefs["invidious.instance"]
- if currentInstance = invalid or currentInstance = ""
- instances = ParseJson(instances)
- if instances.Count() > 0
- instance = instances[0]
- if instance <> invalid and instance <> ""
- userPrefs["invidious.instance"] = instance
- isDirty = true
+ if not userPrefs.DoesExist("__version")
+ instances = RegistryUtils.Read(RegistryUtils.INVIDIOUS_INSTANCES)
+ if instances <> invalid
+ currentInstance = userPrefs["invidious.instance"]
+ if currentInstance = invalid or currentInstance = ""
+ instances = ParseJson(instances)
+ if instances.Count() > 0
+ instance = instances[0]
+ if instance <> invalid and instance <> ""
+ userPrefs["invidious.instance"] = instance
+ end if
end if
end if
+ RegistryUtils.Delete(RegistryUtils.INVIDIOUS_INSTANCES)
end if
- RegistryUtils.Delete(RegistryUtils.INVIDIOUS_INSTANCES)
end if
- ' v1 -> v2 goes here (if needed)
- ' if userPrefs.__version = 1
- ' 'do stuff
- ' end if
+ ' v1 -> v2
+ if ValidInt(userPrefs.__version) = 1
+ if userPrefs.DoesExist("misc.home_screen_layout")
+ homeScreenLayout = userPrefs["misc.home_screen_layout"]
+ if IsArray(homeScreenLayout)
+ containsContinueWatching = false
+ for each item in homeScreenLayout
+ if item.id = "continue_watching"
+ containsContinueWatching = true
+ exit for
+ end if
+ end for
+ if not containsContinueWatching
+ ' continue_watching was added in v2, and should be the first item in the array
+ homeScreenLayout.Unshift({
+ id: "continue_watching"
+ enabled: true
+ })
+ userPrefs["misc.home_screen_layout"] = homeScreenLayout
+ end if
+ end if
+ end if
+ end if
+
+ if ValidInt(userPrefs.__version) <> m.top.__version
+ isDirty = true
+ end if
- ' TODO:PX handle case where saved preferences version is higher than current version (e.g. user downgraded)
+ ' TODO:P2 handle case where saved preferences version is higher than current version (e.g. user downgraded)
return isDirty
end function
diff --git a/playlet-lib/src/components/Services/Preferences/Preferences.xml b/playlet-lib/src/components/Services/Preferences/Preferences.xml
index bab63e6a..e8f8b7d9 100644
--- a/playlet-lib/src/components/Services/Preferences/Preferences.xml
+++ b/playlet-lib/src/components/Services/Preferences/Preferences.xml
@@ -5,7 +5,7 @@
user prefs saved need to be migrated. If the version is less than the
current version, a migration is needed.
-->
-
+
diff --git a/playlet-lib/src/components/VideoFeed/VideoRowCell/FeedContentNodeUtils.bs b/playlet-lib/src/components/VideoFeed/VideoRowCell/FeedContentNodeUtils.bs
new file mode 100644
index 00000000..87ea97cb
--- /dev/null
+++ b/playlet-lib/src/components/VideoFeed/VideoRowCell/FeedContentNodeUtils.bs
@@ -0,0 +1,80 @@
+import "pkg:/components/VideoFeed/FeedLoadState.bs"
+import "pkg:/source/utils/Logging.bs"
+import "pkg:/source/utils/NodePathUtils.bs"
+
+namespace FeedContentNodeUtils
+
+ function Reset(feedContentNode as object, feed as object)
+ feedContentNode.title = feed.title
+ feedContentNode.feedSourcesIndex = 0
+ feedContentNode.loadState = FeedLoadState.None
+ feedSources = feed.feedSources
+ for i = 0 to feedSources.count() - 1
+ feedSources[i].state = {
+ loadState: FeedLoadState.None
+ }
+ end for
+ feedContentNode.feedSources = feedSources
+ feedContentNode.reactivity = CreateReactivityArray(feed.reactivity)
+
+ loadNodes = []
+ for i = 1 to 4
+ loadNodes.push(CreateObject("roSGNode", "LoadingContentNode"))
+ end for
+
+ childCount = feedContentNode.getChildCount()
+ if childCount > 0
+ feedContentNode.removeChildrenIndex(childCount, 0)
+ end if
+
+ feedContentNode.appendChildren(loadNodes)
+ end function
+
+ function ResetSelf(feedContentNode as object)
+ LogInfo("Resetting feed content node")
+ feedSources = feedContentNode.feedSources
+ for i = 0 to feedSources.count() - 1
+ feedSources[i].state = {
+ loadState: FeedLoadState.None
+ }
+ end for
+ feedContentNode.feedSources = feedSources
+
+ feedContentNode.feedSourcesIndex = 0
+ feedContentNode.loadState = FeedLoadState.None
+
+ loadNodes = []
+ for i = 1 to 4
+ loadNodes.push(CreateObject("roSGNode", "LoadingContentNode"))
+ end for
+
+ childCount = feedContentNode.getChildCount()
+ if childCount > 0
+ feedContentNode.removeChildrenIndex(childCount, 0)
+ end if
+
+ feedContentNode.appendChildren(loadNodes)
+ end function
+
+ function CreateReactivityArray(reactivity as object) as object
+ if not IsArray(reactivity)
+ return []
+ end if
+
+ reactivityArray = []
+ for each item in reactivity
+ field = item.field
+ node = NodePathUtils.FindNodeFromPath(m.top, item.node)
+ if node = invalid or not node.hasField(field)
+ continue for
+ end if
+ reactivityArray.push({
+ node: node
+ field: field
+ })
+ end for
+
+ return reactivityArray
+ end function
+
+end namespace
diff --git a/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.bs b/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.bs
index 84705b2e..bf00455a 100644
--- a/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.bs
+++ b/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.bs
@@ -11,6 +11,10 @@ function OnContentSet() as void
content = m.top.itemContent
if content = invalid
+ if m.progressNode <> invalid
+ m.progressNode.unobserveFieldScoped("timestamp")
+ m.progressNode = invalid
+ end if
return
end if
@@ -31,23 +35,27 @@ function OnContentSet() as void
m.top.thumbnailUri = content.thumbnail
- m.top.durationRectVisible = false
- m.top.upcomingRectVisible = false
- m.top.liveRectVisible = false
+ lengthSeconds = ValidInt(content.lengthSeconds)
+ hasLength = lengthSeconds <> 0
+ isUpcoming = not hasLength and content.isUpcoming = true
+ isLive = not hasLength and not isUpcoming and content.liveNow = true
+
+ m.top.durationRectVisible = hasLength
+ m.top.upcomingRectVisible = isUpcoming
+ m.top.liveRectVisible = isLive
- if ValidInt(content.lengthSeconds) <> 0
+ if hasLength
SetDurationText(content.lengthText)
- m.top.durationRectVisible = true
- else
- if content.isUpcoming = true
- m.top.upcomingRectVisible = true
- else if content.liveNow
- m.top.liveRectVisible = true
- end if
end if
+
+ SetupProgress(content)
end function
function SetDurationText(text as string) as void
+ if m.top.duration = text
+ return
+ end if
+
m.top.duration = text
label = m.durationLabel
@@ -65,3 +73,30 @@ function SetDurationText(text as string) as void
rect.translation = [rectParent.width - rect.width, rect.translation[1]]
end function
+function SetupProgress(content as object)
+ if m.progressNode <> invalid
+ m.progressNode.unobserveFieldScoped("timestamp")
+ m.progressNode = invalid
+ end if
+
+ m.progressNode = content.progressNode
+ if m.progressNode <> invalid
+ m.progressNode.observeFieldScoped("timestamp", FuncName(SetProgress))
+ end if
+ SetProgress()
+end function
+
+function SetProgress() as void
+ if m.progressNode = invalid
+ m.top.progressRectScale = [0, 1]
+ return
+ end if
+
+ duration = m.progressNode.duration
+ timestamp = m.progressNode.timestamp
+ progress = 0
+ if duration > 0
+ progress = timestamp / duration
+ end if
+ m.top.progressRectScale = [progress, 1]
+end function
diff --git a/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.xml b/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.xml
index 1bd50050..84b0a223 100644
--- a/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.xml
+++ b/playlet-lib/src/components/VideoFeed/VideoRowCell/VideoRowCell.xml
@@ -10,6 +10,7 @@
+
+
+
diff --git a/playlet-lib/src/components/VideoFeed/VideoRowListContentTask.bs b/playlet-lib/src/components/VideoFeed/VideoRowListContentTask.bs
index d816b426..fe406f63 100644
--- a/playlet-lib/src/components/VideoFeed/VideoRowListContentTask.bs
+++ b/playlet-lib/src/components/VideoFeed/VideoRowListContentTask.bs
@@ -1,5 +1,6 @@
import "pkg:/components/VideoFeed/FeedLoadState.bs"
import "pkg:/source/utils/StringUtils.bs"
+import "VideoRowCell/FeedContentNodeUtils.bs"
@asynctask
function VideoRowListContentTask(input as object)
@@ -10,21 +11,7 @@ function VideoRowListContentTask(input as object)
for each feed in feeds
feedContentNode = CreateObject("roSGNode", "FeedContentNode")
- feedContentNode.title = feed.title
- feedContentNode.feedSourcesIndex = 0
- feedContentNode.loadState = FeedLoadState.None
- feedSources = feed.feedSources
- for i = 0 to feedSources.count() - 1
- feedSources[i].state = {
- loadState: FeedLoadState.None
- }
- end for
- feedContentNode.feedSources = feedSources
-
- for i = 1 to 4
- feedContentNode.createChild("LoadingContentNode")
- end for
-
+ FeedContentNodeUtils.Reset(feedContentNode, feed)
contentNode.appendChild(feedContentNode)
end for
diff --git a/playlet-lib/src/components/VideoFeed/VideoRowListRowContentTask.bs b/playlet-lib/src/components/VideoFeed/VideoRowListRowContentTask.bs
index f18badb6..019139a0 100644
--- a/playlet-lib/src/components/VideoFeed/VideoRowListRowContentTask.bs
+++ b/playlet-lib/src/components/VideoFeed/VideoRowListRowContentTask.bs
@@ -8,6 +8,7 @@ function VideoRowListRowContentTask(input as object) as object
rowList = input.rowList
feedContentNode = input.feedContentNode
invidiousNode = input.invidious
+ continueWatchingContent = input.continueWatchingContent
service = new Invidious.InvidiousService(invidiousNode)
instance = service.GetInstance()
@@ -93,7 +94,7 @@ function VideoRowListRowContentTask(input as object) as object
itemNodes = []
for each item in response.result.items
- itemNode = InvidiousContent.ToRowCellContentNode(item, instance)
+ itemNode = InvidiousContent.ToRowCellContentNode(item, instance, continueWatchingContent)
if itemNode <> invalid
itemNode.feedSourcesIndex = feedSourcesIndex
itemNodes.Push(itemNode)
diff --git a/playlet-lib/src/components/VideoPlayer/SponsorBlock.bs b/playlet-lib/src/components/VideoPlayer/SponsorBlock.bs
index 38ad7e62..ad0c515b 100644
--- a/playlet-lib/src/components/VideoPlayer/SponsorBlock.bs
+++ b/playlet-lib/src/components/VideoPlayer/SponsorBlock.bs
@@ -113,7 +113,6 @@ namespace SponsorBlock
m.top.trickPlayBar.filledBarBlendColor = "#FF000080"
end if
- m.top.UnobserveFieldScoped("position")
m.top.ObserveFieldScoped("position", FuncName(OnPositionChangeSkipSponsorBlockSections))
end function
diff --git a/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs b/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs
index bbd7b5bd..45921352 100644
--- a/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs
+++ b/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs
@@ -1,4 +1,5 @@
import "pkg:/components/Dialog/DialogUtils.bs"
+import "pkg:/components/Services/ContinueWatching/ContinueWatchingUtils.bs"
import "pkg:/components/Services/Invidious/InvidiousToContentNode.bs"
import "pkg:/components/VideoPlayer/SponsorBlock.bs"
import "pkg:/components/VideoPlayer/VideoPlayerStyle.bs"
@@ -7,6 +8,7 @@ import "pkg:/source/AsyncTask/AsyncTask.bs"
import "pkg:/source/AsyncTask/Tasks.bs"
import "pkg:/source/utils/ErrorUtils.bs"
import "pkg:/source/utils/Logging.bs"
+import "pkg:/source/utils/MathUtils.bs"
function Init()
SetPlayerStyle()
@@ -24,7 +26,6 @@ function Init()
' This is used to prevent the video from playing the next video when we are
' still trying to find a working stream url
m.ignoreNextFinishedState = false
-
' asyncStopSemantics available since Roku OS 12.5
' It is set to true because stopping synchronously causes timeout and crash
' Usually we would need to wait for the video state to move to "stopping" then to "stopped"
@@ -34,6 +35,10 @@ function Init()
if m.top.hasField("asyncStopSemantics")
m.top.asyncStopSemantics = true
end if
+
+ m.videoPlayedToCompletion = false
+ m.lastProgressTimestamp = 0
+ m.lastSavedProgressTimestamp = 0
end function
function OnNodeReady()
@@ -73,6 +78,9 @@ function PlayWithContent(contentNode as object)
if not StringUtils.IsNullOrEmpty(contentNode.author)
videoContentNode.secondaryTitle = contentNode.author
end if
+ if contentNode.progressNode <> invalid
+ m.progressNode = contentNode.progressNode
+ end if
StartVideoContentTask(videoContentNode)
end function
@@ -82,7 +90,7 @@ function StartVideoContentTask(videoContentNode as object)
m.videoContentTask.cancel = true
end if
- MarkVideoWatched(videoContentNode.videoId)
+ MarkVideoWatched(videoContentNode)
m.top.content = videoContentNode
@@ -117,24 +125,49 @@ function OnVideoContentTaskResults(output as object) as void
return
end if
- SponsorBlock.FetchSponsorBlock(m.top.content.metadata)
+ content = m.top.content
+ metadata = content.metadata
+ timestamp = ValidInt(content.timestamp)
+ SponsorBlock.FetchSponsorBlock(metadata)
m.top.control = "play"
- if ValidInt(m.top.content.timestamp) > 0
- m.top.seek = m.top.content.timestamp
+
+ if timestamp = 0 and m.progressNode <> invalid and ValidInt(m.progressNode.timestamp) > 0
+ timestamp = m.progressNode.timestamp
+ end if
+
+ if timestamp > 0
+ m.top.seek = timestamp
+ end if
+
+ if m.progressNode <> invalid
+ m.top.observeFieldScoped("position", FuncName(OnPositionChangeVideoProgress))
end if
end function
-function MarkVideoWatched(videoId as string)
+function MarkVideoWatched(videoContentNode as object)
if m.top.invidious.authToken <> invalid
AsyncTask.Start(Tasks.MarkVideoWatchedTask, {
- videoId: videoId
+ videoId: videoContentNode.videoId
invidious: m.top.invidious
})
end if
+
+ if m.preferences["continue_watching.enabled"] = true
+ if m.progressNode = invalid
+ maxVideos = MathUtils.Min(ValidInt(m.preferences["continue_watching.max_videos"]), ContinueWatchingUtils.MAX_VIDEOS)
+ m.progressNode = ContinueWatchingUtils.GetOrCreateNodeForVideo(m.continueWatching, videoContentNode.videoId, maxVideos)
+ end if
+ ContinueWatchingUtils.MarkWatchDate(m.progressNode)
+ m.continueWatching.save = true
+ else
+ m.progressNode = invalid
+ end if
end function
function Close(_unused as dynamic)
+ HandleProgressBeforeClose()
+
if m.videoContentTask <> invalid
m.videoContentTask.cancel = true
m.videoContentTask = invalid
@@ -160,6 +193,59 @@ function Close(_unused as dynamic)
m.top.content = invalid
end function
+function HandleProgressBeforeClose() as void
+ progressNode = m.progressNode
+ if progressNode = invalid
+ return
+ end if
+ m.progressNode = invalid
+
+ if m.preferences["continue_watching.enabled"] <> true
+ return
+ end if
+
+ if not m.videoPlayedToCompletion
+ duration = progressNode.duration
+ timestamp = progressNode.timestamp
+ if duration > 0
+ timeLeft = duration - timestamp
+ percentageLeft = timeLeft / duration
+
+ ' If we are less than 5% away from the end of the video, and the what's left is less than 20 seconds
+ ' then we consider the video to be played to completion, to account for ending credits etc.
+ if percentageLeft < 0.05 and timeLeft < 20
+ m.videoPlayedToCompletion = true
+ end if
+ end if
+ end if
+
+ if not m.videoPlayedToCompletion
+ m.continueWatching.save = true
+ return
+ end if
+
+ content = m.top.content
+ if content = invalid
+ m.continueWatching.save = true
+ return
+ end if
+
+ metadata = content.metadata
+ if metadata = invalid
+ m.continueWatching.save = true
+ return
+ end if
+
+ videoId = metadata.videoId
+ if StringUtils.IsNullOrEmpty(videoId)
+ m.continueWatching.save = true
+ return
+ end if
+
+ ContinueWatchingUtils.RemoveNodeForVideo(m.continueWatching, videoId)
+ m.continueWatching.save = true
+end function
+
function OnVideoPlayerStateChange() as void
state = m.top.state
content = m.top.content
@@ -232,6 +318,7 @@ function OnVideoPlayerStateChange() as void
end function
function OnVideoFinished() as void
+ m.videoPlayedToCompletion = true
content = m.top.content
if content = invalid
m.playQueue@.watchedVideosClear()
@@ -281,7 +368,7 @@ function PlayNextRecommendedVideo(recommendedVideos as object) as boolean
if not m.playQueue@.watchedVideosContain(videoId)
LogInfo(`Playing next recommended video (${videoId})`)
instance = m.invidious@.GetCurrentInstance()
- node = InvidiousContent.ToVideoContentNode(invalid, metadata, instance)
+ node = InvidiousContent.ToVideoContentNode(invalid, metadata, instance, m.continueWatching.content)
m.playQueue@.Play(node, -1)
return true
end if
@@ -367,3 +454,33 @@ function OnFullScreenHintTimer()
m.top.showFullScreenHint = false
end if
end function
+
+function OnPositionChangeVideoProgress() as void
+ currentPosition = Int(m.top.position)
+ ' Only update the progress node every 5 seconds.
+ ' Avoid updating this too often because it makes the ContentNode
+ ' one VideoRowCell nodes update.
+ if Abs(m.lastProgressTimestamp - currentPosition) < 5
+ return
+ end if
+ m.lastProgressTimestamp = currentPosition
+
+ progressNode = m.progressNode
+ if progressNode = invalid
+ return
+ end if
+
+ progressNode.timestamp = currentPosition
+ duration = m.top.duration
+ if duration > 0
+ progressNode.duration = Int(duration)
+ end if
+
+ ' Save the progress every 10 seconds
+ if Abs(m.lastSavedProgressTimestamp - currentPosition) < 10
+ return
+ end if
+ m.lastSavedProgressTimestamp = currentPosition
+
+ m.continueWatching.save = true
+end function
diff --git a/playlet-lib/src/components/VideoPlayer/VideoPlayer.xml b/playlet-lib/src/components/VideoPlayer/VideoPlayer.xml
index 4f52a825..f57d9485 100644
--- a/playlet-lib/src/components/VideoPlayer/VideoPlayer.xml
+++ b/playlet-lib/src/components/VideoPlayer/VideoPlayer.xml
@@ -4,6 +4,7 @@
+
diff --git a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/ContinueWatchingRouter.bs b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/ContinueWatchingRouter.bs
new file mode 100644
index 00000000..cd7d1729
--- /dev/null
+++ b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/ContinueWatchingRouter.bs
@@ -0,0 +1,111 @@
+import "pkg:/components/Services/ContinueWatching/ContinueWatchingUtils.bs"
+import "pkg:/components/Services/Invidious/InvidiousService.bs"
+import "pkg:/source/utils/MathUtils.bs"
+
+namespace Http
+
+ const CONTINUE_WATCHING_PAGE_SIZE = 5
+
+ class ContinueWatchingRouter extends HttpRouter
+
+ function new(server as object)
+ super()
+
+ task = server.task
+ m.preferencesNode = task.preferences
+ m.continueWatchingNode = task.continueWatching
+ m.invidiousNode = task.invidious
+ m.invidiousService = new Invidious.InvidiousService(m.invidiousNode)
+ end function
+
+ @get("/api/continue-watching")
+ function GetContinueWatching(context as object) as boolean
+ request = context.request
+ response = context.response
+
+ isEnabled = m.preferencesNode["continue_watching.enabled"] = true
+ if not isEnabled
+ response.Default(403, "Continue watching is disabled from the settings.")
+ return true
+ end if
+
+ page = 1
+ if not StringUtils.IsNullOrEmpty(request.query.page)
+ page = request.query.page.ToInt()
+ page = MathUtils.Max(page, 1)
+ end if
+
+ totalCount = m.continueWatchingNode.content.getChildCount()
+ startIndex = (page - 1) * CONTINUE_WATCHING_PAGE_SIZE
+ if startIndex >= totalCount
+ response.Json([])
+ return true
+ end if
+
+ videoCount = MathUtils.Min(CONTINUE_WATCHING_PAGE_SIZE, totalCount - startIndex)
+ nodes = m.continueWatchingNode.content.getChildren(videoCount, startIndex)
+
+ videoIds = []
+ for each node in nodes
+ videoIds.push(node.videoId)
+ end for
+
+ instance = m.invidiousService.GetInstance()
+
+ videoRequests = []
+ for each videoId in videoIds
+ videoRequests.push(m.GetVideoMetadata(instance, videoId))
+ end for
+
+ videoResponses = []
+ for each videoRequest in videoRequests
+ videoResponses.push(videoRequest.Await())
+ end for
+
+ videos = []
+ for each videoResponse in videoResponses
+ if videoResponse.StatusCode() = 200
+ videoInfo = videoResponse.Json()
+ videoInfo.type = "video"
+ ' Remove unnecessary fields, keep payload small
+ videoInfo.Delete("adaptiveFormats")
+ videoInfo.Delete("formatStreams")
+ videoInfo.Delete("storyboards")
+ videoInfo.Delete("recommendedVideos")
+ videos.push(videoInfo)
+ end if
+ end for
+
+ response.Json(videos)
+ return true
+ end function
+
+ @delete("/api/continue-watching")
+ function ClearContinueWatching(context as object) as boolean
+ response = context.response
+
+ ContinueWatchingUtils.Clear(m.continueWatchingNode)
+
+ response.Default(204, "OK")
+ return true
+ end function
+
+ function GetVideoMetadata(instance as string, videoId as string, cancellation = invalid as object) as object
+ ' _playlet_ref=video_info so that we give it it's own cache that lasts longer
+ url = `${instance}${Invidious.VIDEOS_ENDPOINT}/${videoId}?_playlet_ref=video_info`
+
+ request = HttpClient.Get(url)
+ ' 3 days of cache
+ request.CacheSeconds(60 * 60 * 24 * 3)
+ request.Cancellation(cancellation)
+ ' Since we're doing multiple requests from the same thread, we might
+ ' benefit from HTTP/2 connection sharing
+ request.UseHttp2()
+
+ ' Send, and not await, since we are launching many requests at once
+ return request.Send()
+ end function
+
+ end class
+
+end namespace
diff --git a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/PlayQueueRouter.bs b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/PlayQueueRouter.bs
index 973160d9..859c8558 100644
--- a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/PlayQueueRouter.bs
+++ b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/PlayQueueRouter.bs
@@ -12,6 +12,7 @@ namespace Http
m.playQueueNode = task.playQueue
m.invidiousNode = task.invidious
m.invidiousService = new Invidious.InvidiousService(m.invidiousNode)
+ m.continueWatchingContent = task.continueWatching.content
end function
@get("/api/queue")
@@ -43,7 +44,7 @@ namespace Http
instance = m.invidiousService.GetInstance()
- contentNode = InvidiousContent.ToRowCellContentNode(payload, instance)
+ contentNode = InvidiousContent.ToRowCellContentNode(payload, instance, m.continueWatchingContent)
m.playQueueNode@.AddToQueue(contentNode)
queue = m.playQueueNode@.GetQueue()
@@ -75,7 +76,7 @@ namespace Http
instance = m.invidiousService.GetInstance()
- contentNode = InvidiousContent.ToRowCellContentNode(payload, instance)
+ contentNode = InvidiousContent.ToRowCellContentNode(payload, instance, m.continueWatchingContent)
playlistIndex = IsInt(payload.playlistIndex) ? payload.playlistIndex : -1
if contentNode.type = "playlist" and playlistIndex = -1
playlistIndex = 0
diff --git a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/ViewRouter.bs b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/ViewRouter.bs
index cc29c411..a85d6e25 100644
--- a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/ViewRouter.bs
+++ b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/ViewRouter.bs
@@ -9,7 +9,9 @@ namespace Http
function new(server as object)
super()
- m.appController = server.task.appController
+ task = server.task
+ m.appController = task.appController
+ m.continueWatchingContent = task.continueWatching.content
end function
@get("/api/view/open")
@@ -21,7 +23,7 @@ namespace Http
authorId = request.query.authorId
if not StringUtils.IsNullOrEmpty(playlistId)
- contentNode = InvidiousContent.ToPlaylistContentNode(invalid, { playlistId: playlistId }, invalid)
+ contentNode = InvidiousContent.ToPlaylistContentNode(invalid, { playlistId: playlistId }, invalid, m.continueWatchingContent)
PlaylistUtils.Open(contentNode, m.appController)
if VideoUtils.IsVideoPlayerOpen() and VideoUtils.IsVideoPlayerFullScreen()
VideoUtils.ToggleVideoPictureInPicture()
diff --git a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/WatchHistoryRouter.bs b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/WatchHistoryRouter.bs
new file mode 100644
index 00000000..42542ff7
--- /dev/null
+++ b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/WatchHistoryRouter.bs
@@ -0,0 +1,99 @@
+import "pkg:/components/Services/Invidious/InvidiousService.bs"
+import "pkg:/source/utils/MathUtils.bs"
+
+namespace Http
+
+ const WATCH_HISTORY_PAGE_SIZE = 5
+
+ class WatchHistoryRouter extends HttpRouter
+
+ function new(server as object)
+ super()
+
+ task = server.task
+ m.invidiousNode = task.invidious
+ m.invidiousService = new Invidious.InvidiousService(m.invidiousNode)
+ end function
+
+ @get("/api/watch-history")
+ function GetWatchHistory(context as object) as boolean
+ request = context.request
+ response = context.response
+
+ page = 1
+ if not StringUtils.IsNullOrEmpty(request.query.page)
+ page = request.query.page.ToInt()
+ page = MathUtils.Max(page, 1)
+ end if
+
+ historyResponse = m.invidiousService.GetWatchHistory(page, WATCH_HISTORY_PAGE_SIZE)
+ if not historyResponse.IsSuccess()
+ response.http_code = historyResponse.StatusCode()
+ response.SetBodyDataString(response.ErrorMessage())
+ response.ContentType("text/plain")
+ return true
+ end if
+
+ videoIds = historyResponse.Json()
+ if not IsArray(videoIds)
+ response.http_code = 500
+ response.SetBodyDataString("Invalid response from Invidious")
+ response.ContentType("text/plain")
+ return true
+ end if
+
+ if videoIds.Count() = 0
+ response.Json([])
+ return true
+ end if
+
+ instance = m.invidiousService.GetInstance()
+
+ videoRequests = []
+ for each videoId in videoIds
+ videoRequests.push(m.GetVideoMetadata(instance, videoId))
+ end for
+
+ videoResponses = []
+ for each videoRequest in videoRequests
+ videoResponses.push(videoRequest.Await())
+ end for
+
+ videos = []
+ for each videoResponse in videoResponses
+ if videoResponse.StatusCode() = 200
+ videoInfo = videoResponse.Json()
+ videoInfo.type = "video"
+ ' Remove unnecessary fields, keep payload small
+ videoInfo.Delete("adaptiveFormats")
+ videoInfo.Delete("formatStreams")
+ videoInfo.Delete("storyboards")
+ videoInfo.Delete("recommendedVideos")
+ videos.push(videoInfo)
+ end if
+ end for
+
+ response.Json(videos)
+ return true
+ end function
+
+ ' TODO:P2 refactor this, since it's similar to continue watching router
+ function GetVideoMetadata(instance as string, videoId as string, cancellation = invalid as object) as object
+ ' _playlet_ref=video_info so that we give it it's own cache that lasts longer
+ url = `${instance}${Invidious.VIDEOS_ENDPOINT}/${videoId}?_playlet_ref=video_info`
+
+ request = HttpClient.Get(url)
+ ' 3 days of cache
+ request.CacheSeconds(60 * 60 * 24 * 3)
+ request.Cancellation(cancellation)
+ ' Since we're doing multiple requests from the same thread, we might
+ ' benefit from HTTP/2 connection sharing
+ request.UseHttp2()
+
+ ' Send, and not await, since we are launching many requests at once
+ return request.Send()
+ end function
+
+ end class
+
+end namespace
diff --git a/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs b/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs
index 3699b2ef..1376c919 100644
--- a/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs
+++ b/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.bs
@@ -1,5 +1,6 @@
import "pkg:/components/parts/AutoBind/OnNodeReadyNoOp.bs"
import "pkg:/components/Web/PlayletWebServer/Middleware/BookmarksRouter.bs"
+import "pkg:/components/Web/PlayletWebServer/Middleware/ContinueWatchingRouter.bs"
import "pkg:/components/Web/PlayletWebServer/Middleware/DashRouter.bs"
import "pkg:/components/Web/PlayletWebServer/Middleware/HomeLayoutRouter.bs"
import "pkg:/components/Web/PlayletWebServer/Middleware/HomeRouter.bs"
@@ -11,6 +12,7 @@ import "pkg:/components/Web/PlayletWebServer/Middleware/RegistryRouter.bs"
import "pkg:/components/Web/PlayletWebServer/Middleware/SearchHistoryRouter.bs"
import "pkg:/components/Web/PlayletWebServer/Middleware/StateApiRouter.bs"
import "pkg:/components/Web/PlayletWebServer/Middleware/ViewRouter.bs"
+import "pkg:/components/Web/PlayletWebServer/Middleware/WatchHistoryRouter.bs"
import "pkg:/components/Web/WebServer/Middleware/CorsMiddleware.bs"
import "pkg:/components/Web/WebServer/Middleware/EtagMiddleware.bs"
import "pkg:/components/Web/WebServer/Middleware/HttpStaticFilesRouter.bs"
@@ -40,6 +42,8 @@ function SetupRoutes(server as object)
server.UseRouter(new Http.DashRouter(server))
server.UseRouter(new Http.PreferencesRouter(server))
server.UseRouter(new Http.InvidiousRouter(server))
+ server.UseRouter(new Http.ContinueWatchingRouter(server))
+ server.UseRouter(new Http.WatchHistoryRouter(server))
server.UseRouter(new Http.PlayQueueRouter(server))
server.UseRouter(new Http.BookmarksRouter(server))
server.UseRouter(new Http.HomeLayoutRouter(server))
diff --git a/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.xml b/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.xml
index 3453558c..8e6178e3 100644
--- a/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.xml
+++ b/playlet-lib/src/components/Web/PlayletWebServer/PlayletWebServer.xml
@@ -6,6 +6,7 @@
+
\ No newline at end of file
diff --git a/playlet-lib/src/config/default_home_layout.yaml b/playlet-lib/src/config/default_home_layout.yaml
index c255d56e..c0d43eac 100644
--- a/playlet-lib/src/config/default_home_layout.yaml
+++ b/playlet-lib/src/config/default_home_layout.yaml
@@ -1,5 +1,19 @@
+- title: Continue watching
+ id: continue_watching
+ reactivity:
+ - node: /ContinueWatching
+ field: changed
+ feedSources:
+ - id: local_continue_watching
+ title: Continue watching
+ apiType: Local
+ endpoint: continue_watching
+
- title: Subscriptions
id: subscriptions
+ reactivity:
+ - node: /Invidious
+ field: authToken
feedSources:
- id: inv_auth_feed
title: Subscriptions
@@ -54,6 +68,9 @@
- title: Playlists
id: playlists
+ reactivity:
+ - node: /Invidious
+ field: authToken
feedSources:
- id: inv_auth_playlists
title: Playlists
@@ -62,8 +79,11 @@
- title: Watch history
id: watch_history
+ reactivity:
+ - node: /Invidious
+ field: authToken
feedSources:
- - id: inv_auth_history
+ - id: local_watch_history
title: Watch history
- apiType: Invidious
+ apiType: Local
endpoint: watch_history
diff --git a/playlet-lib/src/config/local_video_api.yaml b/playlet-lib/src/config/local_video_api.yaml
new file mode 100644
index 00000000..7b2ca02e
--- /dev/null
+++ b/playlet-lib/src/config/local_video_api.yaml
@@ -0,0 +1,10 @@
+continue_watching:
+ title: Continue watching
+ url: /api/continue-watching
+ paginationType: Pages
+
+watch_history:
+ title: Watch history
+ url: /api/watch-history
+ authenticated: true
+ paginationType: Pages
diff --git a/playlet-lib/src/config/preferences.json5 b/playlet-lib/src/config/preferences.json5
index 35272226..f7e3c4ab 100644
--- a/playlet-lib/src/config/preferences.json5
+++ b/playlet-lib/src/config/preferences.json5
@@ -97,6 +97,52 @@
},
],
},
+ {
+ displayText: "Continue Watching",
+ key: "continue_watching",
+ description: "Continue watching preferences",
+ children: [
+ {
+ displayText: "Enabled",
+ key: "continue_watching.enabled",
+ description: "Enable Continue Watching",
+ type: "boolean",
+ defaultValue: true,
+ },
+ {
+ displayText: "Expiration",
+ key: "continue_watching.expiration",
+ description: "Continue Watching expiration time in days. Set to 0 to disable expiration.",
+ type: "number",
+ min: 0,
+ max: 365,
+ defaultValue: 30,
+ },
+ {
+ displayText: "Maximum videos",
+ key: "continue_watching.max_videos",
+ description: "Maximum number of videos to store in Continue Watching",
+ type: "number",
+ min: 5,
+ max: 100,
+ defaultValue: 20,
+ },
+ {
+ displayText: "Clear Continue Watching",
+ key: "continue_watching.clear_continue_watching",
+ description: "Clear Continue Watching from Playlet. This does not affect the watch history on Invidious.",
+ visibility: "web",
+ svelteComponent: "ClearContinueWatchingControl",
+ },
+ {
+ displayText: "Clear Continue Watching",
+ key: "continue_watching.clear_continue_watching",
+ description: "Clear Continue Watching from Playlet. This does not affect the watch history on Invidious.",
+ visibility: "tv",
+ rokuComponent: "ClearContinueWatchingControl",
+ },
+ ],
+ },
{
displayText: "Search History",
key: "search_history",
@@ -136,6 +182,10 @@
description: "Enable/disable and reorder sections on the home screen",
type: "array",
defaultValue: [
+ {
+ id: "continue_watching",
+ enabled: true,
+ },
{
id: "subscriptions",
enabled: true,
diff --git a/playlet-lib/src/source/utils/RegistryUtils.bs b/playlet-lib/src/source/utils/RegistryUtils.bs
index e70d34cc..dd30e900 100644
--- a/playlet-lib/src/source/utils/RegistryUtils.bs
+++ b/playlet-lib/src/source/utils/RegistryUtils.bs
@@ -7,6 +7,7 @@ namespace RegistryUtils
const SEARCH_HISTORY = "search_history"
const PLAYLET_LIB_URLS = "playlet_lib_urls"
const BOOKMARKS = "bookmarks"
+ const CONTINUE_WATCHING = "continue_watching"
const INVIDIOUS_TOKEN = "invidious_token"
diff --git a/playlet-web/src/App.svelte b/playlet-web/src/App.svelte
index 8312149d..a17bf4e7 100644
--- a/playlet-web/src/App.svelte
+++ b/playlet-web/src/App.svelte
@@ -8,6 +8,7 @@
homeLayoutFileStore,
homeLayoutStore,
invidiousVideoApiStore,
+ localVideoApiStore,
playletStateStore,
preferencesModelStore,
searchHistoryStore,
@@ -40,6 +41,10 @@
invidiousVideoApiStore.set(apiDefinitions);
});
+ PlayletApi.getLocalVideoApiFile().then((apiDefinitions) => {
+ localVideoApiStore.set(apiDefinitions);
+ });
+
PlayletApi.getPreferencesFile().then((value) => {
preferencesModelStore.set(value);
});
diff --git a/playlet-web/src/lib/Api/InvidiousApi.ts b/playlet-web/src/lib/Api/InvidiousApi.ts
index d64037af..38ee44fe 100644
--- a/playlet-web/src/lib/Api/InvidiousApi.ts
+++ b/playlet-web/src/lib/Api/InvidiousApi.ts
@@ -1,4 +1,5 @@
import { PlayletApi } from "lib/Api/PlayletApi";
+import { getHost } from "./Host";
export class InvidiousApi {
public instance: string;
@@ -104,7 +105,14 @@ export class InvidiousApi {
return null;
}
- let url = this.instance + endpoint.url
+ let url: string;
+ if (endpoint.apiType === "Local") {
+ url = `http://${getHost()}` + endpoint.url
+ }
+ else {
+ url = this.instance + endpoint.url
+ }
+
let queryParams = {}
if (endpoint.authenticated) {
@@ -112,7 +120,9 @@ export class InvidiousApi {
if (!this.isLoggedIn) {
return null;
}
- return await PlayletApi.invidiousAuthenticatedRequest(feedSource);
+ if (endpoint.apiType !== "Local") {
+ return await PlayletApi.invidiousAuthenticatedRequest(feedSource);
+ }
}
if (endpoint.queryParams !== undefined) {
diff --git a/playlet-web/src/lib/Api/PlayletApi.ts b/playlet-web/src/lib/Api/PlayletApi.ts
index 96bdb1c3..cc29323c 100644
--- a/playlet-web/src/lib/Api/PlayletApi.ts
+++ b/playlet-web/src/lib/Api/PlayletApi.ts
@@ -29,6 +29,11 @@ export class PlayletApi {
return await response.json();
}
+ static async getLocalVideoApiFile() {
+ const response = await fetch(`${PlayletApi.host()}/config/local_video_api.yaml`);
+ return await response.json();
+ }
+
static async invidiousAuthenticatedRequest(feedSource) {
const url = PlayletApi.host() + "/invidious/authenticated-request?feed-source=" + encodeURIComponent(JSON.stringify(feedSource));
const response = await fetch(url);
@@ -120,6 +125,10 @@ export class PlayletApi {
return await fetch(`${PlayletApi.host()}/api/search-history`, { method: "DELETE" });
}
+ static async clearContinueWatching() {
+ return await fetch(`${PlayletApi.host()}/api/continue-watching`, { method: "DELETE" });
+ }
+
static async getBookmarkFeeds() {
const response = await fetch(`${PlayletApi.host()}/api/bookmarks/feeds`);
return await response.json();
diff --git a/playlet-web/src/lib/Screens/Settings/SettingControls/ClearContinueWatchingControl.svelte b/playlet-web/src/lib/Screens/Settings/SettingControls/ClearContinueWatchingControl.svelte
new file mode 100644
index 00000000..1b8084cf
--- /dev/null
+++ b/playlet-web/src/lib/Screens/Settings/SettingControls/ClearContinueWatchingControl.svelte
@@ -0,0 +1,24 @@
+
+
+
+
{displayText}
+
{@html description}
+
+
diff --git a/playlet-web/src/lib/Screens/Settings/SettingControls/NumberControl.svelte b/playlet-web/src/lib/Screens/Settings/SettingControls/NumberControl.svelte
index b005297a..774cbe0a 100644
--- a/playlet-web/src/lib/Screens/Settings/SettingControls/NumberControl.svelte
+++ b/playlet-web/src/lib/Screens/Settings/SettingControls/NumberControl.svelte
@@ -1,17 +1,21 @@
diff --git a/playlet-web/src/lib/Stores.ts b/playlet-web/src/lib/Stores.ts
index cda0be34..cbf028f3 100644
--- a/playlet-web/src/lib/Stores.ts
+++ b/playlet-web/src/lib/Stores.ts
@@ -15,6 +15,8 @@ export const userPreferencesStore = writable({} as any);
export const invidiousVideoApiStore = writable({} as any);
+export const localVideoApiStore = writable({} as any);
+
export const homeLayoutStore = writable([] as any);
export const homeLayoutFileStore = writable([] as any);
diff --git a/playlet-web/src/lib/VideoFeed/VideoListRow.svelte b/playlet-web/src/lib/VideoFeed/VideoListRow.svelte
index d6a02824..3dd6c030 100644
--- a/playlet-web/src/lib/VideoFeed/VideoListRow.svelte
+++ b/playlet-web/src/lib/VideoFeed/VideoListRow.svelte
@@ -1,6 +1,10 @@