diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9397e296..33d49345 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Experimental Playlet backend to play videos using Innertube
- Partial Spanish translations (Thanks to Kamborio and gallegonovato)
## [0.26.0] - 2024-09-23
diff --git a/playlet-lib/src/components/MainScene.bs b/playlet-lib/src/components/MainScene.bs
index 38259cf4..a235133d 100644
--- a/playlet-lib/src/components/MainScene.bs
+++ b/playlet-lib/src/components/MainScene.bs
@@ -43,22 +43,13 @@ function GetDeviceFriendlyName() as string
end function
function ShowAnnouncement()
- title = "Announcement #2 - web app hot fix"
+ title = "Announcement #3 - Playlet innertube backend"
message = [
- "Invidious servers continue being blocked by YouTube. https://github.com/iBicha/playlet/issues/400"
- "A temporary workaround has been implemented which relies on your Roku device instead of Invidious for streaming data. This workaround is limited, doesn't always work, and doesn't have captions.",
- "How to use:"
- ]
-
- bulletText = [
- `Go to the "Remote" screen, and open Playlet Web App in your browser`
- "In the web app, tap the video you want to play"
- `Choose "Play on ${GetDeviceFriendlyName()} (HOT FIX)"`
- ]
-
- bottomMessage = [
- "We apologize for the inconvenience."
+ "Currently, most public Invidious instances don't currently work."
+ "Because of that, a new backend to play videos has been added to the settings, and it is enabled by default."
+ "If you would like to use your Invidious instance for playback, you can change the backend in the settings."
+ "Thank you."
]
buttons = [Tr(Locale.Buttons.OK)]
@@ -66,8 +57,6 @@ function ShowAnnouncement()
dialog = CreateObject("roSGNode", "SimpleDialog")
dialog.title = title
dialog.message = message
- dialog.bulletText = bulletText
- dialog.bottomMessage = bottomMessage
dialog.buttons = buttons
deviceInfo = CreateObject("roDeviceInfo")
displaySize = deviceInfo.GetDisplaySize()
diff --git a/playlet-lib/src/components/MainScene.transpiled.brs b/playlet-lib/src/components/MainScene.transpiled.brs
index 000a86db..54a336ff 100644
--- a/playlet-lib/src/components/MainScene.transpiled.brs
+++ b/playlet-lib/src/components/MainScene.transpiled.brs
@@ -42,19 +42,12 @@ function GetDeviceFriendlyName() as string
end function
function ShowAnnouncement()
- title = "Announcement #2 - web app hot fix"
+ title = "Announcement #3 - Playlet innertube backend"
message = [
- "Invidious servers continue being blocked by YouTube. https://github.com/iBicha/playlet/issues/400"
- "A temporary workaround has been implemented which relies on your Roku device instead of Invidious for streaming data. This workaround is limited, doesn't always work, and doesn't have captions."
- "How to use:"
- ]
- bulletText = [
- "Go to the " + chr(34) + "Remote" + chr(34) + " screen, and open Playlet Web App in your browser"
- "In the web app, tap the video you want to play"
- ("Choose " + chr(34) + "Play on " + bslib_toString(GetDeviceFriendlyName()) + " (HOT FIX)" + chr(34))
- ]
- bottomMessage = [
- "We apologize for the inconvenience."
+ "Currently, most public Invidious instances don't currently work."
+ "Because of that, a new backend to play videos has been added to the settings, and it is enabled by default."
+ "If you would like to use your Invidious instance for playback, you can change the backend in the settings."
+ "Thank you."
]
buttons = [
Tr("OK")
@@ -62,8 +55,6 @@ function ShowAnnouncement()
dialog = CreateObject("roSGNode", "SimpleDialog")
dialog.title = title
dialog.message = message
- dialog.bulletText = bulletText
- dialog.bottomMessage = bottomMessage
dialog.buttons = buttons
deviceInfo = CreateObject("roDeviceInfo")
displaySize = deviceInfo.GetDisplaySize()
diff --git a/playlet-lib/src/components/Services/Innertube/InnertubeService.bs b/playlet-lib/src/components/Services/Innertube/InnertubeService.bs
new file mode 100644
index 00000000..fa2945cb
--- /dev/null
+++ b/playlet-lib/src/components/Services/Innertube/InnertubeService.bs
@@ -0,0 +1,172 @@
+import "pkg:/source/services/HttpClient.bs"
+import "pkg:/source/utils/TimeUtils.bs"
+
+namespace InnertubeService
+
+ function GetVideoMetadata(videoId as string, options = invalid as object) as object
+ request = HttpClient.Post("https://www.youtube.com/youtubei/v1/player", FormatJson(MakePayload(videoId)))
+ request.Headers({
+ "accept": "*/*"
+ "accept-language": "*"
+ "content-type": "application/json"
+ "user-agent": "com.google.ios.youtube/18.06.35 (iPhone; CPU iPhone OS 14_4 like Mac OS X; en_US)"
+ "x-youtube-client-name": "5"
+ "x-youtube-client-version": "18.06.35"
+ })
+
+ cancellation = invalid
+
+ if options <> invalid
+ if options.DoesExist("cancellation")
+ cancellation = options.cancellation
+ end if
+ end if
+ request.Cancellation(cancellation)
+
+ response = request.Await()
+
+ if not response.IsSuccess()
+ return response
+ end if
+
+ parsedResponse = ParseInnertubeVideoResponse(response.Json())
+
+ if parsedResponse <> invalid
+ if parsedResponse.Success
+ response.OverrideJson(parsedResponse.Metadata)
+ else
+ response.OverrideStatusCode(500)
+ response.OverrideErrorMessage(parsedResponse.Error)
+ end if
+ end if
+
+ return response
+ end function
+
+ function MakePayload(videoId as string) as object
+ deviceInfo = CreateObject("roDeviceInfo")
+ return {
+ "playbackContext": {
+ "contentPlaybackContext": {
+ "vis": 0
+ "splay": false
+ "referer": "https://www.youtube.com/watch?v=" + videoId
+ "currentUrl": "/watch?v=" + videoId
+ "autonavState": "STATE_ON"
+ "autoCaptionsDefaultOn": false
+ "html5Preference": "HTML5_PREF_WANTS"
+ "lactMilliseconds": "-1"
+ }
+ }
+ "attestationRequest": {
+ "omitBotguardData": true
+ }
+ "racyCheckOk": true
+ "contentCheckOk": true
+ "videoId": videoId
+ "context": {
+ "client": {
+ "hl": "en"
+ "gl": "US"
+ "remoteHost": ""
+ "screenDensityFloat": 1
+ "screenHeightPoints": 1440
+ "screenPixelDensity": 1
+ "screenWidthPoints": 2560
+ "visitorData": ""
+ "clientName": "iOS"
+ "clientVersion": "18.06.35"
+ "osName": "iOS"
+ "osVersion": "10.0"
+ "platform": "MOBILE"
+ "clientFormFactor": "UNKNOWN_FORM_FACTOR"
+ "userInterfaceTheme": "USER_INTERFACE_THEME_LIGHT"
+ "timeZone": deviceInfo.GetTimeZone()
+ "originalUrl": "https://www.youtube.com"
+ "deviceMake": "Apple"
+ "deviceModel": "iPhone10,6"
+ "utcOffsetMinutes": -240
+ "memoryTotalKbytes": "8000000"
+ "mainAppWebInfo": {
+ "graftUrl": "https://www.youtube.com"
+ "pwaInstallabilityStatus": "PWA_INSTALLABILITY_STATUS_UNKNOWN"
+ "webDisplayMode": "WEB_DISPLAY_MODE_BROWSER"
+ "isWebNativeShareAvailable": true
+ }
+ }
+ "user": {
+ "enableSafetyMode": false
+ "lockedSafetyMode": false
+ }
+ "request": {
+ "useSsl": true
+ "internalExperimentFlags": []
+ }
+ }
+ }
+ end function
+
+ function ParseInnertubeVideoResponse(payload as object) as object
+ if not IsAssociativeArray(payload)
+ return {
+ Success: false
+ Error: "Invalid payload"
+ }
+ end if
+
+ playabilityStatus = payload["playabilityStatus"]
+ if not IsAssociativeArray(playabilityStatus)
+ return {
+ Success: false
+ Error: "Invalid playability status"
+ }
+ end if
+
+ if playabilityStatus["status"] <> "OK"
+ return {
+ Success: false
+ Error: playabilityStatus["reason"]
+ }
+ end if
+
+ videoDetails = payload["videoDetails"]
+ if not IsAssociativeArray(videoDetails)
+ return {
+ Success: false
+ Error: "Invalid video details"
+ }
+ end if
+
+ streamingData = payload["streamingData"]
+ if not IsAssociativeArray(streamingData)
+ return {
+ Success: false
+ Error: "Invalid streaming data"
+ }
+ end if
+
+ videoInfo = {
+ "type": "video"
+ "title": videoDetails["title"]
+ "videoId": videoDetails["videoId"]
+ "videoThumbnails": videoDetails["thumbnail"]["thumbnails"]
+ "storyboards": []
+ "viewCount": videoDetails["viewCount"].ToInt()
+ "author": videoDetails["author"]
+ "authorId": videoDetails["channelId"]
+ "lengthSeconds": videoDetails["lengthSeconds"].ToInt()
+ "liveNow": videoDetails["isLiveContent"]
+ "hlsUrl": streamingData["hlsManifestUrl"]
+ "adaptiveFormats": []
+ "formatStreams": []
+ "captions": []
+ "recommendedVideos": []
+ }
+
+ return {
+ Success: true
+ Metadata: videoInfo
+ }
+ end function
+
+end namespace
diff --git a/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs b/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs
index 077d781b..ade0f321 100644
--- a/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs
+++ b/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs
@@ -1,3 +1,4 @@
+import "pkg:/components/Services/Innertube/InnertubeService.bs"
import "pkg:/components/Services/Invidious/InvidiousService.bs"
@asynctask
@@ -17,7 +18,12 @@ function VideoContentTask(input as object) as object
service = new Invidious.InvidiousService(invidiousNode)
- response = service.GetVideoMetadata(contentNode.videoId, { cancellation: m.top.cancellation })
+ backend = preferencesNode["playback.backend"]
+ if backend = "playlet"
+ response = InnertubeService.GetVideoMetadata(contentNode.videoId, { cancellation: m.top.cancellation })
+ else
+ response = service.GetVideoMetadata(contentNode.videoId, { cancellation: m.top.cancellation })
+ end if
if m.top.cancel
return invalid
diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueView.bs b/playlet-lib/src/components/VideoQueue/VideoQueueView.bs
index c498be33..41c02488 100644
--- a/playlet-lib/src/components/VideoQueue/VideoQueueView.bs
+++ b/playlet-lib/src/components/VideoQueue/VideoQueueView.bs
@@ -144,6 +144,7 @@ function LoadVideoDetailsIfNeeded() as void
m.loadDetailsTask = AsyncTask.Start(Tasks.VideoQueueViewContentTask, {
videoNodes: m.videosDetailsToLoad
invidious: m.top.invidious
+ preferences: m.top.preferences
index: indexInLoadingArray
})
end function
diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueView.xml b/playlet-lib/src/components/VideoQueue/VideoQueueView.xml
index ac4cc6c9..70b9d044 100644
--- a/playlet-lib/src/components/VideoQueue/VideoQueueView.xml
+++ b/playlet-lib/src/components/VideoQueue/VideoQueueView.xml
@@ -5,6 +5,7 @@
+
diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs b/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs
index 86b3b372..dd867fa9 100644
--- a/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs
+++ b/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs
@@ -1,3 +1,4 @@
+import "pkg:/components/Services/Innertube/InnertubeService.bs"
import "pkg:/components/Services/Invidious/InvidiousService.bs"
import "pkg:/components/Services/Invidious/InvidiousToContentNode.bs"
import "pkg:/source/services/HttpClient.bs"
@@ -7,12 +8,15 @@ import "pkg:/source/utils/MathUtils.bs"
function VideoQueueViewContentTask(input as object) as object
videoNodes = input.videoNodes
invidiousNode = input.invidious
+ preferencesNode = input.preferences
service = new Invidious.InvidiousService(invidiousNode)
instance = service.GetInstance()
cancellation = m.top.cancellation
index = MathUtils.Clamp(input.index - 2, 0, videoNodes.Count() - 1)
+ backend = preferencesNode["playback.backend"]
+
requestOptions = {
cacheSeconds: 8640000 ' 100 days
cancellation: cancellation
@@ -24,7 +28,7 @@ function VideoQueueViewContentTask(input as object) as object
' Need to change it to load on demand using render tracking
for i = index to videoNodes.Count() - 1
videoNode = videoNodes[i]
- if not LoadVideoDetail(videoNode, service, instance, requestOptions)
+ if not LoadVideoDetail(videoNode, service, instance, requestOptions, backend)
' Sleep for a bit to avoid creating too much traffic
sleep(500)
end if
@@ -36,7 +40,7 @@ function VideoQueueViewContentTask(input as object) as object
for i = index - 1 to 0 step -1
videoNode = videoNodes[i]
- if not LoadVideoDetail(videoNode, service, instance, requestOptions)
+ if not LoadVideoDetail(videoNode, service, instance, requestOptions, backend)
' Sleep for a bit to avoid creating too much traffic
sleep(1000)
end if
@@ -50,9 +54,15 @@ function VideoQueueViewContentTask(input as object) as object
end function
' Returns true if the video was loaded from cache.
-function LoadVideoDetail(videoNode as object, service as object, instance as string, requestOptions as object) as boolean
+function LoadVideoDetail(videoNode as object, service as object, instance as string, requestOptions as object, backend as string) as boolean
videoId = videoNode.videoId
- response = service.GetVideoMetadata(videoId, requestOptions)
+
+ if backend = "playlet"
+ response = InnertubeService.GetVideoMetadata(videoId, requestOptions)
+ else
+ response = service.GetVideoMetadata(videoId, requestOptions)
+ end if
+
metadata = response.Json()
if not response.IsSuccess() or metadata = invalid
diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs b/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs
index 0162ba37..55e52695 100644
--- a/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs
+++ b/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs
@@ -7,6 +7,7 @@ namespace VideoQueueViewUtils
videoQueueView.videoQueue = videoQueue
videoQueueView.appController = appController
videoQueueView.invidious = videoQueue.invidious
+ videoQueueView.preferences = videoQueue.preferences
appController@.PushScreen(videoQueueView)
content = videoQueue.content
diff --git a/playlet-lib/src/config/preferences.json5 b/playlet-lib/src/config/preferences.json5
index 5d855ea3..88b394f0 100644
--- a/playlet-lib/src/config/preferences.json5
+++ b/playlet-lib/src/config/preferences.json5
@@ -32,6 +32,23 @@
},
],
},
+ {
+ displayText: "Backend",
+ key: "playback.backend",
+ description: "The backend used for fetching video data. The Playlet backend is experimental",
+ type: "radio",
+ defaultValue: "playlet",
+ options: [
+ {
+ displayText: "Invidious",
+ value: "invidious",
+ },
+ {
+ displayText: "Playlet (local)",
+ value: "playlet",
+ },
+ ],
+ },
],
},
{
diff --git a/playlet-lib/src/source/services/HttpClient.bs b/playlet-lib/src/source/services/HttpClient.bs
index 64f5975d..10816626 100644
--- a/playlet-lib/src/source/services/HttpClient.bs
+++ b/playlet-lib/src/source/services/HttpClient.bs
@@ -616,6 +616,14 @@ namespace HttpClient
m._text = text
end function
+ function OverrideJson(json as object)
+ m._json = json
+ end function
+
+ function OverrideErrorMessage(errorMessage as string)
+ m._errorMessage = errorMessage
+ end function
+
function IsSuccess() as boolean
statusCode = m.StatusCode()
return statusCode >= 200 and statusCode < 400