Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cast using Youtube app (DIAL/LoungeAPI) #276

Merged
merged 76 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
95b1755
Dial+Lounge API PoC
iBicha Jan 31, 2024
050352d
format
iBicha Feb 2, 2024
0c85e57
Fix for chrome
iBicha Feb 4, 2024
aaad03c
comments
iBicha Feb 4, 2024
4a2508a
Rate limit and logger stuff
iBicha Feb 5, 2024
6b2750c
rename route
iBicha Feb 5, 2024
8cf9df0
remove invalid routes
iBicha Feb 5, 2024
be8f3e3
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 5, 2024
9fc34f6
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 5, 2024
00a9d84
remove device image
iBicha Feb 5, 2024
3eb2eed
Simplify device description
iBicha Feb 5, 2024
b859e1f
command test
iBicha Feb 5, 2024
6f181cf
Lint fix
Feb 5, 2024
807b600
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 5, 2024
9db2652
register pairing code
iBicha Feb 6, 2024
720e01c
tv code UI
iBicha Feb 6, 2024
d37e836
Lint fix
Feb 6, 2024
2a0a2c3
Avoid creating lots of objects
iBicha Feb 6, 2024
0fea9fa
dpad command
iBicha Feb 6, 2024
4b892d4
small refactor
iBicha Feb 7, 2024
ff7a199
tweak
iBicha Feb 7, 2024
a60c6f2
validation
iBicha Feb 7, 2024
e349565
timer
iBicha Feb 7, 2024
99b4e5a
fix
iBicha Feb 7, 2024
fe8ad1a
Revert "timer"
iBicha Feb 7, 2024
9454325
Commands
iBicha Feb 7, 2024
56519dd
setVolume command
iBicha Feb 7, 2024
6f5567f
lounge service timer
iBicha Feb 7, 2024
ee6ecc3
Some state messages
iBicha Feb 9, 2024
f266da8
Lint fix
Feb 9, 2024
dfda492
player state sync
iBicha Feb 9, 2024
b43ac14
voice handler
iBicha Feb 9, 2024
0221d4e
Adjustments, and add a TODO list
iBicha Feb 10, 2024
b2dc5d6
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 10, 2024
e68703d
Lint fix
Feb 10, 2024
7f08ed4
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 10, 2024
f58d03d
comment
iBicha Feb 10, 2024
6aeb272
play at timestamp
iBicha Feb 10, 2024
f125cdf
Remote screen UI
iBicha Feb 11, 2024
896c148
UI adjustment
iBicha Feb 11, 2024
7224545
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 11, 2024
6aec252
Small refactor
iBicha Feb 11, 2024
43d25d4
Refactor long polling
iBicha Feb 11, 2024
6df3ad8
Add TODO
iBicha Feb 11, 2024
026ccf3
Manage logging a bit better to avoid spam in DIAL server
iBicha Feb 12, 2024
ff064ac
cache dial file
iBicha Feb 12, 2024
5a00828
logging
iBicha Feb 12, 2024
e6282c5
comment
iBicha Feb 12, 2024
e8b887b
Join lounge lazily
iBicha Feb 12, 2024
0c4a38d
long poller tweak
iBicha Feb 12, 2024
b5c0de7
ui tweak
iBicha Feb 12, 2024
64c6176
set shouldQuit field
iBicha Feb 12, 2024
2ca4d31
remote connect notification
iBicha Feb 12, 2024
1d122db
Lint fix
Feb 12, 2024
ebcd8a8
Randomize device id, new device on start
iBicha Feb 14, 2024
64d3993
Lint fix
Feb 14, 2024
3c9d647
Lounge disconnect on start
iBicha Feb 14, 2024
16c82bc
Speed up lounge by not blocking on sent messages
iBicha Feb 14, 2024
b910605
add version to lounge data saved
iBicha Feb 14, 2024
e241d08
dial server fix rendezvous
iBicha Feb 14, 2024
8bc66bb
todo
iBicha Feb 15, 2024
54a285a
no message
iBicha Feb 15, 2024
b69a4e8
Display network name
iBicha Feb 15, 2024
2aab5c2
playlist changes
iBicha Feb 16, 2024
4450f4b
Lint fix
Feb 16, 2024
c723ca2
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 16, 2024
4f1b4d1
tweak params
iBicha Feb 16, 2024
1bbbb08
tweak
iBicha Feb 17, 2024
13d53b4
player state
iBicha Feb 17, 2024
46070b9
comments
iBicha Feb 17, 2024
6e3f496
Refactor Video Queue (#295)
iBicha Feb 19, 2024
cf43392
Merge branch 'main' into feature/dial-cast-poc
iBicha Feb 19, 2024
91cc7d2
queue notification and previous/next state
iBicha Feb 19, 2024
aa21255
Lint fix
Feb 19, 2024
9527f4a
very basic error handling
iBicha Feb 19, 2024
c1f649c
Update changelog and privacy policy
iBicha Feb 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Support for casting from the YouTube app (also known as Lounge API, or LeanBack)
- Connect to Playlet from the YouTube mobile app or from certain browsers (such as Chrome)
- Connect to Playlet from local network, or through the TV code.
- On the cast dialog in your YouTube mobile app, you might see two listings for the same device. E.g. `Roku TV` and `Playlet on Roku TV`. Use the `Playlet on` one to cast to Playlet, and the other one to cast to the regular YouTube TV app.
- The functionality is still experiemntal, and has some limitations. To name a few:
- The queue does not perfectly sync between the mobile app and Playlet, espeically if it gets modified by the web app or in Playlet.
- Many functions (such as d-pad controls, setting volume) are not working due to OS limitations
- Many functions (such as changing the subtitle settings) are not currently implemented
- **ATTENTION**: This feature is not very privacy friendly. When connected to a lounge, all network traffic (videos played, queued, etc) go through YouTube servers. For this reason, certain measures are taken:
- While Playlet broadcasts its casting capabilities to the local network, it does not connect to a network for the first time until:
- A device connects to Playlet (using DIAL/Connect using Wi-Fi)
- A `Link with TV code` is generated, by visiting the `Remote` -> `Link with TV code` tab.
- Playlet disconnects from previous lounge sessions on start, and joins a new one instead of one continous session. In other words, restarting Playlet will disconnect your second device. This is a feature, not a bug.
- Playlet does not expose device details, and uses a randomly generated id on each start, instead of consistent device id.

### Changed

- The queue no longer contains playlists. When a playlist is added to the queue, the entire playlist is loaded.
- It can take a few seconds to load large playlist before it can be added to the queue
- This is done to be more compatible more the lounge, which does not contain "Playlists in the queue" concept.
- The `Web App` tab is now called `Remote`. It can be used to open the web app, or connect using Lounge (cast from YouTube)
- Removed `fields` from Invidious requests, as per [https://github.com/iv-org/invidious/pull/4276](https://github.com/iv-org/invidious/pull/4276)
- The `POST /api/queue/` no longer returns the current queue. Instead it returns a 204.

## [0.19.2] - 2024-02-11

Expand Down
35 changes: 24 additions & 11 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,52 @@
# PRIVACY POLICY

## Playlet

Playlet, the Roku app, does not collect any user information whatsoever.
In fact, it does not have any servers capable of collecting any form of data.

Playlet does not show advertising, whether personalized or non personalized.

## Invidious

Playlet may request information from one or more [Invidious](https://invidious.io/)
servers, depending on user configuration. With these requests, absolutely no
user data is collected, and only the bare minimum and necessary information
servers, depending on user configuration. With these requests, absolutely no
user data is collected, and only the bare minimum and necessary information
is sent, which is used to enable the viewing of content.

Please note that some of the [Invidious](https://invidious.io/) servers used
could be public instances hosted by volenteers. These instances are available
[here](https://api.invidious.io/).
[here](https://api.invidious.io/).

While invidious does no data collection by default, and it is in fact built to
be privacy driven, but in theory, from a playlet request it is possible to
know the IP address of a user, the identifiers of watched video, and search
be privacy driven, but in theory, from a playlet request it is possible to
know the IP address of a user, the identifiers of watched video, and search
keywords. In case the user has privacy concerns, they can host their own
Invidious instance and use it from Playlet.

## SponsorBlock

Playlet may request information from a [SponsorBlock](https://github.com/ajayyy/SponsorBlock)
server, in order to get sponsor sections for requested videos to watch.
With these requests, absolutely no
user data is collected, and only the bare minimum and necessary information
With these requests, absolutely no
user data is collected, and only the bare minimum and necessary information
is sent, which is used to enable the viewing of content.

Playlet uses hashing when requesting video metadata, which obfuscates
Playlet uses hashing when requesting video metadata, which obfuscates
the video id requested. This is a SponsorBlock privacy feature that protects
user privacy.

Additionally, Playlet may send a "skipped" event to SponsorBlock, to indicate
that a section of a video has been skipped. This event does not have any
tracking information attached to it, and it is anonymous. It is merely to
indicate to contributers how much their contributions are being used in the wild.
that a section of a video has been skipped. This event does not have any
tracking information attached to it, and it is anonymous. It is merely to
indicate to contributers how much their contributions are being used in the wild.

## LeanBack

LeanBack, also known as Lounge API, also known as "cast from phone". Is a feature that Playlet implmements as a convenience to allow users to use a separate device to browse and cast videos.
When this feature is used, all traffic related to the videos being watched, added to the queue, and the video player state is routed through YouTube servers.

Playlet tries to preserve user privacy, to the best of its ability, by implementing several techniques, such as randomizing device id, using a fresh session on each start, and only providing necessary fields for the feature to function.
Additionally, Playlet does not join a session/lounge, until a device is attempting to connect using DIAL (**DI**scovery **A**nd **L**aunch spec), or when a `Link with TV code` is generated, by visiting the appropriate settings page.

If you have privacy concerns and do not wish to use this functionality, simply do not connect from a local network using the YouTube app or the browser, and do not visit the `Link with TV code` tab.
51 changes: 19 additions & 32 deletions docs/playlet-web-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,9 @@ paths:
description: No Content
/api/queue:
get:
summary: Get play queue
summary: Get video queue
description: Get the current videos in the queue.
operationId: getPlayQueue
operationId: getVideoQueue
responses:
"200":
description: OK
Expand All @@ -151,56 +151,43 @@ paths:
schema:
type: object
properties:
index:
type: integer
playlistIndex:
type: integer
items:
type: array
items:
$ref: "#/components/schemas/PlayQueueObject"
$ref: "#/components/schemas/VideoQueueObject"
index:
type: integer
nowPlaying:
$ref: "#/components/schemas/VideoQueueObject"
post:
summary: Add to play queue
summary: Add to video queue
description: Add a video or a playlist to the queue.
operationId: addToPlayQueue
operationId: addToVideoQueue
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/PlayQueueObject"
$ref: "#/components/schemas/VideoQueueObject"
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
index:
type: integer
playlistIndex:
type: integer
items:
type: array
items:
$ref: "#/components/schemas/PlayQueueObject"
"204":
description: No Content
delete:
summary: Clear play queue
description: Clear the play queue.
operationId: clearPlayQueue
summary: Clear video queue
description: Clear the video queue.
operationId: clearVideoQueue
responses:
"204":
description: No Content
/api/queue/play:
post:
summary: Play video or playlist
summary: Play video
description: Play a video or playlist. This adds the video or playlist to the current position in the queue and plays it.
operationId: playPlayQueue
operationId: playVideoQueue
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/PlayQueueObject"
$ref: "#/components/schemas/VideoQueueObject"
responses:
"204":
description: No Content
Expand Down Expand Up @@ -574,7 +561,7 @@ components:
sponsorblock.show_notifications:
type: boolean
# TODO:P1 add the rest of properties
PlayQueueObject:
VideoQueueObject:
type: object
properties:
videoId:
Expand Down
20 changes: 10 additions & 10 deletions playlet-lib/src/components/AppController/AppController.bs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "pkg:/components/Dialog/DialogUtils.bs"
import "pkg:/components/Navigation/LongPress.bs"
import "pkg:/components/PlayQueue/PlayQueueViewUtils.bs"
import "pkg:/components/VideoPlayer/VideoUtils.bs"
import "pkg:/components/VideoQueue/VideoQueueUtils.bs"
import "pkg:/components/VideoQueue/VideoQueueViewUtils.bs"
import "pkg:/source/utils/FocusManagement.bs"
import "pkg:/source/utils/Logging.bs"
import "pkg:/source/utils/StringUtils.bs"
Expand Down Expand Up @@ -86,13 +86,13 @@ function onKeyEvent(key as string, press as boolean) as boolean
end if

if key = OPTIONS_LONG_PRESS_KEY and press
LogInfo("Opening PlayQueueView")
PlayQueueViewUtils.Open(m.top.playQueue, m.top)
LogInfo("Opening VideoQueueView")
VideoQueueViewUtils.Open(m.top.videoQueue, m.top)
return true
end if

if key = "options" and not press
if VideoUtils.ToggleVideoPictureInPicture()
if VideoQueueUtils.ToggleVideoPictureInPicture(m.top.videoQueue)
return true
end if
end if
Expand All @@ -102,26 +102,26 @@ function onKeyEvent(key as string, press as boolean) as boolean
end if

if key = "play"
if VideoUtils.TogglePause()
if VideoQueueUtils.TogglePause(m.top.videoQueue)
return true
end if
end if

if key = "pause"
if VideoUtils.PauseVideo()
if VideoQueueUtils.Pause(m.top.videoQueue)
return true
end if
end if

if key = "playonly"
if VideoUtils.ResumeVideo()
if VideoQueueUtils.Play(m.top.videoQueue)
return true
end if
end if

if key = "back"
if VideoUtils.IsVideoPlayerOpen() and not VideoUtils.IsVideoPlayerFullScreen()
if VideoUtils.ToggleVideoPictureInPicture()
if VideoQueueUtils.IsVideoPlayerOpen(m.top.videoQueue) and not VideoQueueUtils.IsVideoPlayerFullScreen(m.top.videoQueue)
if VideoQueueUtils.ToggleVideoPictureInPicture(m.top.videoQueue)
return true
end if
end if
Expand Down
2 changes: 1 addition & 1 deletion playlet-lib/src/components/AppController/AppController.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<interface>
<field id="root" type="node" />
<field id="stack" type="node" />
<field id="playQueue" type="node" />
<field id="videoQueue" type="node" />
<function name="PushScreen" />
<function name="PopScreen" />
<function name="FocusTopScreen" />
Expand Down
3 changes: 0 additions & 3 deletions playlet-lib/src/components/ContentNode/ChannelContentNode.bs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import "pkg:/source/AsyncTask/Tasks.bs"
import "pkg:/source/utils/ErrorUtils.bs"
import "pkg:/source/utils/Logging.bs"

function Init()
end function

function LoadChannel(invidiousNode as object) as void
if m.contentTask <> invalid
m.contentTask.cancel = true
Expand Down
55 changes: 55 additions & 0 deletions playlet-lib/src/components/ContentNode/PlaylistContentNode.bs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import "pkg:/components/Dialog/DialogUtils.bs"
import "pkg:/components/VideoFeed/FeedLoadState.bs"
import "pkg:/source/AsyncTask/AsyncTask.bs"
import "pkg:/source/AsyncTask/Tasks.bs"
import "pkg:/source/utils/ErrorUtils.bs"
import "pkg:/source/utils/Logging.bs"

function LoadPlaylistPage(invidiousNode as object) as void
LoadPlaylist(invidiousNode, true)
end function

function LoadPlaylistAll(invidiousNode as object) as void
LoadPlaylist(invidiousNode, false)
end function

function LoadPlaylist(invidiousNode as object, singlePage as boolean) as void
if m.contentTask <> invalid
m.contentTask.cancel = true
end if

loadState = m.top.loadState
if loadState = FeedLoadState.Loading or loadState = FeedLoadState.Loaded
return
end if

m.top.loadState = FeedLoadState.Loading
m.contentTask = AsyncTask.Start(Tasks.PlaylistContentTask, {
content: m.top
invidious: invidiousNode
singlePage: singlePage
}, OnPlaylistContentTaskResult)
end function

function OnPlaylistContentTaskResult(output as object) as void
m.contentTask = invalid

if output.cancelled
return
end if

if not output.success or not output.result.success
' output.error for unhandled exception
error = output.error
if error = invalid
' output.result.error for network errors
error = output.result.error
end if
error = ErrorUtils.Format(error)
LogError(error)
playlistId = output.task.input.content.playlistId
message = `Failed to load playlist ${playlistId}\n${error}`
DialogUtils.ShowDialog(message, "Playlist load fail", true)
return
end if
end function
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<component name="PlaylistContentNode" extends="ContentNode">
<interface>
<function name="LoadPlaylistPage" />
<function name="LoadPlaylistAll" />

<field id="type" type="string" value="playlist" />
<field id="loadState" type="string" />
<!-- index in a feed -->
Expand Down
57 changes: 57 additions & 0 deletions playlet-lib/src/components/ContentNode/PlaylistContentTask.bs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import "pkg:/components/Services/Invidious/InvidiousService.bs"
import "pkg:/components/Services/Invidious/InvidiousToContentNode.bs"
import "pkg:/components/VideoFeed/FeedLoadState.bs"

@asynctask
function PlaylistContentTask(input as object) as object
contentNode = input.content
invidiousNode = input.invidious
singlePage = input.singlePage

if m.top.cancel
contentNode.loadState = FeedLoadState.None
return invalid
end if

service = new Invidious.InvidiousService(invidiousNode)
instance = service.GetInstance()

while true
index = contentNode.getChildCount()
response = service.GetPlaylist(contentNode.playlistId, index, m.top.cancellation)

if m.top.cancel
contentNode.loadState = FeedLoadState.None
return invalid
end if

metadata = response.Json()

if not response.IsSuccess() or metadata = invalid
contentNode.loadState = FeedLoadState.Error
return {
success: false
error: response.ErrorMessage()
}
end if

InvidiousContent.ToPlaylistContentNode(contentNode, metadata, instance)
childCount = contentNode.getChildCount()

if metadata.videos.Count() = 0 or childCount >= metadata.videoCount
contentNode.loadState = FeedLoadState.Loaded
return {
success: true
}
else
contentNode.loadState = FeedLoadState.LoadedPage
if singlePage
return {
success: true
}
end if
end if
end while

return invalid
end function
Loading
Loading