Repository: postlund/pyatv
Pull request #2079 (2023-06-30 12:23:05)

airplay: Fix play_url with newer tvOS versions
The play_url method has been broken for a very long time as I believed
Apple wasn't maintaining the underlying functionality anymore. They do,
but an AirPlay session must be established for it to work now. So I
have added that and verified with an Apple TV 3 and 4K running
tvOS 14.5.

Relatest to #2064

<a href="https://gitpod.io/#https://github.com/postlund/pyatv/pull/2079"><img src="https://gitpod.io/button/open-in-gitpod.svg"/></a>



codecov[bot]: ## [Codecov](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) Report
Patch coverage: **`78.34`**% and project coverage change: **`-0.19`** :warning:
> Comparison is base [(`7cae1a0`)](https://app.codecov.io/gh/postlund/pyatv/commit/7cae1a0446db329ab24694dc724109961b00658a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 88.84% compared to head [(`36c19c0`)](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 88.65%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #2079      +/-   ##
==========================================
- Coverage   88.84%   88.65%   -0.19%     
==========================================
  Files         162      162              
  Lines       10863    10942      +79     
==========================================
+ Hits         9651     9701      +50     
- Misses       1212     1241      +29     
```


| [Impacted Files](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) | Coverage Δ | |
|---|---|---|
| [pyatv/protocols/raop/protocols/airplayv2.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3AvcHJvdG9jb2xzL2FpcnBsYXl2Mi5weQ==) | `29.70% <27.58%> (-0.17%)` | :arrow_down: |
| [pyatv/protocols/raop/protocols/\_\_init\_\_.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3AvcHJvdG9jb2xzL19faW5pdF9fLnB5) | `93.50% <80.76%> (-6.50%)` | :arrow_down: |
| [pyatv/protocols/airplay/player.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL2FpcnBsYXkvcGxheWVyLnB5) | `89.23% <85.71%> (-6.69%)` | :arrow_down: |
| [pyatv/protocols/airplay/\_\_init\_\_.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL2FpcnBsYXkvX19pbml0X18ucHk=) | `73.50% <86.66%> (+0.84%)` | :arrow_up: |
| [pyatv/support/http.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9odHRwLnB5) | `94.15% <96.15%> (-0.04%)` | :arrow_down: |
| [pyatv/exceptions.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvZXhjZXB0aW9ucy5weQ==) | `96.77% <100.00%> (+0.10%)` | :arrow_up: |
| [pyatv/protocols/raop/protocols/airplayv1.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3AvcHJvdG9jb2xzL2FpcnBsYXl2MS5weQ==) | `97.05% <100.00%> (+0.39%)` | :arrow_up: |
| [pyatv/protocols/raop/stream\_client.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3Avc3RyZWFtX2NsaWVudC5weQ==) | `90.76% <100.00%> (+0.94%)` | :arrow_up: |
| [pyatv/protocols/raop/timing.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3AvdGltaW5nLnB5) | `100.00% <100.00%> (ø)` | |
| [pyatv/support/rtsp.py](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9ydHNwLnB5) | `94.82% <100.00%> (ø)` | |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/postlund/pyatv/pull/2079/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/postlund/pyatv/pull/2079?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).

Ghawken: Testing this on my non-HA use of your great and getting greater, pyatv library.

Play_url works on my testing (>OS 16.5)
Apple4k gen 3

Works with:
https://gist.github.com/jsturgis/3b19447b304616f18657
Pretty much mp4s here

Get an NSOSDomainError when trying to playback camera streams from BlueIris, or camera direct.
Either http://temp.m fails and so does raw camera stream.
Presume  this is because of nature of stream, encoders used etc.




postlund: Thanks for the detailed testing @Ghawken (and sorry for the late response). I honestly don't know what AirPlay is supposed to support in this particular use case, so I can't really tell what is expected to work or not. Figuring that out would be good. Feel free to open a separate issue and attach any findings you make there to keep track of it, maybe we can make some hacks to extend support over time.

Patch:
diff --git a/README.md b/README.md
index 149fb984..32f43912 100644
--- a/README.md
+++ b/README.md
@@ -8,15 +8,14 @@ A client library for Apple TV and AirPlay devices
 [![codecov](https://codecov.io/gh/postlund/pyatv/branch/master/graph/badge.svg)](https://codecov.io/gh/postlund/pyatv)
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
 [![PyPi Package](https://badge.fury.io/py/pyatv.svg)](https://badge.fury.io/py/pyatv)
-[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/postlund/pyatv.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/postlund/pyatv/context:python)
 [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/postlund/pyatv)
 [![Downloads](https://pepy.tech/badge/pyatv)](https://pepy.tech/project/pyatv)
 [![PyPI pyversions](https://img.shields.io/pypi/pyversions/pyatv.svg)](https://pypi.python.org/pypi/pyatv/)
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
 
 This is an asyncio python library for interacting with Apple TV and AirPlay devices. It mainly
-targets Apple TVs (all generations, **including tvOS 15**), but also support audio streaming via AirPlay to
-receivers like the HomePod, AirPort Express and third-party speakers. It can act as remote control to the Music
+targets Apple TVs (all generations, **including tvOS 15 and later**), but also supports audio streaming via AirPlay
+to receivers like the HomePod, AirPort Express and third-party speakers. It can act as remote control to the Music
 app/iTunes in macOS.
 
 All the documentation you need is available at **[pyatv.dev](https://pyatv.dev)**.
@@ -29,6 +28,9 @@ Some examples include:
 * Metadata retrieval with push updates
 * Stream files via AirPlay
 * List and launch installed apps
+* List and switch user accounts
+* Add, remove or set audio output devices (e.g. HomePods)
+* Keyboard support
 
 ...and lots more! A complete list is available [here](https://pyatv.dev/documentation/supported_features/).
 
diff --git a/chickn.yaml b/chickn.yaml
index f4508cbb..fa21517f 100644
--- a/chickn.yaml
+++ b/chickn.yaml
@@ -8,7 +8,7 @@ variables:
     - pyatv
     - scripts
     - examples
-  cs_exclude_words: cann,cant,asai,cafs
+  cs_exclude_words: infoms,cann,cant,asai,cafs
   requirements_file: requirements/requirements.txt
   miniaudio_package: $(grep miniaudio= requirements/requirements.txt)
 
diff --git a/docs/api/pyatv/exceptions.html b/docs/api/pyatv/exceptions.html
index bb2257dd..4411b4a4 100644
--- a/docs/api/pyatv/exceptions.html
+++ b/docs/api/pyatv/exceptions.html
@@ -33,6 +33,9 @@ link_group: api
 <h4><code><a title="pyatv.exceptions.ConnectionFailedError" href="#pyatv.exceptions.ConnectionFailedError">ConnectionFailedError</a></code></h4>
 </li>
 <li>
+<h4><code><a title="pyatv.exceptions.ConnectionLostError" href="#pyatv.exceptions.ConnectionLostError">ConnectionLostError</a></code></h4>
+</li>
+<li>
 <h4><code><a title="pyatv.exceptions.DeviceIdMissingError" href="#pyatv.exceptions.DeviceIdMissingError">DeviceIdMissingError</a></code></h4>
 </li>
 <li>
@@ -102,7 +105,7 @@ link_group: api
 </header>
 <section id="section-intro">
 <p>Local exceptions used by library.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L1-L123" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L1-L127" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -119,7 +122,7 @@ link_group: api
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when authentication fails.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L23-L24" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L27-L28" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -132,7 +135,7 @@ link_group: api
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when device mandates a backoff period.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L59-L60" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L63-L64" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -148,7 +151,7 @@ link_group: api
 <p>Typically, public interface methods (e.g. any method available via the object
 returned by <code><a title="pyatv.connect" href="index#pyatv.connect">connect()</a></code>) becomes blocked after either calling close or because
 the connection was closed for some other reason.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L109-L115" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L113-L119" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -161,7 +164,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when a command (e.g. play or pause) failed.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L67-L68" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L71-L72" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -181,13 +184,26 @@ the connection was closed for some other reason.</p></section>
 <li>builtins.BaseException</li>
 </ul>
 </dd>
+<dt id="pyatv.exceptions.ConnectionLostError"><code class="flex name class">
+<span>class <span class="ident">ConnectionLostError</span></span>
+<span>(</span><span>*args, **kwargs)</span>
+</code></dt>
+<dd>
+<section class="desc"><p>Thrown when a connection is lost.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L19-L20" class="git-link">Browse git</a></div>
+<h3>Ancestors</h3>
+<ul class="hlist">
+<li>builtins.Exception</li>
+<li>builtins.BaseException</li>
+</ul>
+</dd>
 <dt id="pyatv.exceptions.DeviceIdMissingError"><code class="flex name class">
 <span>class <span class="ident">DeviceIdMissingError</span></span>
 <span>(</span><span>*args, **kwargs)</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when device id is missing.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L55-L56" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L59-L60" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -201,7 +217,7 @@ the connection was closed for some other reason.</p></section>
 <dd>
 <section class="desc"><p>Thrown when a HTTP error occurs.</p>
 <p>Initialize a new HttpError.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L91-L102" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L95-L106" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li><a title="pyatv.exceptions.ProtocolError" href="#pyatv.exceptions.ProtocolError">ProtocolError</a></li>
@@ -213,7 +229,7 @@ the connection was closed for some other reason.</p></section>
 <dt id="pyatv.exceptions.HttpError.status_code"><code class="name">var <span class="ident">status_code</span> -> int</code></dt>
 <dd>
 <section class="desc"><p>Return status code that triggered the error.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L99-L102" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L103-L106" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
@@ -223,7 +239,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when something is wrong or missing in the config.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L105-L106" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L109-L110" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -236,7 +252,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown if credentials are invalid.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L51-L52" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L55-L56" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -249,7 +265,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when invalid DMAP data is parsed.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L31-L32" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L35-L36" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -262,7 +278,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when a remote sends an invalid response.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L118-L119" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L122-L123" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -275,7 +291,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when trying to perform an action not possible in the current state.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L78-L79" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L82-L83" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -288,7 +304,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when starting AsyncUpdater with no listener.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L43-L44" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L47-L48" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -301,7 +317,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown if credentials are missing.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L47-L48" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L51-L52" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -328,7 +344,7 @@ the connection was closed for some other reason.</p></section>
 <dd>
 <section class="desc"><p>Thrown when address it not in any local subnet.</p>
 <p>DEPRECATED: Not used since 0.7.1. Will be removed in 0.9.0!</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L71-L75" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L75-L79" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -341,7 +357,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when trying to perform an action that is not supported.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L27-L28" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L31-L32" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.NotImplementedError</li>
@@ -356,7 +372,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Raised when a timeout happens.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L122-L123" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L126-L127" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -369,7 +385,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when pairing fails.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L19-L20" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L23-L24" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -382,7 +398,7 @@ the connection was closed for some other reason.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when media playback failed.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L63-L64" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L67-L68" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -398,7 +414,7 @@ the connection was closed for some other reason.</p></section>
 <p>Generic protocol errors includes for instance missing fields, incorrect or
 unexpected types, etc. Any error that can happen when communicating with a device
 that is not covered by another exception is covered by this exception.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L82-L88" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L86-L92" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -415,7 +431,7 @@ that is not covered by another exception is covered by this exception.</p></sect
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when an unknown media kind is found.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L35-L36" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L39-L40" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
@@ -428,7 +444,7 @@ that is not covered by another exception is covered by this exception.</p></sect
 </code></dt>
 <dd>
 <section class="desc"><p>Thrown when an unknown play state is found.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L39-L40" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L43-L44" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.Exception</li>
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index 2f27e2cb..27b0da06 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -1720,6 +1720,7 @@ actually changes.</p>
 <h3>Subclasses</h3>
 <ul class="hlist">
 <li>pyatv.core.facade.FacadeRemoteControl</li>
+<li>pyatv.protocols.airplay.AirPlayRemoteControl</li>
 <li>pyatv.protocols.companion.CompanionRemoteControl</li>
 <li>pyatv.protocols.dmap.DmapRemoteControl</li>
 <li>pyatv.protocols.mrp.MrpRemoteControl</li>
@@ -1997,7 +1998,7 @@ actually changes.</p>
 <dd>
 <div class="api_feature">
 <span>Feature: <a title="pyatv.const.FeatureName.Stop" href="const#pyatv.const.FeatureName.Stop">FeatureName.Stop</a>,</span>
-<span>Supported by: <a title="pyatv.const.Protocol.DMAP" href="const#pyatv.const.Protocol.DMAP">Protocol.DMAP</a>, <a title="pyatv.const.Protocol.MRP" href="const#pyatv.const.Protocol.MRP">Protocol.MRP</a>, <a title="pyatv.const.Protocol.RAOP" href="const#pyatv.const.Protocol.RAOP">Protocol.RAOP</a></span>
+<span>Supported by: <a title="pyatv.const.Protocol.AirPlay" href="const#pyatv.const.Protocol.AirPlay">Protocol.AirPlay</a>, <a title="pyatv.const.Protocol.DMAP" href="const#pyatv.const.Protocol.DMAP">Protocol.DMAP</a>, <a title="pyatv.const.Protocol.MRP" href="const#pyatv.const.Protocol.MRP">Protocol.MRP</a>, <a title="pyatv.const.Protocol.RAOP" href="const#pyatv.const.Protocol.RAOP">Protocol.RAOP</a></span>
 </div>
 <section class="desc"><p>Press key stop.</p></section>
 <div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L314-L317" class="git-link">Browse git</a></div>
diff --git a/pyatv/exceptions.py b/pyatv/exceptions.py
index f0c34510..5221db7e 100644
--- a/pyatv/exceptions.py
+++ b/pyatv/exceptions.py
@@ -16,6 +16,10 @@ class ConnectionFailedError(Exception):
     """Thrown when connection fails, e.g. refused or timed out."""
 
 
+class ConnectionLostError(Exception):
+    """Thrown when a connection is lost."""
+
+
 class PairingError(Exception):
     """Thrown when pairing fails."""
 
diff --git a/pyatv/protocols/airplay/__init__.py b/pyatv/protocols/airplay/__init__.py
index c9df8ef1..252b435d 100644
--- a/pyatv/protocols/airplay/__init__.py
+++ b/pyatv/protocols/airplay/__init__.py
@@ -23,11 +23,12 @@ from pyatv.interface import (
     Features,
     FeatureState,
     PairingHandler,
+    RemoteControl,
     Stream,
 )
 from pyatv.protocols import mrp
 from pyatv.protocols.airplay.ap2_session import AP2Session
-from pyatv.protocols.airplay.auth import extract_credentials, verify_connection
+from pyatv.protocols.airplay.auth import extract_credentials
 from pyatv.protocols.airplay.mrp_connection import AirPlayMrpConnection
 from pyatv.protocols.airplay.pairing import (
     AirPlayPairingHandler,
@@ -36,11 +37,19 @@ from pyatv.protocols.airplay.pairing import (
 from pyatv.protocols.airplay.player import AirPlayPlayer
 from pyatv.protocols.airplay.utils import (
     AirPlayFlags,
+    AirPlayMajorVersion,
+    get_protocol_version,
     is_remote_control_supported,
     parse_features,
     update_service_details,
 )
 from pyatv.protocols.raop import setup as raop_setup
+from pyatv.protocols.raop.protocols import (
+    StreamContext,
+    StreamProtocol,
+    airplayv1,
+    airplayv2,
+)
 from pyatv.support import net
 from pyatv.support.device_info import lookup_model
 from pyatv.support.http import (
@@ -49,6 +58,7 @@ from pyatv.support.http import (
     StaticFileWebServer,
     http_connect,
 )
+from pyatv.support.rtsp import RtspSession
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -68,26 +78,39 @@ class AirPlayFeatures(Features):
         ):
             return FeatureInfo(FeatureState.Available)
 
+        if feature_name == FeatureName.Stop:
+            return FeatureInfo(FeatureState.Available)
+
         return FeatureInfo(FeatureState.Unavailable)
 
 
 class AirPlayStream(Stream):  # pylint: disable=too-few-public-methods
     """Implementation of stream API with AirPlay."""
 
-    def __init__(self, config: BaseConfig, service: BaseService) -> None:
+    def __init__(self, core: Core) -> None:
         """Initialize a new AirPlayStreamAPI instance."""
-        self.config = config
-        self.service = service
+        self.core = core
+        self.config = core.config
+        self.service = core.service
         self._credentials: HapCredentials = parse_credentials(self.service.credentials)
         self._play_task: Optional[asyncio.Future] = None
+        self._connection: Optional[HttpConnection] = None
 
     def close(self) -> None:
         """Close and free resources."""
+        if self._connection is not None:
+            self._connection.close()
+            self._connection = None
         if self._play_task is not None:
             _LOGGER.debug("Stopping AirPlay play task")
             self._play_task.cancel()
             self._play_task = None
 
+    def stop(self) -> None:
+        """Stop current playback."""
+        if self._connection is not None:
+            self._connection.close()
+
     async def play_url(self, url: str, **kwargs) -> None:
         """Play media from an URL on the device.
 
@@ -107,23 +130,56 @@ class AirPlayStream(Stream):  # pylint: disable=too-few-public-methods
             await server.start()
             url = server.file_address
 
-        connection: Optional[HttpConnection] = None
+        takeover_release = self.core.takeover(RemoteControl)
         try:
-            # Connect and verify connection to set up encryption
-            connection = await http_connect(str(self.config.address), self.service.port)
-            await verify_connection(self._credentials, connection)
-
-            player = AirPlayPlayer(connection)
+            # Set up a new connection and wrap it with an AirPlay stream of
+            # correct protocol version
+            self._connection = await http_connect(
+                str(self.config.address), self.service.port
+            )
+            rtsp = RtspSession(self._connection)
+            stream_protocol = AirPlayStream.create_airplay_protocol(self.service, rtsp)
+            player = AirPlayPlayer(rtsp, stream_protocol)
             position = int(kwargs.get("position", 0))
             self._play_task = asyncio.ensure_future(player.play_url(url, position))
             return await self._play_task
         finally:
+            takeover_release()
             self._play_task = None
-            if connection:
-                connection.close()
+            if self._connection:
+                self._connection.close()
+                self._connection = None
             if server:
                 await server.close()
 
+    # Should be included in a "common" module with shared AirPlay code
+    @staticmethod
+    def create_airplay_protocol(
+        service: BaseService, rtsp: RtspSession
+    ) -> StreamProtocol:
+        """Create AirPlay protocol implementation based on supported version."""
+        if service.protocol != Protocol.AirPlay:
+            raise exceptions.InvalidConfigError("invalid service")
+
+        context = StreamContext()
+        context.credentials = parse_credentials(service.credentials)
+
+        if get_protocol_version(service) == AirPlayMajorVersion.AirPlayV1:
+            return airplayv1.AirPlayV1(context, rtsp)
+        return airplayv2.AirPlayV2(context, rtsp)
+
+
+class AirPlayRemoteControl(RemoteControl):
+    """Implementation of remote control functionality."""
+
+    def __init__(self, stream: AirPlayStream):
+        """Initialize a new AirPlayRemoteControl instance."""
+        self.stream = stream
+
+    async def stop(self) -> None:
+        """Press key stop."""
+        self.stream.stop()
+
 
 def airplay_service_handler(
     mdns_service: mdns.Service, response: mdns.Response
@@ -181,13 +237,14 @@ def setup(  # pylint: disable=too-many-locals
 ) -> Generator[SetupData, None, None]:
     """Set up a new AirPlay service."""
     # TODO: Split up in connect/protocol and Stream implementation
-    stream = AirPlayStream(core.config, core.service)
+    stream = AirPlayStream(core)
 
     features = parse_features(core.service.properties.get("features", "0x0"))
     credentials = extract_credentials(core.service)
 
     interfaces = {
         Features: AirPlayFeatures(features),
+        RemoteControl: AirPlayRemoteControl(stream),
         Stream: stream,
     }
 
@@ -207,7 +264,7 @@ def setup(  # pylint: disable=too-many-locals
         _close,
         _device_info,
         interfaces,
-        set([FeatureName.PlayUrl]),
+        set([FeatureName.PlayUrl, FeatureName.Stop]),
     )
 
     # AirPlay 2 does not mandate that a separate RAOP service exists for streaming
diff --git a/pyatv/protocols/airplay/player.py b/pyatv/protocols/airplay/player.py
index ee9f8d07..e2c073cb 100644
--- a/pyatv/protocols/airplay/player.py
+++ b/pyatv/protocols/airplay/player.py
@@ -1,67 +1,72 @@
 """Play media on a device by sending an URL."""
 
 import asyncio
+from contextlib import asynccontextmanager
 import logging
-import plistlib
-from uuid import uuid4
 
 from pyatv import exceptions
-from pyatv.support.http import HttpConnection, decode_bplist_from_body
+from pyatv.protocols.raop.protocols import StreamProtocol, TimingServer
+from pyatv.support.http import decode_bplist_from_body
+from pyatv.support.rtsp import RtspSession
 
 _LOGGER = logging.getLogger(__name__)
 
 PLAY_RETRIES = 3
 WAIT_RETRIES = 5
 HEADERS = {
-    "User-Agent": "MediaControl/1.0",
+    "User-Agent": "AirPlay/550.10",
     "Content-Type": "application/x-apple-binary-plist",
+    "X-Apple-ProtocolVersion": "1",
+    "X-Apple-Stream-ID": "1",
 }
 
 
+@asynccontextmanager
+async def timing_server(rtsp: RtspSession):
+    """Context manager setting up a timing server."""
+    local_addr = (rtsp.connection.local_ip, 0)
+    (_, server) = await asyncio.get_event_loop().create_datagram_endpoint(
+        TimingServer, local_addr=local_addr
+    )
+    yield server
+    server.close()
+
+
 # pylint: disable=too-few-public-methods
 class AirPlayPlayer:
     """This class helps with playing media from an URL."""
 
-    def __init__(self, http: HttpConnection) -> None:
+    def __init__(self, rtsp: RtspSession, stream_protocol: StreamProtocol) -> None:
         """Initialize a new AirPlay instance."""
-        self.http = http
+        self.rtsp = rtsp
+        self.stream_protocol = stream_protocol
 
     async def play_url(self, url: str, position: float = 0) -> None:
         """Play media from an URL on the device."""
-        body = {
-            "Content-Location": url,
-            "Start-Position": position,
-            "X-Apple-Session-ID": str(uuid4()),
-        }
-
         retry = 0
-        while retry < PLAY_RETRIES:
-            _LOGGER.debug("Starting to play %s", url)
-
-            # pylint: disable=no-member
-            resp = await self.http.post(
-                "/play",
-                headers=HEADERS,
-                body=plistlib.dumps(body, fmt=plistlib.FMT_BINARY),
-                allow_error=True,
-            )
-
-            # Sometimes AirPlay fails with "Internal Server Error", we
-            # apply a "lets try again"-approach to that
-            if resp.code == 500:
-                retry += 1
-                _LOGGER.debug(
-                    "Failed to stream %s, retry %d of %d", url, retry, PLAY_RETRIES
-                )
-                await asyncio.sleep(1.0)
-                continue
 
-            # TODO: Should be more fine-grained
-            if 400 <= resp.code < 600:
-                raise exceptions.AuthenticationError(f"status code: {resp.code}")
+        async with timing_server(self.rtsp) as server:
+            while retry < PLAY_RETRIES:
+                _LOGGER.debug("Starting to play %s", url)
+
+                resp = await self.stream_protocol.play_url(server.port, url, position)
+
+                # Sometimes AirPlay fails with "Internal Server Error", we
+                # apply a "lets try again"-approach to that
+                if resp.code == 500:
+                    retry += 1
+                    _LOGGER.debug(
+                        "Failed to stream %s, retry %d of %d", url, retry, PLAY_RETRIES
+                    )
+                    await asyncio.sleep(1.0)
+                    continue
 
-            await self._wait_for_media_to_end()
-            return
+                # TODO: Should be more fine-grained
+                if 400 <= resp.code < 600:
+                    raise exceptions.AuthenticationError(f"status code: {resp.code}")
+
+                await self._wait_for_media_to_end()
+                return
 
         raise exceptions.PlaybackError("Max retries exceeded")
 
@@ -72,7 +77,15 @@ class AirPlayPlayer:
         video_started: bool = False
 
         while True:
-            resp = await self.http.get("/playback-info")
+            # In some cases this call will fail if video was stopped by the sender,
+            # e.g. stopping video via remote control. For now, handle this gracefully
+            # by not spewing an exception.
+            try:
+                resp = await self.rtsp.connection.get("/playback-info")
+            except (RuntimeError, exceptions.ConnectionLostError):
+                _LOGGER.debug("Connection was lost, assuming video playback stopped")
+                break
+
             _LOGGER.debug("Playback-info: %s", resp)
 
             if resp.body:
@@ -81,6 +94,14 @@ class AirPlayPlayer:
                 parsed = {}
                 _LOGGER.debug("Got playback-info response without content")
 
+            # In case we got an error, abort with that here
+            if "error" in parsed:
+                code = parsed["error"].get("code", "unknown")
+                domain = parsed["error"].get("domain", "unknown domain")
+                raise exceptions.PlaybackError(
+                    f"got error {code} ({domain}) when playing video"
+                )
+
             # duration is only available if something is playing
             if "duration" in parsed:
                 video_started = True
diff --git a/pyatv/protocols/raop/protocols/__init__.py b/pyatv/protocols/raop/protocols/__init__.py
index 34346cac..27bb910a 100644
--- a/pyatv/protocols/raop/protocols/__init__.py
+++ b/pyatv/protocols/raop/protocols/__init__.py
@@ -1,13 +1,17 @@
 """Base classes used by streaming protocols."""
 from abc import ABC, abstractmethod
 import asyncio
+import logging
 from random import randrange
 from typing import Optional, Tuple
 
 from pyatv.auth.hap_pairing import NO_CREDENTIALS, HapCredentials
 from pyatv.protocols.raop import timing
+from pyatv.protocols.raop.packets import TimingPacket
 from pyatv.support.rtsp import FRAMES_PER_PACKET
 
+_LOGGER = logging.getLogger(__name__)
+
 
 class StreamContext:
     """Data used for one RAOP session."""
@@ -72,7 +76,7 @@ class StreamProtocol(ABC):
     """Base interface for a streaming protocol."""
 
     @abstractmethod
-    async def setup(self, timing_client_port: int, control_client_port: int) -> None:
+    async def setup(self, timing_server_port: int, control_client_port: int) -> None:
         """To setup connection prior to starting to stream."""
 
     @abstractmethod
@@ -88,3 +92,53 @@ class StreamProtocol(ABC):
         self, transport: asyncio.DatagramTransport, rtp_header: bytes, audio: bytes
     ) -> Tuple[int, bytes]:
         """Send audio packet to receiver."""
+
+    @abstractmethod
+    async def play_url(self, timing_server_port: int, url: str, position: float = 0.0):
+        """Play media from a URL."""
+
+
+class TimingServer(asyncio.Protocol):
+    """Basic timing server responding to timing requests."""
+
+    def __init__(self):
+        """Initialize a new TimingServer."""
+        self.transport = None
+
+    def close(self):
+        """Close timing server."""
+        if self.transport:
+            self.transport.close()
+            self.transport = None
+
+    @property
+    def port(self):
+        """Port this server listens to."""
+        return self.transport.get_extra_info("socket").getsockname()[1]
+
+    def connection_made(self, transport):
+        """Handle that connection succeeded."""
+        self.transport = transport
+
+    def datagram_received(self, data, addr):
+        """Handle incoming timing requests."""
+        req = TimingPacket.decode(data)
+        recvtime_sec, recvtime_frac = timing.ntp2parts(timing.ntp_now())
+        resp = TimingPacket.encode(
+            req.proto,
+            0x53 | 0x80,
+            7,
+            0,
+            req.sendtime_sec,
+            req.sendtime_frac,
+            recvtime_sec,
+            recvtime_frac,
+            recvtime_sec,
+            recvtime_frac,
+        )
+        self.transport.sendto(resp, addr)
+
+    @staticmethod
+    def error_received(exc) -> None:
+        """Handle a connection error."""
+        _LOGGER.error("Error received: %s", exc)
diff --git a/pyatv/protocols/raop/protocols/airplayv1.py b/pyatv/protocols/raop/protocols/airplayv1.py
index 983aebfb..d38d5500 100644
--- a/pyatv/protocols/raop/protocols/airplayv1.py
+++ b/pyatv/protocols/raop/protocols/airplayv1.py
@@ -1,7 +1,9 @@
 """Implementation of AirPlay v1 protocol logic."""
 import asyncio
 import logging
+import plistlib
 from typing import List, Mapping, Optional, Tuple
+from uuid import uuid4
 
 from pyatv import exceptions
 from pyatv.protocols.airplay.auth import pair_verify
@@ -12,6 +14,11 @@ _LOGGER = logging.getLogger(__name__)
 
 KEEP_ALIVE_INTERVAL = 25  # Seconds
 
+HEADERS = {
+    "User-Agent": "MediaControl/1.0",
+    "Content-Type": "application/x-apple-binary-plist",
+}
+
 
 def parse_transport(transport: str) -> Tuple[List[str], Mapping[str, str]]:
     """Parse Transport header in SETUP response."""
@@ -36,7 +43,7 @@ class AirPlayV1(StreamProtocol):
         self.rtsp = rtsp
         self._keep_alive_task: Optional[asyncio.Future] = None
 
-    async def setup(self, timing_client_port: int, control_client_port: int) -> None:
+    async def setup(self, timing_server_port: int, control_client_port: int) -> None:
         """To setup connection prior to starting to stream."""
         verifier = pair_verify(self.context.credentials, self.rtsp.connection)
         await verifier.verify_credentials()
@@ -53,7 +60,7 @@ class AirPlayV1(StreamProtocol):
                 "Transport": (
                     "RTP/AVP/UDP;unicast;interleaved=0-1;mode=record;"
                     f"control_port={control_client_port};"
-                    f"timing_port={timing_client_port}"
+                    f"timing_port={timing_server_port}"
                 )
             }
         )
@@ -107,3 +114,23 @@ class AirPlayV1(StreamProtocol):
         packet = rtp_header + audio
         transport.sendto(packet)
         return self.context.rtpseq, packet
+
+    async def play_url(self, timing_server_port: int, url: str, position: float = 0.0):
+        """Play media from a URL."""
+        verifier = pair_verify(self.context.credentials, self.rtsp.connection)
+        await verifier.verify_credentials()
+
+        body = {
+            "Content-Location": url,
+            "Start-Position": position,
+            "X-Apple-Session-ID": str(uuid4()),
+        }
+
+        return await self.rtsp.connection.post(
+            "/play",
+            headers=HEADERS,
+            body=plistlib.dumps(
+                body, fmt=plistlib.FMT_BINARY  # pylint: disable=no-member
+            ),
+            allow_error=True,
+        )
diff --git a/pyatv/protocols/raop/protocols/airplayv2.py b/pyatv/protocols/raop/protocols/airplayv2.py
index d9c129e3..bb8417c7 100644
--- a/pyatv/protocols/raop/protocols/airplayv2.py
+++ b/pyatv/protocols/raop/protocols/airplayv2.py
@@ -1,10 +1,13 @@
 """Implementation of AirPlay v2 protocol logic."""
 import asyncio
 import logging
+import plistlib
 from typing import Optional, Tuple
 from uuid import uuid4
 
+from pyatv import exceptions
 from pyatv.auth.hap_channel import setup_channel
+from pyatv.auth.hap_pairing import PairVerifyProcedure
 from pyatv.protocols.airplay.auth import verify_connection
 from pyatv.protocols.airplay.channels import EventChannel
 from pyatv.protocols.raop.protocols import StreamContext, StreamProtocol
@@ -20,6 +23,14 @@ EVENTS_READ_INFO = "Events-Read-Encryption-Key"
 
 FEEDBACK_INTERVAL = 2.0  # Seconds
 
+HEADERS = {
+    "User-Agent": "AirPlay/550.10",
+    "Content-Type": "application/x-apple-binary-plist",
+    "X-Apple-ProtocolVersion": "1",
+    "X-Apple-Session-ID": str(uuid4()).lower(),
+    "X-Apple-Stream-ID": "1",
+}
+
 
 class AirPlayV2(StreamProtocol):
     """Stream protocol used for AirPlay v1 support."""
@@ -30,12 +41,14 @@ class AirPlayV2(StreamProtocol):
         self.context = context
         self.rtsp = rtsp
         self.event_channel: Optional[asyncio.BaseTransport] = None
+        self._verifier: Optional[PairVerifyProcedure] = None
         self._cipher: Optional[Chacha20Cipher] = None
         self._feedback_task: Optional[asyncio.Task] = None
 
-    async def setup(self, timing_client_port: int, control_client_port: int) -> None:
-        """To setup connection prior to starting to stream."""
-        verifier = await verify_connection(
+        self.uuid = str(uuid4())
+
+    async def _setup_base(self, timing_server_port: int) -> None:
+        self._verifier = await verify_connection(
             self.context.credentials, self.rtsp.connection
         )
 
@@ -43,7 +56,7 @@ class AirPlayV2(StreamProtocol):
             body={
                 "deviceID": "AA:BB:CC:DD:EE:FF",
                 "sessionUUID": str(uuid4()).upper(),
-                "timingPort": timing_client_port,
+                "timingPort": timing_server_port,
                 "timingProtocol": "NTP",
                 "isMultiSelectAirPlay": True,
                 "groupContainsGroupLeader": False,
@@ -73,7 +86,7 @@ class AirPlayV2(StreamProtocol):
             try:
                 transport, _ = await setup_channel(
                     EventChannel,
-                    verifier,
+                    self._verifier,
                     self.rtsp.connection.remote_ip,
                     event_port,
                     EVENTS_SALT,
@@ -90,12 +103,22 @@ class AirPlayV2(StreamProtocol):
 
         self.event_channel = transport
 
+    async def setup(self, timing_server_port: int, control_client_port: int) -> None:
+        """To setup connection prior to starting to stream."""
+        await self._setup_base(timing_server_port)
+        await self.setup_audio_stream(control_client_port)
+
+    async def setup_audio_stream(self, control_client_port: int) -> None:
+        """Setup a new stream used for audio."""
+        if self._verifier is None:
+            raise exceptions.InvalidStateError("base stream not set up")
+
         # Ok, so this is not really correct. I believe the shared secret should be used
         # as base for the shared key, but it's hard to get hold of that here
         # (abstractions). It doesn't really matter what the key is (could be hardcoded)
         # as it's merely a security feature. For the sake of it, derive a key from event
         # parameters, it won't hurt (and the key will be different every time).
-        out_key, _ = verifier.encryption_keys(
+        out_key, _ = self._verifier.encryption_keys(
             EVENTS_SALT, EVENTS_WRITE_INFO, EVENTS_READ_INFO
         )
         shared_secret = out_key[0:32]
@@ -151,8 +174,9 @@ class AirPlayV2(StreamProtocol):
         while True:
             try:
                 await self.rtsp.feedback()
-            except Exception:
-                _LOGGER.exception("feedback failed")
+            except Exception as ex:
+                # Treat feedback as "best effort" and don't raise any errors
+                _LOGGER.debug("Feedback failed: %s", ex)
             await asyncio.sleep(FEEDBACK_INTERVAL)
 
     async def send_audio_packet(
@@ -171,3 +195,68 @@ class AirPlayV2(StreamProtocol):
         transport.sendto(packet)
 
         return self.context.rtpseq, packet
+
+    async def play_url(self, timing_server_port: int, url: str, position: float = 0.0):
+        """Play media from a URL."""
+        await self._setup_base(timing_server_port)
+        await self.start_feedback()
+        await self.rtsp.record()
+
+        # Most fields are not needed here, but keeping them for reference
+        body = {
+            "Content-Location": url,
+            "Start-Position-Seconds": position,
+            "uuid": self.uuid,
+            "streamType": 1,
+            "mediaType": "file",
+            "mightSupportStorePastisKeyRequests": True,
+            "playbackRestrictions": 0,
+            "secureConnectionMs": 22,
+            "volume": 1.0,
+            "infoMs": 122,
+            "connectMs": 18,
+            "authMs": 0,
+            "bonjourMs": 0,
+            "referenceRestrictions": 3,
+            "SenderMACAddress": "AA:BB:CC:DD:EE:FF",
+            "model": "iPhone14,3",
+            "postAuthMs": 0,
+            "clientBundleID": "dev.pyatv.GPU",
+            "clientProcName": "dev.pyatv.GPU",
+            "osBuildVersion": "20G1116",
+            "rate": 1.0,
+        }
+
+        # Actually start the stream
+        resp = await self.rtsp.connection.post(
+            "/play",
+            headers=HEADERS,
+            body=plistlib.dumps(
+                body, fmt=plistlib.FMT_BINARY  # pylint: disable=no-member
+            ),
+            allow_error=True,
+        )
+
+        # Various commands, most of which are probably not needed for pyatv. Doing them
+        # anyways, just to be sure things work. Most important command is "/rate" as
+        # that sets playback rate to 100% (will start paused otherwise).
+        # TODO: Maybe check some return values?
+        await self.rtsp.exchange(
+            "PUT", uri="/setProperty?isInterestedInDateRange", body={"value": True}
+        )
+        await self.rtsp.exchange(
+            "PUT", uri="/setProperty?actionAtItemEnd", body={"value": 0}
+        )
+        await self.rtsp.exchange("POST", uri="/rate?value=1.000000")
+        await self.rtsp.exchange(
+            "PUT",
+            uri="/setProperty?forwardEndTime",
+            body={"value": {"flags": 0, "value": 0, "epoch": 0, "timescale": 0}},
+        )
+        await self.rtsp.exchange(
+            "PUT",
+            uri="/setProperty?reverseEndTime",
+            body={"value": {"flags": 0, "value": 0, "epoch": 0, "timescale": 0}},
+        )
+
+        return resp
diff --git a/pyatv/protocols/raop/stream_client.py b/pyatv/protocols/raop/stream_client.py
index dbd5bd0d..12b66ef2 100644
--- a/pyatv/protocols/raop/stream_client.py
+++ b/pyatv/protocols/raop/stream_client.py
@@ -7,7 +7,7 @@ AirPlay v1 and/or v2. This is mainly for code re-use purposes.
 from abc import ABC, abstractmethod
 import asyncio
 import logging
-from time import monotonic, perf_counter
+from time import monotonic, monotonic_ns
 from typing import Any, Dict, Mapping, NamedTuple, Optional, Tuple, cast
 import weakref
 
@@ -20,7 +20,6 @@ from pyatv.protocols.raop.packets import (
     AudioPacketHeader,
     RetransmitReqeust,
     SyncPacket,
-    TimingPacket,
 )
 from pyatv.protocols.raop.parsers import (
     EncryptionType,
@@ -29,7 +28,7 @@ from pyatv.protocols.raop.parsers import (
     get_encryption_types,
     get_metadata_types,
 )
-from pyatv.protocols.raop.protocols import StreamContext, StreamProtocol
+from pyatv.protocols.raop.protocols import StreamContext, StreamProtocol, TimingServer
 from pyatv.support import log_binary
 from pyatv.support.metadata import EMPTY_METADATA, MediaMetadata
 from pyatv.support.rtsp import FRAMES_PER_PACKET, RtspSession
@@ -181,52 +180,6 @@ class ControlClient(asyncio.Protocol):
             self.task = None
 
 
-class TimingClient(asyncio.Protocol):
-    """Basic timing client responding to timing requests."""
-
-    def __init__(self):
-        """Initialize a new TimingClient."""
-        self.transport = None
-
-    def close(self):
-        """Close timing client."""
-        if self.transport:
-            self.transport.close()
-            self.transport = None
-
-    @property
-    def port(self):
-        """Port this client listens to."""
-        return self.transport.get_extra_info("socket").getsockname()[1]
-
-    def connection_made(self, transport):
-        """Handle that connection succeeded."""
-        self.transport = transport
-
-    def datagram_received(self, data, addr):
-        """Handle incoming timing requests."""
-        req = TimingPacket.decode(data)
-        recvtime_sec, recvtime_frac = timing.ntp2parts(timing.ntp_now())
-        resp = TimingPacket.encode(
-            req.proto,
-            0x53 | 0x80,
-            7,
-            0,
-            req.sendtime_sec,
-            req.sendtime_frac,
-            recvtime_sec,
-            recvtime_frac,
-            recvtime_sec,
-            recvtime_frac,
-        )
-        self.transport.sendto(resp, addr)
-
-    @staticmethod
-    def error_received(exc) -> None:
-        """Handle a connection error."""
-        _LOGGER.error("Error received: %s", exc)
-
-
 class AudioProtocol(asyncio.Protocol):
     """Minimal protocol to send audio packets."""
 
@@ -280,7 +233,7 @@ class StreamClient:
         self.rtsp: RtspSession = rtsp
         self.context: StreamContext = context
         self.control_client: Optional[ControlClient] = None
-        self.timing_client: Optional[TimingClient] = None
+        self.timing_server: Optional[TimingServer] = None
         self._packet_backlog: PacketFifo = PacketFifo(PACKET_BACKLOG_SIZE)
         self._encryption_types: EncryptionType = EncryptionType.Unknown
         self._metadata_types: MetadataType = MetadataType.NotSupported
@@ -324,8 +277,8 @@ class StreamClient:
         self._protocol.teardown()
         if self.control_client:
             self.control_client.close()
-        if self.timing_client:
-            self.timing_client.close()
+        if self.timing_server:
+            self.timing_server.close()
 
     async def initialize(self, properties: Mapping[str, str]):
         """Initialize the session."""
@@ -351,17 +304,17 @@ class StreamClient:
             lambda: ControlClient(self.context, self._packet_backlog),
             local_addr=local_addr,
         )
-        (_, timing_client) = await self.loop.create_datagram_endpoint(
-            TimingClient, local_addr=local_addr
+        (_, timing_server) = await self.loop.create_datagram_endpoint(
+            TimingServer, local_addr=local_addr
         )
 
         self.control_client = cast(ControlClient, control_client)
-        self.timing_client = cast(TimingClient, timing_client)
+        self.timing_server = cast(TimingServer, timing_server)
 
         _LOGGER.debug(
             "Local ports: control=%d, timing=%d",
             self.control_client.port,
-            self.timing_client.port,
+            self.timing_server.port,
         )
 
         self._info.update(await self.rtsp.info())
@@ -372,7 +325,7 @@ class StreamClient:
             await self.rtsp.auth_setup()
 
         # Set up the streaming session
-        await self._protocol.setup(self.timing_client.port, self.control_client.port)
+        await self._protocol.setup(self.timing_server.port, self.control_client.port)
 
     def _update_output_properties(self, properties: Mapping[str, str]) -> None:
         (
@@ -417,7 +370,7 @@ class StreamClient:
         volume: Optional[float] = None
     ):
         """Send an audio stream to the device."""
-        if self.control_client is None or self.timing_client is None:
+        if self.control_client is None or self.timing_server is None:
             raise RuntimeError("not initialized")
 
         self.context.reset()
@@ -591,7 +544,7 @@ class StreamClient:
 
         _LOGGER.debug(
             "Audio finished sending in %fs",
-            (timing.perf_counter_ns() - stats.start_time_ns) / 10**9,
+            (monotonic_ns() - stats.start_time_ns) / 10**9,
         )
 
     async def _send_packet(
@@ -662,18 +615,15 @@ class Statistics:
     def __init__(self, sample_rate: int):
         """Initialize a new Statistics instance."""
         self.sample_rate: int = sample_rate
-        self.start_time_ns: int = timing.perf_counter_ns()
-        self.interval_time: float = perf_counter()
+        self.start_time_ns: int = monotonic_ns()
+        self.interval_time: float = monotonic()
         self.total_frames: int = 0
         self.interval_frames: int = 0
 
     @property
     def expected_frame_count(self) -> int:
         """Number of frames expected to be sent at current time."""
-        return int(
-            (timing.perf_counter_ns() - self.start_time_ns)
-            / (10**9 / self.sample_rate)
-        )
+        return int((monotonic_ns() - self.start_time_ns) / (10**9 / self.sample_rate))
 
     @property
     def frames_behind(self) -> int:
@@ -696,7 +646,7 @@ class Statistics:
 
     def new_interval(self) -> Tuple[float, int]:
         """Start measuring a new time interval."""
-        end_time = perf_counter()
+        end_time = monotonic()
         diff = end_time - self.interval_time
         self.interval_time = end_time
 
diff --git a/pyatv/protocols/raop/timing.py b/pyatv/protocols/raop/timing.py
index 469246df..97fe96f8 100644
--- a/pyatv/protocols/raop/timing.py
+++ b/pyatv/protocols/raop/timing.py
@@ -4,19 +4,13 @@ The timing routines in this module is based on the excellent work of RAOP-Player
 https://github.com/philippe44/RAOP-Player
 """
 
-from time import perf_counter
+from time import time_ns
 from typing import Tuple
 
 
-# TODO: Replace with time.perf_counter_ns when python 3.6 is dropped
-def perf_counter_ns():
-    """Return a perf_counter time in nanoseconds."""
-    return int(perf_counter() * 10**9)
-
-
 def ntp_now() -> int:
     """Return current time in NTP format."""
-    now_us = perf_counter_ns() / 1000
+    now_us = time_ns() / 1000
     seconds = int(now_us / 1000000)
     frac = int(now_us - seconds * 1000000)
     return (seconds + 0x83AA7E80) << 32 | (int((frac << 32) / 1000000))
diff --git a/pyatv/scripts/atvproxy.py b/pyatv/scripts/atvproxy.py
index 98afe387..fe343f21 100755
--- a/pyatv/scripts/atvproxy.py
+++ b/pyatv/scripts/atvproxy.py
@@ -21,7 +21,6 @@ from pyatv.auth.hap_srp import SRPAuthHandler, hkdf_expand
 from pyatv.auth.server_auth import SERVER_IDENTIFIER
 from pyatv.const import Protocol
 from pyatv.core import MutableService, mdns
-from pyatv.protocols.airplay import extract_credentials, verify_connection
 from pyatv.protocols.airplay.ap2_session import (
     DATASTREAM_INPUT_INFO,
     DATASTREAM_OUTPUT_INFO,
@@ -30,6 +29,7 @@ from pyatv.protocols.airplay.ap2_session import (
     EVENTS_SALT,
     EVENTS_WRITE_INFO,
 )
+from pyatv.protocols.airplay.auth import extract_credentials, verify_connection
 from pyatv.protocols.airplay.channels import (
     BaseDataStreamChannel,
     BaseEventChannel,
@@ -966,8 +966,8 @@ class AirPlayAppleTVProxy(BasicHttpServer, BaseAirPlayServerAuth):
         self.loop = loop
         self.address = address
         self.port = port
-        self.target_identifier = properties["psi"]
-        self.target_group = properties["gid"]
+        self.target_identifier = properties.get("psi", "")
+        self.target_group = properties.get("gid", "")
         self.credentials: Optional[str] = credentials
         self.client_hap_session: Optional[HAPSession] = None
         self.client_encryption = False
diff --git a/pyatv/support/http.py b/pyatv/support/http.py
index 8e9a3977..f61e7abb 100644
--- a/pyatv/support/http.py
+++ b/pyatv/support/http.py
@@ -2,10 +2,10 @@
 from abc import ABC, abstractmethod
 import asyncio
 from collections import deque
+from dataclasses import dataclass
 import logging
 import pathlib
 import plistlib
-from queue import Queue
 import re
 from typing import (
     Any,
@@ -326,6 +326,14 @@ class HttpSession:
 class HttpConnection(asyncio.Protocol):
     """Representation of a HTTP connection."""
 
+    @dataclass
+    class PendingRequest:
+        """Container class for a pending request."""
+
+        event: asyncio.Event
+        response: Optional[HttpResponse] = None
+        connection_closed: bool = False
+
     def __init__(
         self,
         receive_processor: Optional[Callable[[bytes], bytes]] = None,
@@ -342,7 +350,6 @@ class HttpConnection(asyncio.Protocol):
         self._local_ip: Optional[str] = None
         self._remote_ip: Optional[str] = None
         self._requests: deque = deque()
-        self._responses: Queue = Queue()
         self._buffer = b""
 
     @property
@@ -365,6 +372,7 @@ class HttpConnection(asyncio.Protocol):
             transport = self.transport
             self.transport = None
             transport.close()
+        self._requests.clear()
 
     def connection_made(self, transport: asyncio.BaseTransport) -> None:
         """Handle that a connection has been made."""
@@ -388,9 +396,10 @@ class HttpConnection(asyncio.Protocol):
                 break
 
             # Dispatch message to first receiver
-            self._responses.put(parsed)
             if self._requests:
-                self._requests.pop().set()
+                pending_request = self._requests.pop()
+                pending_request.response = parsed
+                pending_request.event.set()
             else:
                 _LOGGER.warning("Got response without having a request: %s", parsed)
 
@@ -402,6 +411,11 @@ class HttpConnection(asyncio.Protocol):
     def connection_lost(self, exc) -> None:
         """Handle that connection was lost."""
         _LOGGER.debug("Connection closed")
+        for pending_request in self._requests:
+            pending_request.connection_closed = True
+            pending_request.event.set()
+        self._requests.clear()
+        self.transport = None
 
     async def get(self, path: str, allow_error: bool = False) -> HttpResponse:
         """Make a GET request and return response."""
@@ -437,23 +451,31 @@ class HttpConnection(asyncio.Protocol):
         )
 
         _LOGGER.debug("Sending %s message: %s", protocol, output)
-        if not self.transport:
+        if self.transport is None:
             raise RuntimeError("not connected to remote")
 
         self.transport.write(self.send_processor(output))
 
-        event = asyncio.Event()
-        self._requests.appendleft(event)
+        pending_request = HttpConnection.PendingRequest(event=asyncio.Event())
+        self._requests.appendleft(pending_request)
         try:
             async with async_timeout.timeout(timeout):
-                await event.wait()
-            response = cast(HttpResponse, self._responses.get())
+                await pending_request.event.wait()
+
+            if pending_request.connection_closed:
+                raise exceptions.ConnectionLostError("connection was lost")
+
+            response = pending_request.response
+
+            # This should never happen, but raise an exception for the sake of it
+            if response is None:
+                raise RuntimeError("did not get a response")
         except asyncio.TimeoutError as ex:
             raise TimeoutError(f"no response to {method} {uri} ({protocol})") from ex
         finally:
             # If request failed and event is still in request queue, remove it
-            if self._requests and self._requests[-1] == event:
-                self._requests.pop()
+            if pending_request in self._requests:
+                self._requests.remove(pending_request)
 
         _LOGGER.debug("Got %s response: %s:", response.protocol, response)
 
diff --git a/pyatv/support/rtsp.py b/pyatv/support/rtsp.py
index 5be1b188..a8622ec4 100644
--- a/pyatv/support/rtsp.py
+++ b/pyatv/support/rtsp.py
@@ -19,7 +19,7 @@ from pyatv.support.metadata import MediaMetadata
 _LOGGER = logging.getLogger(__name__)
 
 FRAMES_PER_PACKET = 352
-USER_AGENT = "AirPlay/540.31"
+USER_AGENT = "AirPlay/550.10"
 HTTP_PROTOCOL = "HTTP/1.1"
 
 ANNOUNCE_PAYLOAD = (


Test patch:
diff --git a/tests/protocols/airplay/test_airplay_player.py b/tests/protocols/airplay/test_airplay_player.py
index 09f6dc29..5dd13f01 100644
--- a/tests/protocols/airplay/test_airplay_player.py
+++ b/tests/protocols/airplay/test_airplay_player.py
@@ -4,7 +4,10 @@ import math
 import pytest
 
 from pyatv import exceptions
+from pyatv.auth.hap_pairing import NO_CREDENTIALS
 from pyatv.protocols.airplay import player
+from pyatv.protocols.raop.protocols import StreamContext, airplayv1
+from pyatv.support.rtsp import RtspSession
 
 from tests.utils import total_sleep_time
 
@@ -16,7 +19,11 @@ pytestmark = pytest.mark.asyncio
 
 @pytest.fixture(name="airplay_player")
 def airplay_player_fixture(client_connection):
-    yield player.AirPlayPlayer(client_connection)
+    rtsp = RtspSession(client_connection)
+    context = StreamContext()
+    context.credentials = NO_CREDENTIALS
+    stream_protocol = airplayv1.AirPlayV1(context, rtsp)
+    yield player.AirPlayPlayer(rtsp, stream_protocol)
 
 
 async def test_play_video(airplay_usecase, airplay_player, airplay_state):
diff --git a/tests/support/test_http.py b/tests/support/test_http.py
index 6feb6869..e38e166f 100644
--- a/tests/support/test_http.py
+++ b/tests/support/test_http.py
@@ -1,12 +1,14 @@
 """Unit tests for pyatv.support.http."""
 import asyncio
 import inspect
+import logging
 from typing import Optional, Tuple
 from unittest.mock import MagicMock, patch
 
 from deepdiff import DeepDiff
 import pytest
 
+from pyatv import exceptions
 from pyatv.support.http import (
     SERVER_NAME,
     USER_AGENT,
@@ -22,6 +24,9 @@ from pyatv.support.http import (
     parse_request,
     parse_response,
 )
+from pyatv.support.net import unused_port
+
+_LOGGER = logging.getLogger(__name__)
 
 # HTTP MESSAGE PARSING
 #
@@ -461,3 +466,30 @@ async def test_connection_receive_processor():
     assert resp.message == "something else"
 
     server.close()
+
+
+@pytest.mark.asyncio
+async def test_connection_abort_timeout_if_connection_closed():
+    # Set up a TCP server that will just consume the request and disconnect
+    # so that the connection gets closed
+    async def _dummy_server(reader, writer):
+        _LOGGER.debug("Connection established")
+        await reader.read(1)
+
+        _LOGGER.debug("Data read, closing connection")
+        writer.close()
+
+    port = unused_port()
+    await asyncio.start_server(_dummy_server, "127.0.0.1", port)
+
+    connection = await http_connect("127.0.0.1", port)
+
+    # Spawn three requests. Connection will be closed when processing the first one
+    # but all three shall be aborted with an exception.
+    tasks = [
+        asyncio.create_task(connection.send_and_receive("GET", "/test"))
+        for _ in range(3)
+    ]
+    for task in tasks:
+        with pytest.raises(exceptions.ConnectionLostError):
+            await task