Repository: postlund/pyatv (python)

Issue:
push_updates with volume modifications (from lib or from other means) for homepods
### What feature would you like?

receiving {"volume":20.5} in atvscript push_updates when the volume has been modified from any means (lib, +/- buttons on top, airplay, ...) on HomePods at least (i know volume management on apple tv is strange).

could be a part of the playing json or separated like power_state, as you wish

### Describe the solution you'd like

each time the volume is changed, a line should appear in push_updates

### Any other information to share?

no

Pull request description:
Add listener interface to Audio for volume updates
Adds a listener interface to `Audio`, to enable push updates for volume changes as recommended in https://github.com/postlund/pyatv/issues/1798#issuecomment-1188276550

Resolves #1798

Patch:
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index ff6f4dc8..17f302f6 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -73,6 +73,12 @@ link_group: api
 </ul>
 </li>
 <li>
+<h4><code><a title="pyatv.interface.AudioListener" href="#pyatv.interface.AudioListener">AudioListener</a></code></h4>
+<ul class="">
+<li><code><a title="pyatv.interface.AudioListener.volume_update" href="#pyatv.interface.AudioListener.volume_update">volume_update</a></code></li>
+</ul>
+</li>
+<li>
 <h4><code><a title="pyatv.interface.BaseConfig" href="#pyatv.interface.BaseConfig">BaseConfig</a></code></h4>
 <ul class="two-column">
 <li><code><a title="pyatv.interface.BaseConfig.add_service" href="#pyatv.interface.BaseConfig.add_service">add_service</a></code></li>
@@ -273,7 +279,7 @@ link_group: api
 <p>Public interface exposed by library.</p>
 <p>This module contains all the interfaces that represents a generic Apple TV device and
 all its features.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1-L1287" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1-L1299" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -326,7 +332,7 @@ all its features.</p>
 <section class="desc"><p>Base class representing an Apple TV.</p>
 <p>Listener interface: <code>pyatv.interfaces.DeviceListener</code></p>
 <p>Initialize a new StateProducer instance.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1217-L1287" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1229-L1299" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>abc.ABC</li>
@@ -342,57 +348,57 @@ all its features.</p>
 <dt id="pyatv.interface.AppleTV.apps"><code class="name">var <span class="ident">apps</span> -> <a title="pyatv.interface.Apps" href="#pyatv.interface.Apps">Apps</a></code></dt>
 <dd>
 <section class="desc"><p>Return apps interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1274-L1277" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1286-L1289" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.audio"><code class="name">var <span class="ident">audio</span> -> <a title="pyatv.interface.Audio" href="#pyatv.interface.Audio">Audio</a></code></dt>
 <dd>
 <section class="desc"><p>Return audio interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1284-L1287" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1296-L1299" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.device_info"><code class="name">var <span class="ident">device_info</span> -> <a title="pyatv.interface.DeviceInfo" href="#pyatv.interface.DeviceInfo">DeviceInfo</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for device information.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1234-L1237" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1246-L1249" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.features"><code class="name">var <span class="ident">features</span> -> <a title="pyatv.interface.Features" href="#pyatv.interface.Features">Features</a></code></dt>
 <dd>
 <section class="desc"><p>Return features interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1269-L1272" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1281-L1284" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.metadata"><code class="name">var <span class="ident">metadata</span> -> <a title="pyatv.interface.Metadata" href="#pyatv.interface.Metadata">Metadata</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for retrieving metadata from the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1249-L1252" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1261-L1264" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.power"><code class="name">var <span class="ident">power</span> -> <a title="pyatv.interface.Power" href="#pyatv.interface.Power">Power</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for power management.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1264-L1267" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1276-L1279" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.push_updater"><code class="name">var <span class="ident">push_updater</span> -> <a title="pyatv.interface.PushUpdater" href="#pyatv.interface.PushUpdater">PushUpdater</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for handling push update from the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1254-L1257" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1266-L1269" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.remote_control"><code class="name">var <span class="ident">remote_control</span> -> <a title="pyatv.interface.RemoteControl" href="#pyatv.interface.RemoteControl">RemoteControl</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for controlling the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1244-L1247" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1256-L1259" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.service"><code class="name">var <span class="ident">service</span> -> <a title="pyatv.interface.BaseService" href="#pyatv.interface.BaseService">BaseService</a></code></dt>
 <dd>
 <section class="desc"><p>Return service used to connect to the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1239-L1242" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1251-L1254" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.stream"><code class="name">var <span class="ident">stream</span> -> <a title="pyatv.interface.Stream" href="#pyatv.interface.Stream">Stream</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for streaming media.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1259-L1262" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1271-L1274" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.user_accounts"><code class="name">var <span class="ident">user_accounts</span> -> <a title="pyatv.interface.UserAccounts" href="#pyatv.interface.UserAccounts">UserAccounts</a></code></dt>
 <dd>
 <section class="desc"><p>Return user accounts interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1279-L1282" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1291-L1294" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Methods</h3>
@@ -404,7 +410,7 @@ all its features.</p>
 </dt>
 <dd>
 <section class="desc"><p>Close connection and release allocated resources.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1230-L1232" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1242-L1244" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.connect">
 <code class="name flex">
@@ -414,7 +420,7 @@ all its features.</p>
 <dd>
 <section class="desc"><p>Initiate connection to device.</p>
 <p>No need to call it yourself, it's done automatically.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1223-L1228" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1235-L1240" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
@@ -492,11 +498,20 @@ all its features.</p>
 </dd>
 <dt id="pyatv.interface.Audio"><code class="flex name class">
 <span>class <span class="ident">Audio</span></span>
+<span>(</span><span>max_calls: int = 0)</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Base class for audio functionality.</p>
-<p>Volume level is managed in percent where 0 is muted and 100 is max volume.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1039-L1084" class="git-link">Browse git</a></div>
+<p>Volume level is managed in percent where 0 is muted and 100 is max volume.</p>
+<p>Listener interface: <code>pyatv.interfaces.AudioListener</code></p>
+<p>Initialize a new StateProducer instance.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1048-L1096" class="git-link">Browse git</a></div>
+<h3>Ancestors</h3>
+<ul class="hlist">
+<li>abc.ABC</li>
+<li>pyatv.support.state_producer.StateProducer</li>
+<li>typing.Generic</li>
+</ul>
 <h3>Subclasses</h3>
 <ul class="hlist">
 <li>pyatv.core.facade.FacadeAudio</li>
@@ -515,7 +530,7 @@ all its features.</p>
 </div>
 <section class="desc"><p>Return current volume level.</p>
 <p>Range is in percent, i.e. [0.0-100.0].</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1045-L1052" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1057-L1064" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Methods</h3>
@@ -532,7 +547,7 @@ all its features.</p>
 </div>
 <section class="desc"><p>Change current volume level.</p>
 <p>Range is in percent, i.e. [0.0-100.0].</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1054-L1060" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1066-L1072" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.Audio.volume_down">
 <code class="name flex">
@@ -549,7 +564,7 @@ all its features.</p>
 range. It is not necessarily linear.</p>
 <p>Call will block until volume change has been acknowledged by the device (when
 possible and supported).</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1074-L1084" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1086-L1096" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.Audio.volume_up">
 <code class="name flex">
@@ -566,7 +581,34 @@ possible and supported).</p></section>
 range. It is not necessarily linear.</p>
 <p>Call will block until volume change has been acknowledged by the device (when
 possible and supported).</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1062-L1072" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1074-L1084" class="git-link">Browse git</a></div>
+</dd>
+</dl>
+</dd>
+<dt id="pyatv.interface.AudioListener"><code class="flex name class">
+<span>class <span class="ident">AudioListener</span></span>
+</code></dt>
+<dd>
+<section class="desc"><p>Listener interface for audio updates.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1039-L1045" class="git-link">Browse git</a></div>
+<h3>Ancestors</h3>
+<ul class="hlist">
+<li>abc.ABC</li>
+</ul>
+<h3>Subclasses</h3>
+<ul class="hlist">
+<li>pyatv.scripts.atvscript.AudioPrinter</li>
+</ul>
+<h3>Methods</h3>
+<dl>
+<dt id="pyatv.interface.AudioListener.volume_update">
+<code class="name flex">
+<span>def <span class="ident">volume_update</span></span>(<span>self, old_level: float, new_level: float)</span>
+</code>
+</dt>
+<dd>
+<section class="desc"><p>Device volume was updated.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1042-L1045" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
@@ -580,7 +622,7 @@ possible and supported).</p></section>
 several services depending on the protocols it supports, e.g. DMAP or
 AirPlay.</p>
 <p>Initialize a new BaseConfig instance.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1087-L1214" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1099-L1226" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>abc.ABC</li>
@@ -594,47 +636,47 @@ AirPlay.</p>
 <dt id="pyatv.interface.BaseConfig.address"><code class="name">var <span class="ident">address</span> -> ipaddress.IPv4Address</code></dt>
 <dd>
 <section class="desc"><p>IP address of device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1099-L1102" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1111-L1114" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.all_identifiers"><code class="name">var <span class="ident">all_identifiers</span> -> List[str]</code></dt>
 <dd>
 <section class="desc"><p>Return all unique identifiers for this device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1161-L1164" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1173-L1176" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.deep_sleep"><code class="name">var <span class="ident">deep_sleep</span> -> bool</code></dt>
 <dd>
 <section class="desc"><p>If device is in deep sleep.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1109-L1112" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1121-L1124" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.device_info"><code class="name">var <span class="ident">device_info</span> -> <a title="pyatv.interface.DeviceInfo" href="#pyatv.interface.DeviceInfo">DeviceInfo</a></code></dt>
 <dd>
 <section class="desc"><p>Return general device information.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1119-L1122" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1131-L1134" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.identifier"><code class="name">var <span class="ident">identifier</span> -> Optional[str]</code></dt>
 <dd>
 <section class="desc"><p>Return the main identifier associated with this device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1152-L1159" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1164-L1171" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.name"><code class="name">var <span class="ident">name</span> -> str</code></dt>
 <dd>
 <section class="desc"><p>Name of device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1104-L1107" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1116-L1119" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.properties"><code class="name">var <span class="ident">properties</span> -> Mapping[str, Mapping[str, str]]</code></dt>
 <dd>
 <section class="desc"><p>Return Zeroconf properties.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1139-L1142" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1151-L1154" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.ready"><code class="name">var <span class="ident">ready</span> -> bool</code></dt>
 <dd>
 <section class="desc"><p>Return if configuration is ready, (at least one service with identifier).</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1144-L1150" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1156-L1162" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.services"><code class="name">var <span class="ident">services</span> -> List[<a title="pyatv.interface.BaseService" href="#pyatv.interface.BaseService">BaseService</a>]</code></dt>
 <dd>
 <section class="desc"><p>Return all supported services.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1114-L1117" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1126-L1129" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Methods</h3>
@@ -647,7 +689,7 @@ AirPlay.</p>
 <dd>
 <section class="desc"><p>Add a new service.</p>
 <p>If the service already exists, it will be merged.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1124-L1129" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1136-L1141" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.get_service">
 <code class="name flex">
@@ -658,7 +700,7 @@ AirPlay.</p>
 <section class="desc"><p>Look up a service based on protocol.</p>
 <p>If a service with the specified protocol is not available, None is
 returned.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1131-L1137" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1143-L1149" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.main_service">
 <code class="name flex">
@@ -667,7 +709,7 @@ returned.</p></section>
 </dt>
 <dd>
 <section class="desc"><p>Return suggested service used to establish connection.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1166-L1179" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1178-L1191" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.set_credentials">
 <code class="name flex">
@@ -676,7 +718,7 @@ returned.</p></section>
 </dt>
 <dd>
 <section class="desc"><p>Set credentials for a protocol if it exists.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1181-L1187" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1193-L1199" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
diff --git a/docs/development/listeners.md b/docs/development/listeners.md
index adfa2c80..9e8266e4 100644
--- a/docs/development/listeners.md
+++ b/docs/development/listeners.md
@@ -89,3 +89,25 @@ atv.power.listener = listener
 
 A small note here about this API. Power state updates are working for `MRP` devices
 only.
+
+## Audio Updates
+
+It is possible to get callbacks whenever the volume level of a device is changed.
+The API is defined by the
+{% include api i="interface.AudioListener" %} interface and works similarly to how push updates works.
+
+Here is a simple example:
+
+```python
+class MyAudioListener(interface.AudioListener):
+
+    def volume_update(self, old_level, new_level):
+        print('Volume level changed from {0:f} to {1:f}'.format(old_level, new_level))
+
+
+listener = MyAudioListener()
+atv.audio.listener = listener
+```
+
+Live volume level updates are only sent over the `MRP` protocol. If an Apple TV is connected to
+speakers in a way that doesn't support volume levels, it will not send these updates.
diff --git a/docs/documentation/atvscript.md b/docs/documentation/atvscript.md
index dc4bef43..111205ba 100644
--- a/docs/documentation/atvscript.md
+++ b/docs/documentation/atvscript.md
@@ -203,15 +203,17 @@ $ atvscript -s 10.0.10.81 menu
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "command": "menu"}
 ```
 
-## Push and Power Updates
+## Push, Power and Audio Updates
 
-Push and power updates are printed to the terminal as they happen:
+Push, power and audio updates are printed to the terminal as they happen:
 
 ```shell
 $ atvscript -s 10.0.10.81 push_updates
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "power_state": "off"}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "hash": "azyFEzFpSNOSGq9ZvcaX4A\u2206DcpumkUoRty+R098MQeIKA", "media_type": "music", "device_state": "paused", "title": "Ordinary World (Live)", "artist": "Duran Duran", "album": "From Mediterranea With Love - EP", "genre": "Rock", "total_time": 395, "position": 1, "shuffle": "off", "repeat": "off", "app": "Musik", "app_id": "com.apple.TVMusic"}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "power_state": "on"}
+{"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "volume": 20.0}
+{"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "volume": 15.0}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "power_state": "off"}
 
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "push_updates": "finished"}
diff --git a/pyatv/core/facade.py b/pyatv/core/facade.py
index c32c3347..bb210b2e 100644
--- a/pyatv/core/facade.py
+++ b/pyatv/core/facade.py
@@ -431,9 +431,24 @@ class FacadeUserAccounts(Relayer, interface.UserAccounts):
 class FacadeAudio(Relayer, interface.Audio):
     """Facade implementation for audio functionality."""
 
-    def __init__(self):
+    def __init__(self, core_dispatcher: CoreStateDispatcher):
         """Initialize a new FacadeAudio instance."""
-        super().__init__(interface.Audio, DEFAULT_PRIORITIES)
+        Relayer.__init__(self, interface.Audio, DEFAULT_PRIORITIES)
+        interface.Audio.__init__(self)
+        self._volume = 0.0
+        core_dispatcher.listen_to(UpdatedState.Volume, self._volume_changed)
+
+    def _volume_changed(self, message: StateMessage) -> None:
+        """State of something changed."""
+        volume = cast(float, message.value)
+
+        # Compute new state so we can know if we should update or not
+        old_level = self._volume
+        new_level = self._volume = volume
+
+        # Do not update state in case it didn't change
+        if new_level != old_level:
+            self.listener.volume_update(old_level, new_level)
 
     @shield.guard
     async def volume_up(self) -> None:
@@ -541,7 +556,7 @@ class FacadeAppleTV(interface.AppleTV):
             interface.Stream: FacadeStream(self._features),
             interface.Apps: FacadeApps(),
             interface.UserAccounts: FacadeUserAccounts(),
-            interface.Audio: FacadeAudio(),
+            interface.Audio: FacadeAudio(core_dispatcher),
         }
         self._shield_everything()
 
diff --git a/pyatv/interface.py b/pyatv/interface.py
index 74c99371..9db342d8 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -1036,10 +1036,22 @@ class Features:
         return True
 
 
-class Audio:
+class AudioListener(ABC):  # pylint: disable=too-few-public-methods
+    """Listener interface for audio updates."""
+
+    @abstractmethod
+    def volume_update(self, old_level: float, new_level: float):
+        """Device volume was updated."""
+        raise NotImplementedError()
+
+
+class Audio(ABC, StateProducer):
     """Base class for audio functionality.
 
     Volume level is managed in percent where 0 is muted and 100 is max volume.
+
+
+    Listener interface: `pyatv.interfaces.AudioListener`
     """
 
     @property  # type: ignore
diff --git a/pyatv/scripts/atvscript.py b/pyatv/scripts/atvscript.py
index eb7de0ff..76501711 100644
--- a/pyatv/scripts/atvscript.py
+++ b/pyatv/scripts/atvscript.py
@@ -16,6 +16,7 @@ from pyatv.const import FeatureState, Protocol
 from pyatv.interface import (
     App,
     AppleTV,
+    AudioListener,
     DeviceListener,
     Playing,
     PowerListener,
@@ -77,6 +78,21 @@ class PowerPrinter(PowerListener):
         )
 
 
+class AudioPrinter(AudioListener):
+    """Listen for audio updates and print changes."""
+
+    def __init__(self, formatter):
+        """Initialize a new AudioPrinter."""
+        self.formatter = formatter
+
+    def volume_update(self, old_level: float, new_level: float):
+        """Device volume level was updated."""
+        print(
+            self.formatter(output(True, values={"volume": new_level})),
+            flush=True,
+        )
+
+
 class DevicePrinter(DeviceListener):
     """Listen for generic device updates and print them."""
 
@@ -219,10 +235,12 @@ async def _run_command(atv, args, abort_sem, loop):
 
     if args.command == "push_updates":
         power_listener = PowerPrinter(args.output)
+        audio_listener = AudioPrinter(args.output)
         device_listener = DevicePrinter(args.output, abort_sem)
         push_listener = PushPrinter(args.output, atv)
 
         atv.power.listener = power_listener
+        atv.audio.listener = audio_listener
         atv.listener = device_listener
         atv.push_updater.listener = push_listener
         atv.push_updater.start()


Test patch:
diff --git a/tests/core/test_facade.py b/tests/core/test_facade.py
index 6e7538ad..f4cc3975 100644
--- a/tests/core/test_facade.py
+++ b/tests/core/test_facade.py
@@ -27,6 +27,7 @@ from pyatv.interface import (
     AppleTV,
     Apps,
     Audio,
+    AudioListener,
     DeviceInfo,
     DeviceListener,
     FeatureInfo,
@@ -192,6 +193,17 @@ class SavingPowerListener(PowerListener):
         self.all_updates.append(new_state)
 
 
+class SavingAudioListener(AudioListener):
+    def __init__(self):
+        self.last_update = None
+        self.all_updates = []
+
+    def volume_update(self, old_level: float, new_level: float):
+        """Device volume was updated."""
+        self.last_update = new_level
+        self.all_updates.append(new_level)
+
+
 @pytest.fixture(name="register_interface")
 def register_interface_fixture(facade_dummy):
     def _register_func(feature: FeatureName, instance, protocol: Protocol):
@@ -802,6 +814,46 @@ async def test_power_no_updates_without_power_instance(
     assert listener.last_update is None
 
 
+# AUDIO RELATED TESTS
+
+
+@pytest_asyncio.fixture(name="audio_setup")
+async def audio_setup_fixture(facade_dummy, register_interface):
+    listener = SavingAudioListener()
+
+    register_basic_interfaces(register_interface, Protocol.MRP)
+
+    await facade_dummy.connect()
+    facade_dummy.audio.listener = listener
+
+    yield facade_dummy.audio
+
+
+async def dispatch_volume_update(mrp_state_dispatcher, level, protocol=Protocol.MRP):
+    event = asyncio.Event()
+
+    # Add a listener last in the last and make it set an asyncio.Event. That way we
+    # can synchronize and know that all other listeners have been called.
+    mrp_state_dispatcher.listen_to(UpdatedState.Volume, lambda message: event.set())
+    mrp_state_dispatcher.dispatch(UpdatedState.Volume, level)
+
+    await event.wait()
+
+
+async def test_audio_listener_updates(mrp_state_dispatcher, audio_setup):
+    await dispatch_volume_update(mrp_state_dispatcher, 10.0)
+    assert audio_setup.listener.last_update == 10.0
+    await dispatch_volume_update(mrp_state_dispatcher, 20.0)
+    assert audio_setup.listener.last_update == 20.0
+
+
+async def test_audio_no_listener_duplicates(mrp_state_dispatcher, audio_setup):
+    await dispatch_volume_update(mrp_state_dispatcher, 10.0)
+    await dispatch_volume_update(mrp_state_dispatcher, 10.0)
+    assert len(audio_setup.listener.all_updates) == 1
+    assert audio_setup.listener.last_update == 10.0
+
+
 # GUARD CALLS AFTER CLOSE
 
 
diff --git a/tests/protocols/mrp/test_mrp_interface.py b/tests/protocols/mrp/test_mrp_interface.py
index 8f0fdebb..d2699d90 100644
--- a/tests/protocols/mrp/test_mrp_interface.py
+++ b/tests/protocols/mrp/test_mrp_interface.py
@@ -1,9 +1,11 @@
 """Unit tests for interface implementations in pyatv.protocols.mrp."""
 import math
+from unittest.mock import Mock
 
 import pytest
 
 from pyatv import exceptions
+from pyatv.core import UpdatedState
 from pyatv.protocols.mrp import MrpAudio, messages, protobuf
 
 DEVICE_UID = "F2204E63-BCAB-4941-80A0-06C46CB71391"
@@ -73,6 +75,41 @@ async def test_audio_volume_did_change(
     assert math.isclose(audio.volume, expected_volume)
 
 
+@pytest.mark.parametrize(
+    "device_uid,volume,expect_called,expected_volume",
+    [
+        ("foo", 0.2, False, None),  # deviceUID mismatch => no update
+        (DEVICE_UID, 0.2, True, 20.0),  # deviceUID matches => update
+    ],
+)
+async def test_audio_volume_did_change_dispatches(
+    protocol_mock,
+    audio,
+    mrp_state_dispatcher,
+    device_uid,
+    volume,
+    expect_called,
+    expected_volume,
+):
+    await volume_controls_changed(protocol_mock, DEVICE_UID, True)
+    assert audio.is_available
+
+    assert math.isclose(audio.volume, 0.0)
+
+    callback = Mock()
+    mrp_state_dispatcher.listen_to(UpdatedState.Volume, callback)
+
+    message = messages.create(protobuf.VOLUME_DID_CHANGE_MESSAGE)
+    message.inner().outputDeviceUID = device_uid
+    message.inner().volume = volume
+    await protocol_mock.inject(message)
+
+    assert callback.called == expect_called
+    if expected_volume is not None:
+        message = callback.call_args.args[0]
+        assert math.isclose(message.value, expected_volume)
+
+
 async def test_audio_set_volume_only_device_info(protocol_mock, audio):
     device_info = messages.device_information("test", "id")
     device_info.inner().deviceUID = DEVICE_UID