Repository: sveltejs/svelte (JavaScript)
Date: 2018-02-03 17:33:43+00:00
Task type: Feature

Issue:
HTML <audio> volume attribute
I know that `volume` is not one of the documented bind properties for `<audio>`, but I'm surprised that setting the attribute doesn't work at all. This can be worked around with refs, but it would be handy if svelte did that for us when changing `volume`.

```html
<audio src="{{src}}" volume="{{volume}} />
```

I imagine this shouldn't be terribly difficult, I'll try to look at this as soon as I find the time.

Thank you!


Issue discussion:
Volume attributes currently compile the same way any regular attribute does:

```javascript
setAttribute(audio, "volume", state.volume);
```

Is there some corresponding property that needs to be used instead, that was missed [here](https://github.com/sveltejs/svelte/blob/56e9343294d4a83806ab4640c6f4df968a258353/src/generators/nodes/Attribute.ts)? If there is, it doesn't look like it's on [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes).
I think the element just wants to have the property set directly (`audio.volume = 0.66`), it doesn't seem like it responds to dom element attribute changes. 
Looks like it should support this?: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio#attr-volume

edit: removed some stupid stuff
~~That page doesn't mention anything about whether the volume should _update_ with the attribute updating, merely that you can set the initial state using it.~~

I tested this REPL in Firefox Nightly and Chrome, both behave the same way: https://svelte.technology/repl?version=1.54.0&gist=718ff93aa528f49030c68a988108a0c2

![image](https://user-images.githubusercontent.com/3939997/35712105-17470e12-0786-11e8-8148-d38cab16e826.png)


Here's an example with audio instead of relying on that property value: https://svelte.technology/repl?version=1.54.0&gist=7cad943bb07c698cb02d5e9efbe74621
The fix to this is probably to just implement bind for the volume property for video and audio elements, I'll try to do that soon!

Edit: Or yes, just use the `volume` property instead of setting the dom attribute, sorry for missing you mentioning that earlier!
The `<audio>` tag has no `volume` content attribute at all.

I'm surprised the MDN page doesn't show the specific types of the attributes. `volume` is an _IDL attribute_ (available via js), which is not the same as _content attributes_ (those you can set on html tags). [MDN on the difference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#Content_versus_IDL_attributes).

See [the specification](https://html.spec.whatwg.org/#the-audio-element) and [the repl without bindings](https://svelte.technology/repl?version=1.54.0&gist=fcbb94938cf117bebe34c99246be5024).

Oh interesting, I guess I misunderstood the MDN page. Thanks for clearing that up!
Not much of a misunderstanding when they completely omitted it from the page! 😄 

Solution patch:
diff --git a/src/generators/nodes/Attribute.ts b/src/generators/nodes/Attribute.ts
--- a/src/generators/nodes/Attribute.ts
+++ b/src/generators/nodes/Attribute.ts
@@ -540,6 +540,7 @@ const attributeLookup = {
 			'textarea',
 		],
 	},
+	volume: { appliesTo: ['audio', 'video'] },
 	width: {
 		appliesTo: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
 	},
diff --git a/src/generators/nodes/Binding.ts b/src/generators/nodes/Binding.ts
--- a/src/generators/nodes/Binding.ts
+++ b/src/generators/nodes/Binding.ts
@@ -73,9 +73,11 @@ export default class Binding extends Node {
 			);
 		}
 
-		if (this.name === 'currentTime') {
+		if (this.name === 'currentTime' || this.name === 'volume') {
 			updateCondition = `!isNaN(${snippet})`;
-			initialUpdate = null;
+
+			if (this.name === 'currentTime')
+				initialUpdate = null;
 		}
 
 		if (this.name === 'paused') {
@@ -267,4 +269,4 @@ function isComputed(node: Node) {
 	}
 
 	return false;
-}
\ No newline at end of file
+}
diff --git a/src/generators/nodes/Element.ts b/src/generators/nodes/Element.ts
--- a/src/generators/nodes/Element.ts
+++ b/src/generators/nodes/Element.ts
@@ -760,5 +760,11 @@ const events = [
 		filter: (node: Element, name: string) =>
 			node.isMediaNode() &&
 			(name === 'buffered' || name === 'seekable')
+	},
+	{
+		eventNames: ['volumechange'],
+		filter: (node: Element, name: string) =>
+			node.isMediaNode() &&
+			name === 'volume'
 	}
 ];
diff --git a/src/validate/html/validateElement.ts b/src/validate/html/validateElement.ts
--- a/src/validate/html/validateElement.ts
+++ b/src/validate/html/validateElement.ts
@@ -139,7 +139,8 @@ export default function validateElement(
 				name === 'paused' ||
 				name === 'buffered' ||
 				name === 'seekable' ||
-				name === 'played'
+				name === 'played' ||
+				name === 'volume'
 			) {
 				if (node.name !== 'audio' && node.name !== 'video') {
 					validator.error(


Test patch:
diff --git a/test/js/samples/media-bindings/expected-bundle.js b/test/js/samples/media-bindings/expected-bundle.js
--- a/test/js/samples/media-bindings/expected-bundle.js
+++ b/test/js/samples/media-bindings/expected-bundle.js
@@ -226,6 +226,12 @@ function create_main_fragment(state, component) {
 		component.set({ buffered: timeRangesToArray(audio.buffered), seekable: timeRangesToArray(audio.seekable) });
 	}
 
+	function audio_volumechange_handler() {
+		audio_updating = true;
+		component.set({ volume: audio.volume });
+		audio_updating = false;
+	}
+
 	return {
 		c: function create() {
 			audio = createElement("audio");
@@ -243,15 +249,19 @@ function create_main_fragment(state, component) {
 			if (!('buffered' in state)) component.root._beforecreate.push(audio_progress_handler);
 			addListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
 			if (!('buffered' in state && 'seekable' in state)) component.root._beforecreate.push(audio_loadedmetadata_handler);
+			addListener(audio, "volumechange", audio_volumechange_handler);
 		},
 
 		m: function mount(target, anchor) {
 			insertNode(audio, target, anchor);
+
+			audio.volume = state.volume;
 		},
 
 		p: function update(changed, state) {
 			if (!audio_updating && !isNaN(state.currentTime )) audio.currentTime = state.currentTime ;
-			if (!audio_updating && audio_is_paused !== (audio_is_paused = state.paused)) audio[audio_is_paused ? "pause" : "play"]();
+			if (!audio_updating && audio_is_paused !== (audio_is_paused = state.paused )) audio[audio_is_paused ? "pause" : "play"]();
+			if (!audio_updating && !isNaN(state.volume)) audio.volume = state.volume;
 		},
 
 		u: function unmount() {
@@ -265,6 +275,7 @@ function create_main_fragment(state, component) {
 			removeListener(audio, "pause", audio_play_pause_handler);
 			removeListener(audio, "progress", audio_progress_handler);
 			removeListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
+			removeListener(audio, "volumechange", audio_volumechange_handler);
 		}
 	};
 }
diff --git a/test/js/samples/media-bindings/expected.js b/test/js/samples/media-bindings/expected.js
--- a/test/js/samples/media-bindings/expected.js
+++ b/test/js/samples/media-bindings/expected.js
@@ -30,6 +30,12 @@ function create_main_fragment(state, component) {
 		component.set({ buffered: timeRangesToArray(audio.buffered), seekable: timeRangesToArray(audio.seekable) });
 	}
 
+	function audio_volumechange_handler() {
+		audio_updating = true;
+		component.set({ volume: audio.volume });
+		audio_updating = false;
+	}
+
 	return {
 		c: function create() {
 			audio = createElement("audio");
@@ -47,15 +53,19 @@ function create_main_fragment(state, component) {
 			if (!('buffered' in state)) component.root._beforecreate.push(audio_progress_handler);
 			addListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
 			if (!('buffered' in state && 'seekable' in state)) component.root._beforecreate.push(audio_loadedmetadata_handler);
+			addListener(audio, "volumechange", audio_volumechange_handler);
 		},
 
 		m: function mount(target, anchor) {
 			insertNode(audio, target, anchor);
+
+			audio.volume = state.volume;
 		},
 
 		p: function update(changed, state) {
 			if (!audio_updating && !isNaN(state.currentTime )) audio.currentTime = state.currentTime ;
-			if (!audio_updating && audio_is_paused !== (audio_is_paused = state.paused)) audio[audio_is_paused ? "pause" : "play"]();
+			if (!audio_updating && audio_is_paused !== (audio_is_paused = state.paused )) audio[audio_is_paused ? "pause" : "play"]();
+			if (!audio_updating && !isNaN(state.volume)) audio.volume = state.volume;
 		},
 
 		u: function unmount() {
@@ -69,6 +79,7 @@ function create_main_fragment(state, component) {
 			removeListener(audio, "pause", audio_play_pause_handler);
 			removeListener(audio, "progress", audio_progress_handler);
 			removeListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
+			removeListener(audio, "volumechange", audio_volumechange_handler);
 		}
 	};
 }
diff --git a/test/js/samples/media-bindings/input.html b/test/js/samples/media-bindings/input.html
--- a/test/js/samples/media-bindings/input.html
+++ b/test/js/samples/media-bindings/input.html
@@ -1 +1 @@
-<audio bind:buffered bind:seekable bind:played bind:currentTime bind:duration bind:paused/>
\ No newline at end of file
+<audio bind:buffered bind:seekable bind:played bind:currentTime bind:duration bind:paused bind:volume/>
diff --git a/test/runtime/samples/binding-audio-currenttime-duration/_config.js b/test/runtime/samples/binding-audio-currenttime-duration-volume/_config.js
similarity index 80%
rename from test/runtime/samples/binding-audio-currenttime-duration/_config.js
rename to test/runtime/samples/binding-audio-currenttime-duration-volume/_config.js
--- a/test/runtime/samples/binding-audio-currenttime-duration/_config.js
+++ b/test/runtime/samples/binding-audio-currenttime-duration-volume/_config.js
@@ -7,20 +7,25 @@ export default {
 	test ( assert, component, target, window ) {
 		assert.equal( component.get( 't' ), 0 );
 		assert.equal( component.get( 'd' ), 0 );
+		assert.equal( component.get( 'v' ), 0.5 );
 		assert.equal( component.get( 'paused' ), true );
 
 		const audio = target.querySelector( 'audio' );
 		const timeupdate = new window.Event( 'timeupdate' );
 		const durationchange = new window.Event( 'durationchange' );
+		const volumechange = new window.Event( 'volumechange' );
 
 		audio.currentTime = 10;
 		audio.duration = 20;
+		audio.volume = 0.75;
 		audio.dispatchEvent( timeupdate );
 		audio.dispatchEvent( durationchange );
+		audio.dispatchEvent( volumechange );
 		audio.play();
 
 		assert.equal( component.get( 't' ), 10 );
 		assert.equal( component.get( 'd' ), 0 ); // not 20, because read-only. Not sure how to test this!
+		assert.equal( component.get( 'v' ), 0.75 );
 		assert.equal( component.get( 'paused' ), true ); // ditto...
 		component.destroy();
 	}
diff --git a/test/runtime/samples/binding-audio-currenttime-duration-volume/main.html b/test/runtime/samples/binding-audio-currenttime-duration-volume/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/binding-audio-currenttime-duration-volume/main.html
@@ -0,0 +1,2 @@
+<audio bind:currentTime='t' bind:duration='d' bind:paused bind:volume='v'
+	src='music.mp3'></audio>
diff --git a/test/runtime/samples/binding-audio-currenttime-duration/main.html b/test/runtime/samples/binding-audio-currenttime-duration/main.html
deleted file mode 100644
--- a/test/runtime/samples/binding-audio-currenttime-duration/main.html
+++ /dev/null
@@ -1 +0,0 @@
-<audio bind:currentTime='t' bind:duration='d' bind:paused src='music.mp3'></audio>