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

Support to Cast Any Website to Chromecast (via DashCast) #102

Merged
merged 19 commits into from
May 23, 2018

Conversation

marcosdiez
Copy link
Contributor

@marcosdiez marcosdiez commented May 14, 2018

In another step to make catt cast ALL the things, here is a simple PR to make it cast any website to Chromecast.

The heavy lifting is done by DashCast ( http://stestagg.github.io/dashcast/ ), which was easier than I thought to implement on catt because pychromecast already supports it.

I decided not to change the UI by falling back to dastcast every time youtube-dl can not handle a URL.

That means you just needs to type:

catt cast https://www.github.com/

to see github on your chromecast!

catt info shows the title of the webpage that is being displayed.

@marcosdiez marcosdiez changed the title dashcast Support to Cast Any Website to ChromeCast (via DashCast) May 14, 2018
@marcosdiez marcosdiez changed the title Support to Cast Any Website to ChromeCast (via DashCast) Support to Cast Any Website to Chromecast (via DashCast) May 14, 2018
@skorokithakis
Copy link
Owner

Thank you for the PR! I'm a bit apprehensive about the mode of failure where the URL mistakenly doesn't return a video and the site itself gets cast. Maybe this should be an explicit switch that would force casting a site?

@theychx, what do you think?

@mdiez-modus
Copy link

I though a lot about that. Maybe have a completely different command, like catt cast_url URL
I don't like heuristics either.
Since this is a delicate topic in which is very easy for the 3 of us to have at least 4 different opinions, I let you guys decide what the best interface should be.

I can easily implement it after a conclusion is achieved!

@skorokithakis
Copy link
Owner

skorokithakis commented May 17, 2018

Yes, I think a dedicated command might be much better. I think this also fits with the spirit of catt, generally, and the code doesn't seem very complicated. Let's wait for @theychx to chime in as well.

@mdiez-modus
Copy link

my code is broken. please don't merge. I am adding the cast_url command as well. please do not merge yet

@theychx
Copy link
Collaborator

theychx commented May 17, 2018

I'm not keen on the current approach. Making a separate command seems like the obvious solution, as this would mean that you could just call setup_cast with controller="dashcast", prep="app" and not need to modify StreamInfo or setup_cast.

@skorokithakis
Copy link
Owner

Yes, I'm keen on that for the same reason, it would remove much of the if is_website code duplication that smells bad.

@theychx
Copy link
Collaborator

theychx commented May 17, 2018

@mdiez-modus cast_url is not a good name, as it's not distinct enough from the cast command and it's functionality. I thought of cast_site, but that's perhaps not a very good name either. @skorokithakis ?

@skorokithakis
Copy link
Owner

mirror_site?

@theychx
Copy link
Collaborator

theychx commented May 17, 2018

Yeah, or just mirror.

@skorokithakis
Copy link
Owner

I think mirror_site is a bit more explicit as to what you're mirroring, but I have no strong preference either way.

@mdiez-modus
Copy link

mirror_site ? I would prefer display_website ...

@skorokithakis
Copy link
Owner

The official terminology that chrome uses is "tab mirroring", so this kind of keeps in line with that.

@marcosdiez
Copy link
Contributor Author

Sorry @skorokithakis but that's completely different. What Chrome does is open the tab in itself and mirror what it has rendered to Chromecast.
What I plan to do is tell Chromecast to open a URL. There is no mirroring. The website is even accessed using Chromecast's own user agent.

@skorokithakis
Copy link
Owner

Hmm, yes, good point. Do you want to make the changes first, and decide on the name later? Renaming the command is a one-line change anyway, and I think we have consensus that this is a useful feature to include.

@marcosdiez
Copy link
Contributor Author

The idea of making this a separate URL really made everything easier.

I did not like to have to change StreamInfo and the changes I did to setup_cast were minimal.

I also made sure we can reload URLs, something that was previously not possible.

We now have only to decide how the command will be called.

I let to you guys decide. I am happy with the functionality!

Thanks for the code review

Copy link
Collaborator

@theychx theychx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is working nicely, although it's a real shame we can't utilize the reload functionality of the controller.

@@ -334,11 +343,13 @@ def _prep_app(self):
def _prep_control(self):
"""Make shure chromecast is in an active state."""

if self._cast.app_id == DASHCAST_APP_ID:
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hack, please remove.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a hack. With this line, it's possible to type catt info and see the title of the current loaded webpage. Without it, we get a boring Error: Nothing is currently playing..

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also allows the user to use catt ffwd 60 while a webpage is loaded, and then get the irrelevant error message Error: Stream is not seekable.
info and status displays video/audio info (as descibed in the command helptexts).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok... you convinced me!

if self._cast.app_id == BACKDROP_APP_ID or not self._cast.app_id:
raise CattCastError("Chromecast is inactive.")
self._cast.media_controller.block_until_active(1.0)
if self._cast.media_controller.status.player_state in ["UNKNOWN", "IDLE"]:
raise CattCastError("Nothing is currently playing.")
raise CattCastError("Nothing is currently playing. [APP_ID={}]".format(self._cast.app_id))
Copy link
Collaborator

@theychx theychx May 18, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app id is not useful to the regular user (not in this context at least).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was useful to me, but I don't mind removing it.

self._load_url(url) # this will actually take us to the "Waiting for URL" screen
while not self._is_waiting_for_url():
time.sleep(1)
self._load_url(url) # this will finally load the URL
Copy link
Collaborator

@theychx theychx May 18, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have found a way to make this work without going through these hoops:
Override _prep_app in your dashcast controller, so that it always kills the running app.
Add a backdrop_ready flag to CastStatusListener.
In the overridden prep_app, kill the running app and wait for the flag to be set.

catt/cli.py Outdated
@@ -181,6 +181,15 @@ def process_subtitle(ctx, param, value):
return value


@cli.command(short_help="Makes your Chromecast display any webpage")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the imperative mood, like in the other command help texts.

@@ -110,8 +116,11 @@ def setup_cast(device_name, video_url=None, prep=None, controller=None):

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to fail here, if the type of the chosen cast device is not in supported_device_types.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of failing, I decided to just print a warning for two reasons:

  • this is what catt currently do for other stuff
  • warnings are more future proof than failures

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A warning is not going to cut it. The warning logic in setup_cast is currently used to make sure that the default cast controller is returned to cli.py when the selected device is not supported by a custom controller. In cli.py there is then a fallback when trying to cast a playlist with the default castcontroller. With your functionality, there is no fallback, so we need to fail.
(and you print a warning, and then return the dashcast castcontroller anyway.)

Copy link
Collaborator

@theychx theychx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more notes.

@@ -110,8 +116,11 @@ def setup_cast(device_name, video_url=None, prep=None, controller=None):

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A warning is not going to cut it. The warning logic in setup_cast is currently used to make sure that the default cast controller is returned to cli.py when the selected device is not supported by a custom controller. In cli.py there is then a fallback when trying to cast a playlist with the default castcontroller. With your functionality, there is no fallback, so we need to fail.
(and you print a warning, and then return the dashcast castcontroller anyway.)

self.app_ready = threading.Event()
self.set_app_id(app_id, active_app_id)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please just add a backdrop_ready flag of the threading.Event kind, and then initiate it like app_ready, and expand the logic in new_cast_status so the flag is set/cleared in the same manner as app_ready.

self._cast_listener.set_app_id(DASHCAST_APP_ID)

self._cast.start_app(DASHCAST_APP_ID)
self._cast_listener.app_ready.wait()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine _prep_app being as simple as this:

self.kill()
self._cast_listener.backdrop_ready.wait()
self._cast.start_app(self._cast_listener.app_id)
self._cast_listener.app_ready.wait()

@marcosdiez
Copy link
Contributor Author

@theychx I did all the changes you requested. _prep_app has one extra line than what you had in mind, because there is no need to kill the current app unless it's dashcast itself.

I believe it should be ready to be merged now!

Copy link
Collaborator

@theychx theychx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more note.

self.app_ready = threading.Event()
self.set_app_id(app_id, active_app_id)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I wanted was this:

class CastStatusListener:
    def __init__(self, app_id, active_app_id):
        self.app_id = app_id
        self.app_ready = threading.Event()
        self.backdrop_ready = threading.Event()
        if active_app_id == app_id:
            self.app_ready.set()
        elif active_app_id == BACKDROP_APP_ID:
            self.backdrop_ready.set()

    def new_cast_status(self, status):
        if status.app_id == self.app_id:
            self.app_ready.set()
            self.backdrop_ready.clear()
        elif status.app_id == BACKDROP_APP_ID:
            self.backdrop_ready.set()
            self.app_ready.clear()
        else:
            self.app_ready.clear()
            self.backdrop_ready.clear()

You might want to know that after the listener has been registered in the constructor of the CastController baseclass, pychromecast automatically calls the new_cast_status method of the listener object whenever there is a change.
Also, threading.Event flags are initially set to false.

@marcosdiez
Copy link
Contributor Author

I see. It all makes sense. By the way I was reading pychromecast's source code and I think there is a way to bypass backdrop completelly. I will test tomorrow after work

@marcosdiez
Copy link
Contributor Author

@theychx now it's G R E A T !
No more going to backdrop and going back to DashCast! Pages load a few seconds faster because of that.
Of course I did not like the internal API call I had to do. But that's what we have for today.

I will soon write a patch to pychromecast.

@skorokithakis
Copy link
Owner

That's pretty fantastic. Hopefully your patch will stabilize the API so it doesn't change from under our feet.

@marcosdiez
Copy link
Contributor Author

My PR to pychromecast is here: home-assistant-libs/pychromecast#237
Now it's up to them to merge it!

Copy link
Collaborator

@theychx theychx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great find!

# becomes unresponsive after a website is loaded
self._cast.socket_client.receiver_controller.launch_app(self._cast_listener.app_id, force_launch=True)
self._cast.start_app(self._cast_listener.app_id)
self._cast_listener.app_ready.wait()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you clear the app_ready flag before force launching the app, it would not be necessary to modify CastStatusListener or use the class variable. Also the self._cast.start_app call is not needed anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't not modifying CastStatusListener make things rely on a race condition ?
The modified version of CastStatusListener guarantees that the app is only ready when it should be.

The DashCast app has an "Application Is Starting" state in which it can not yet receive URLs: https://github.com/stestagg/dashcast/blob/master/receiver.html#L163

This is one of the reasons, actually, that I liked that catt info showed the state of the DashCast app.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't not modifying CastStatusListener make things rely on a race condition ?

Hmm, I guess thats true, although it's extremely unlikely that this would ever be a real problem. I've tested my own suggestion and could not get it to fail.
I'm keen on the code needed to circumvent the odd behaviour of dashcast, being contained within the overridden _prep_app. And I am honestly not keen on the class variable and the use of it in CastStatusListener.

@skorokithakis I'm not completely sure I'm in the right here, any input?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not like the change either on CastStatusListener but either we leave things as is or we patch DashCast to behave as expected. Race conditions are must always be avoided because debugging them is a real pain. My vote is to do nothing and push the merge button.

Copy link
Owner

@skorokithakis skorokithakis May 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to agree with @marcosdiez here, if eliminating a race condition means that the code will be a bit uglier, I can live with that, since it will be really hard to debug something like "Sometimes catt doesn't work" or "catt doesn't work on my new Chromecast".

I would, however, like to have comments here and on CastStatusListener explaining exactly which race condition this guards against, and how it does that. I can definitely see us coming back in a year, not remembering this discussion and saying "Oh we don't actually need this, we can just clear the app_ready flag" and fall into the race condition trap.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it looks like your suggestion sets the app_ready flag if the app is not DashCast, but don't we need a way to tell that DashCast is ready? Sorry to be such a pain, I just don't understand the flow here exactly.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, but he also implemented the extra status text check, and it is the necessity of this I am contesting.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@theychx Can we discuss somewhere more real-time? I've created a Gitter room and invited you there (you can just log in with Github).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcosdiez You're welcome too, of course.

Copy link
Owner

@skorokithakis skorokithakis May 22, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @marcosdiez, @theychx and I discussed this on Gitter, and we agree that eliminating the race condition makes the most sense. However, could you change the code in the check to this?:

def _is_app_ready(self, status):
    if status.app_id == self.app_id == DASHCAST_APP_ID:
        # Big-ass comment about why this is here here.
        return status.status_text == "Application ready"
    return status.app_id == self.app_id

That way, we get rid of the confusingly-named class variable that's only used in one place, and we have a comment on the special case explaining exactly why it happens.

Also, could you add a comment on the check in __init__ on the CastStatusListener about why the DashCast check is necessary? I think this is a good compromise between clearly documenting the special-case code and eliminating any potential race conditions.

# becomes unresponsive after a website is loaded
self._cast.socket_client.receiver_controller.launch_app(self._cast_listener.app_id, force_launch=True)
self._cast.start_app(self._cast_listener.app_id)
self._cast_listener.app_ready.wait()
Copy link
Owner

@skorokithakis skorokithakis May 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to agree with @marcosdiez here, if eliminating a race condition means that the code will be a bit uglier, I can live with that, since it will be really hard to debug something like "Sometimes catt doesn't work" or "catt doesn't work on my new Chromecast".

I would, however, like to have comments here and on CastStatusListener explaining exactly which race condition this guards against, and how it does that. I can definitely see us coming back in a year, not remembering this discussion and saying "Oh we don't actually need this, we can just clear the app_ready flag" and fall into the race condition trap.

Copy link
Collaborator

@theychx theychx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcosdiez Thank you for this contribution! We can merge once you have adopted @skorokithakis final suggestion.

@marcosdiez
Copy link
Contributor Author

Hi guys,
sorry for not participating in the Gitter discussion. I was busy with my real job :/

@theychx you were right, self._cast_listener.app_ready.wait() was redundant and therefore was removed

@skorokithakis Yet on the race condition topic, I did check on the YouTube and Default Media Player app and they both already start in a ready status.

I did describe the race condition in the code as best as I could. Feel free to request further changes or modify yourself.

And by the way, the triple comparison was a nice trick that I was not aware of. Thanks for teaching me that one!

@skorokithakis
Copy link
Owner

Thank you, @marcosdiez! The comment looks good to me. However, you seem to have removed start_app() instead of wait()? Is that a mistake?

@theychx
Copy link
Collaborator

theychx commented May 23, 2018

It's cool. He just quoted the wrong line, the code is correct.

@skorokithakis
Copy link
Owner

Ah alright, great. Are you good to merge this, @theychx?

@theychx
Copy link
Collaborator

theychx commented May 23, 2018

Yep

@skorokithakis
Copy link
Owner

Fantastic! There are a few conflicts, it seems, which is a bit odd since we didn't do much to master. Is either of you at a computer and able to resolve those?

@theychx
Copy link
Collaborator

theychx commented May 23, 2018

It was just the gitter badge.

@skorokithakis
Copy link
Owner

Thanks, I realized there was a button but it didn't show on mobile.

@skorokithakis skorokithakis merged commit b66f205 into skorokithakis:master May 23, 2018
@skorokithakis
Copy link
Owner

Merged, thank you both!

@theychx theychx mentioned this pull request Sep 6, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants