diff --git a/playlet-lib/src/components/ContentNode/ProfileContentNode.xml b/playlet-lib/src/components/ContentNode/ProfileContentNode.xml
index 854b1ed0..f073fd76 100644
--- a/playlet-lib/src/components/ContentNode/ProfileContentNode.xml
+++ b/playlet-lib/src/components/ContentNode/ProfileContentNode.xml
@@ -8,6 +8,7 @@
+
diff --git a/playlet-lib/src/components/Dialog/YouTubeLoginDialog.bs b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.bs
new file mode 100644
index 00000000..9e5afbd1
--- /dev/null
+++ b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.bs
@@ -0,0 +1,51 @@
+import "pkg:/source/AsyncTask/AsyncTask.bs"
+import "pkg:/source/AsyncTask/Tasks.bs"
+import "pkg:/source/utils/Locale.bs"
+import "pkg:/source/utils/Types.bs"
+
+function Init()
+ m.qrCode = m.top.findNode("QrCodePoster")
+
+ codeLabel = m.top.findNode("codeLabel")
+ if codeLabel <> invalid
+ codeLabel = codeLabel.getChild(0)
+ if codeLabel <> invalid
+ codeFont = m.top.findNode("codeFont")
+ codeLabel.font = codeFont
+ end if
+ end if
+
+ m.top.width = "920"
+ m.top.observeFieldScoped("buttonSelected", FuncName(Close))
+ m.top.observeFieldScoped("wasClosed", FuncName(OnWasClosed))
+ scanLabel = m.top.findNode("scanLabel")
+ ' TODO:P2 localize all dialog items
+ scanLabel.text = Tr(Locale.Dialogs.ScanTheQrCode)
+end function
+
+function OnNodeReady()
+ m.task = AsyncTask.Start(Tasks.YouTubeLoginTask, {
+ dialog: m.top
+ profilesService: m.top.profilesService
+ })
+end function
+
+function OnCodeSet(event as object)
+ code = event.getData()
+ m.qrCode.text = code
+end function
+
+function Close()
+ m.top.close = true
+end function
+
+function OnWasClosed()
+ if m.task <> invalid
+ m.task.cancel = true
+ m.task = invalid
+ end if
+end function
+
+function OnUrlSet()
+ m.qrCode.text = m.top.url
+end function
diff --git a/playlet-lib/src/components/Dialog/YouTubeLoginDialog.xml b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.xml
new file mode 100644
index 00000000..795f0cda
--- /dev/null
+++ b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playlet-lib/src/components/Dialog/YouTubeLoginTask.bs b/playlet-lib/src/components/Dialog/YouTubeLoginTask.bs
new file mode 100644
index 00000000..8d426069
--- /dev/null
+++ b/playlet-lib/src/components/Dialog/YouTubeLoginTask.bs
@@ -0,0 +1,118 @@
+import "pkg:/components/Dialog/DialogUtils.bs"
+import "pkg:/components/Services/Innertube/InnertubeService.bs"
+import "pkg:/source/utils/CancellationUtils.bs"
+import "pkg:/source/utils/Logging.bs"
+
+@asynctask
+function YouTubeLoginTask(input as object) as object
+ profilesNode = input.profilesService
+ dialogNode = input.dialog
+
+ cancellation = m.top.cancellation
+ ' TODO:P2 cache client identity
+ authCode = InnertubeService.AuthGetCode(cancellation)
+
+ if CancellationUtils.IsCancelled(cancellation)
+ return invalid
+ end if
+
+ if authCode.error <> invalid
+ LogError(authCode.error)
+ DialogUtils.ShowDialogEx({
+ title: "Error"
+ message: authCode.error
+ large: true
+ })
+ return invalid
+ end if
+
+ url = InnertubeService.AuthGetActivationUrl(authCode)
+
+ LogInfo("Login url:", url)
+
+ dialogNode.url = url
+ dialogNode.code = authCode.userCode
+
+ accessToken = InnertubeService.AuthPollForAccessToken(authCode, cancellation)
+
+ if CancellationUtils.IsCancelled(cancellation)
+ return invalid
+ end if
+
+ if accessToken.error <> invalid
+ LogError(accessToken.error)
+ DialogUtils.ShowDialogEx({
+ title: "Error"
+ message: accessToken.error
+ large: true
+ })
+ return invalid
+ end if
+
+ accounts = InnertubeService.AuthListAccounts(accessToken.accessToken, cancellation)
+
+ if CancellationUtils.IsCancelled(cancellation)
+ return invalid
+ end if
+
+ if IsAssociativeArray(accounts) and accounts.error <> invalid
+ LogError(accounts.error)
+ DialogUtils.ShowDialogEx({
+ title: "Error"
+ message: accounts.error
+ large: true
+ })
+ return invalid
+ end if
+
+ if not IsArray(accounts) or accounts.Count() = 0
+ LogError("No accounts found")
+ DialogUtils.ShowDialogEx({
+ title: "Error"
+ message: "No accounts found"
+ large: true
+ })
+ return invalid
+ end if
+
+ selectedAccount = invalid
+ for each account in accounts
+ if ValidBool(account.isSelected)
+ selectedAccount = account
+ exit for
+ end if
+ end for
+
+ if selectedAccount = invalid
+ selectedAccount = accounts[0]
+ end if
+
+ profile = CreateProfileContentNode(selectedAccount, accessToken)
+ profilesNode@.LoginWithProfile(profile)
+
+ dialogNode.close = true
+
+ return invalid
+end function
+
+function CreateProfileContentNode(account as object, accessToken as object) as object
+ profile = CreateObject("roSGNode", "ProfileContentNode")
+ profile.type = "youtube"
+ profile.serverUrl = "http://127.0.0.1:8888/playlet-invidious-backend"
+ username = account.channelHandle
+ if StringUtils.IsNullOrEmpty(username)
+ username = account.accountByline
+ end if
+ profile.username = username
+ profile.thumbnail = account.accountPhoto
+ profile.accessToken = accessToken.accessToken
+ profile.refreshToken = accessToken.refreshToken
+ profile.scope = accessToken.scope
+ profile.tokenType = accessToken.tokenType
+ profile.expiresIn = accessToken.expiresIn
+ profile.expiresTimestamp = accessToken.expiresTimestamp
+ profile.clientId = accessToken.clientId
+ profile.clientSecret = accessToken.clientSecret
+
+ return profile
+end function
diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs b/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs
index e87fb5ac..386fbc4e 100644
--- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs
+++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs
@@ -24,24 +24,37 @@ function OnContentSet() as void
return
end if
- m.top.circlePosterInnerUri = "pkg:/images/white-circle.png"
- m.top.circlePosterInnerBlendColor = content.color
username = content.username
m.top.username = username
- if not StringUtils.IsNullOrEmpty(username)
- m.top.letter = UCase(username.Left(1))
- else
+
+ thumbnail = content.thumbnail
+ if not StringUtils.IsNullOrEmpty(thumbnail)
+ m.top.circlePosterInnerUri = thumbnail
+ ' bs:disable-next-line LINT3023
+ m.top.circlePosterInnerBlendColor = "#FFFFFFFF"
m.top.letter = ""
+ else
+ m.top.circlePosterInnerUri = "pkg:/images/white-circle.png"
+ m.top.circlePosterInnerBlendColor = content.color
+
+ if not StringUtils.IsNullOrEmpty(username)
+ m.top.letter = UCase(username.Left(1))
+ else
+ m.top.letter = ""
+ end if
end if
- m.top.serverUrl = content.serverUrl
+
m.top.crownVisible = content.isSelected
backendType = content.type
if backendType = "invidious"
+ m.top.serverUrl = content.serverUrl
m.top.backendTypePosterUri = "pkg:/images/invidious-logo.png"
else if backendType = "youtube"
+ m.top.serverUrl = "YouTube"
m.top.backendTypePosterUri = "pkg:/images/youtube-logo.png"
else
+ m.top.serverUrl = ""
m.top.backendTypePosterUri = ""
end if
end function
diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs b/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs
index cbdd977c..d4acb380 100644
--- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs
+++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs
@@ -61,7 +61,7 @@ function OnBackEndSelected(event as object) as void
if selectedBackendType = "BackendTypeInvidious"
LoginWithInvidious()
else if selectedBackendType = "BackendTypeYouTube"
- LoginWithYouTube()
+ ShowLoginWithYouTubeDialog()
else
LogError("Unknown backend type:", selectedBackendType)
end if
@@ -85,11 +85,32 @@ function LoginWithInvidious() as void
m.top.getScene().dialog = dialog
end function
-function LoginWithYouTube() as void
- DialogUtils.ShowDialogEx({
- title: "YouTube"
- message: ["Not implemented yet."]
+function ShowLoginWithYouTubeDialog() as void
+ ' TODO:P2 localize
+ dialog = DialogUtils.ShowDialogEx({
+ title: "Disclaimer"
+ message: "Playlet authors acknowledge that all trademarks and registered trademarks mentioned in this application and related pages are the property of their respective owners. The use of these trademarks or trade names is for identification purposes only and does not imply any endorsement, affiliation, or sponsorship by the trademark owner."
+ buttons: [
+ Tr(Locale.Buttons.OK)
+ Tr(Locale.Buttons.Cancel)
+ ]
})
+ dialog.ObserveField("buttonSelected", FuncName(OnLoginWithYouTubeDialogButtonSelected))
+end function
+
+function OnLoginWithYouTubeDialogButtonSelected(event as object) as void
+ buttonSelected = event.getData()
+ if buttonSelected <> 0
+ return
+ end if
+
+ LoginWithYouTube()
+end function
+
+function LoginWithYouTube() as void
+ dialog = CreateObject("roSGNode", "YouTubeLoginDialog")
+ dialog@.BindNode()
+ m.top.getScene().dialog = dialog
end function
function OnCurrentProfileChanged() as void
diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs
index cd396bb4..1e35a5c9 100644
--- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs
+++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs
@@ -43,6 +43,16 @@ function OnContentSet() as void
end if
m.top.serverUrl = content.serverUrl
m.top.crownVisible = content.isSelected
+
+ backendType = content.type
+ if backendType = "invidious"
+ m.top.backendTypePosterUri = "pkg:/images/invidious-logo.png"
+ else if backendType = "youtube"
+ m.top.backendTypePosterUri = "pkg:/images/youtube-logo.png"
+ else
+ m.top.backendTypePosterUri = ""
+ end if
+
UpdateActivateButton()
end function
diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml
index 79be8c7e..be4b9ea2 100644
--- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml
+++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml
@@ -5,6 +5,7 @@
+
@@ -47,6 +48,12 @@
horizAlign="center">
+
invalid
+ return result
end if
result2 = Innertube.GetDeviceAndUserCode(result.clientId, cancellation)
- if CancellationUtils.IsCancelled(cancellation)
- return invalid
+ if CancellationUtils.IsCancelled(cancellation) or result2.error <> invalid
+ return result2
end if
result.Append(result2)
@@ -508,4 +508,7 @@ namespace InnertubeService
return Innertube.RevokeAccessToken(accessToken["accessToken"], cancellation)
end function
+ function AuthListAccounts(accessToken as string, cancellation = invalid as object) as object
+ return Innertube.ListAccounts(accessToken, cancellation)
+ end function
end namespace
diff --git a/playlet-lib/src/components/Services/Innertube/OAuth.bs b/playlet-lib/src/components/Services/Innertube/OAuth.bs
index 2743d1fb..6cbc80e4 100644
--- a/playlet-lib/src/components/Services/Innertube/OAuth.bs
+++ b/playlet-lib/src/components/Services/Innertube/OAuth.bs
@@ -1,4 +1,5 @@
import "pkg:/source/services/HttpClient.bs"
+import "pkg:/source/utils/ObjectUtils.bs"
import "pkg:/source/utils/TimeUtils.bs"
namespace Innertube
@@ -14,7 +15,9 @@ namespace Innertube
response = request.Await()
if not response.IsSuccess()
- throw `Failed to get client id: ${response.ErrorMessage()}`
+ return {
+ "error": `Failed to get client id: ${response.ErrorMessage()}`
+ }
end if
text = response.Text()
@@ -23,7 +26,9 @@ namespace Innertube
match = scriptRegex.Match(text)
if match.Count() < 2
- throw "Could not find base-js script"
+ return {
+ "error": "Could not find base-js script"
+ }
end if
baseJsUrl = "https://www.youtube.com" + match[1]
@@ -33,7 +38,9 @@ namespace Innertube
response = request.Await()
if not response.IsSuccess()
- throw `Failed to get base js: ${response.ErrorMessage()}`
+ return {
+ "error": `Failed to get base js: ${response.ErrorMessage()}`
+ }
end if
text = response.Text()
@@ -42,7 +49,9 @@ namespace Innertube
match = clientIdRegex.Match(text)
if match.Count() < 3
- throw "Could not find client id"
+ return {
+ "error": "Could not find client id and secret"
+ }
end if
return {
@@ -66,13 +75,17 @@ namespace Innertube
response = request.Await()
if not response.IsSuccess()
- throw `Failed to get device code: ${response.ErrorMessage()}`
+ return {
+ "error": `Failed to get device code: ${response.ErrorMessage()}`
+ }
end if
responseData = ToCamelCase(response.Json())
if responseData.DoesExist("errorCode")
- throw "Failed to get device code: " + ToString(responseData)
+ return {
+ "error": "Failed to get device code: " + ToString(responseData)
+ }
end if
if responseData.DoesExist("expiresIn")
@@ -130,11 +143,13 @@ namespace Innertube
end if
continue while
else if responseData.error = "expired_token"
- throw "Failed to get access token: " + ToString(responseData)
+ return responseData
else if responseData.error = "access_denied"
- throw "Failed to get access token: " + ToString(responseData)
+ return responseData
else
- throw "Failed to get access token: " + ToString(responseData)
+ return {
+ "error": "Failed to get access token: " + ToString(responseData)
+ }
end if
end while
@@ -177,6 +192,64 @@ namespace Innertube
return success
end function
+ function ListAccounts(accessToken as string, cancellation = invalid as dynamic) as object
+ deviceInfo = CreateObject("roDeviceInfo")
+
+ payload = {
+ "context": Innertube.CreateContext(Innertube.ClientType.Tv, deviceInfo, "", "")
+ "accountReadMask": {
+ "returnOwner": true
+ "returnBrandAccounts": true
+ "returnPersonaAccounts": true
+ "returnFamilyChildAccounts": true
+ }
+ }
+
+ request = HttpClient.PostJson("https://www.youtube.com/youtubei/v1/account/accounts_list", payload)
+ headers = {
+ "Authorization": `Bearer ${accessToken}`
+ }
+ headers.Append(Innertube.CreateHeaders(Innertube.ClientType.Tv))
+ request.Headers(headers)
+ request.Cancellation(cancellation)
+
+ response = request.Await()
+
+ if not response.IsSuccess()
+ return {
+ "error": `Failed to list accounts: ${response.ErrorMessage()}`
+ }
+ end if
+
+ accounts = ObjectUtils.Dig(response.Json(), ["contents", 0, "accountSectionListRenderer", "contents", 0, "accountItemSectionRenderer", "contents"])
+ if not IsArray(accounts)
+ return {
+ "error": "Could not parse accounts"
+ }
+ end if
+
+ parsedAccounts = []
+ for each account in accounts
+ accountItem = account["accountItem"]
+ if not IsAssociativeArray(accountItem)
+ continue for
+ end if
+
+ parsedAccount = {
+ "accountName": ParseText(accountItem["accountName"])
+ "accountPhoto": ObjectUtils.Dig(accountItem, ["accountPhoto", "thumbnails", 0, "url"])
+ "isSelected": ValidBool(accountItem["isSelected"])
+ "isDisabled": ValidBool(accountItem["isDisabled"])
+ "hasChannel": ValidBool(accountItem["hasChannel"])
+ "accountByline": ParseText(accountItem["accountByline"])
+ "channelHandle": ParseText(accountItem["channelHandle"])
+ }
+ parsedAccounts.Push(parsedAccount)
+ end for
+
+ return parsedAccounts
+ end function
+
function ToCamelCase(obj as object) as object
if not IsAssociativeArray(obj)
return obj
diff --git a/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs b/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs
index 8027d43d..6f873ad2 100644
--- a/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs
+++ b/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs
@@ -50,6 +50,7 @@ function LoadProfilesFromRegistry()
profileNode.type = profile.type
profileNode.username = profile.username
profileNode.serverUrl = profile.serverUrl
+ profileNode.thumbnail = profile.thumbnail
profileNode.accessToken = profile.accessToken
profileNode.refreshToken = profile.refreshToken
@@ -161,6 +162,7 @@ function GetProfilesDto(includeAccessToken as boolean) as object
"type": profileNode.type
"username": profileNode.username
"serverUrl": profileNode.serverUrl
+ "thumbnail": profileNode.thumbnail
"color": color
}
@@ -203,8 +205,17 @@ function LoginWithProfile(newProfile as object)
end for
if not isNewProfile
- ' Refresh token of existing profile
+ existingProfile.username = newProfile.username
+ existingProfile.thumbnail = newProfile.thumbnail
existingProfile.accessToken = newProfile.accessToken
+ existingProfile.refreshToken = newProfile.refreshToken
+ existingProfile.scope = newProfile.scope
+ existingProfile.tokenType = newProfile.tokenType
+ existingProfile.expiresIn = newProfile.expiresIn
+ existingProfile.expiresTimestamp = newProfile.expiresTimestamp
+ existingProfile.clientId = newProfile.clientId
+ existingProfile.clientSecret = newProfile.clientSecret
+
newProfile = existingProfile
end if